mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
feat(release): add visual changelog comparisons
This commit is contained in:
@@ -12,6 +12,11 @@ on:
|
||||
description: 'Release notes (markdown)'
|
||||
required: true
|
||||
type: string
|
||||
release_screenshot_plan:
|
||||
description: 'Validated model-selected release-note visual plan (JSON)'
|
||||
required: false
|
||||
type: string
|
||||
default: '{"schema_version":1,"captures":[]}'
|
||||
promoted_from_tag:
|
||||
description: 'Stable only: prerelease tag being promoted (for example 6.0.0-rc.2)'
|
||||
required: false
|
||||
@@ -99,6 +104,8 @@ jobs:
|
||||
promotion_mode: ${{ steps.promotion.outputs.promotion_mode }}
|
||||
is_stable_patch: ${{ steps.promotion.outputs.is_stable_patch }}
|
||||
historical_asset_backfill_only: ${{ steps.extract.outputs.historical_asset_backfill_only }}
|
||||
visual_capture_count: ${{ steps.visual_plan.outputs.capture_count }}
|
||||
visual_comparison_tag: ${{ steps.visual_plan.outputs.comparison_tag }}
|
||||
steps:
|
||||
- name: Extract version
|
||||
id: extract
|
||||
@@ -146,6 +153,29 @@ jobs:
|
||||
echo "required_branch=${REQUIRED_BRANCH}" >> "$GITHUB_OUTPUT"
|
||||
echo "[OK] Governed release branch for ${{ steps.extract.outputs.version }} is ${REQUIRED_BRANCH}"
|
||||
|
||||
- name: Validate release-note visual plan
|
||||
id: visual_plan
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PLAN_FILE=$(mktemp)
|
||||
if ! jq -er '.inputs.release_screenshot_plan | select(type == "string" and length > 0)' \
|
||||
"$GITHUB_EVENT_PATH" > "$PLAN_FILE"; then
|
||||
printf '%s\n' '{"schema_version":1,"captures":[]}' > "$PLAN_FILE"
|
||||
fi
|
||||
python3 scripts/release_control/release_note_visuals.py \
|
||||
validate --plan "$PLAN_FILE" --output "$PLAN_FILE"
|
||||
CAPTURE_COUNT=$(python3 scripts/release_control/release_note_visuals.py \
|
||||
count --plan "$PLAN_FILE")
|
||||
COMPARISON_TAG=""
|
||||
if [ "$CAPTURE_COUNT" -gt 0 ] && \
|
||||
[ "${{ steps.extract.outputs.historical_asset_backfill_only }}" != "true" ]; then
|
||||
COMPARISON_TAG=$(./scripts/generate-release-notes.sh \
|
||||
--resolve-base "${{ steps.extract.outputs.version }}")
|
||||
fi
|
||||
echo "capture_count=${CAPTURE_COUNT}" >> "$GITHUB_OUTPUT"
|
||||
echo "comparison_tag=${COMPARISON_TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "[OK] Release-note visual plan contains ${CAPTURE_COUNT} capture(s)"
|
||||
|
||||
- name: Validate VERSION file
|
||||
if: ${{ steps.extract.outputs.historical_asset_backfill_only != 'true' }}
|
||||
run: |
|
||||
@@ -653,13 +683,54 @@ jobs:
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
|
||||
release_note_visuals:
|
||||
needs: prepare
|
||||
if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
if: ${{ needs.prepare.outputs.visual_capture_count != '0' }}
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install browser capture runtime
|
||||
if: ${{ needs.prepare.outputs.visual_capture_count != '0' }}
|
||||
run: |
|
||||
npm ci --ignore-scripts
|
||||
npx playwright install --with-deps chromium
|
||||
|
||||
- name: Capture comparison and candidate views
|
||||
if: ${{ needs.prepare.outputs.visual_capture_count != '0' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PLAN_FILE=$(mktemp)
|
||||
jq -er '.inputs.release_screenshot_plan' "$GITHUB_EVENT_PATH" > "$PLAN_FILE"
|
||||
bash scripts/capture-release-note-visuals.sh \
|
||||
"$PLAN_FILE" \
|
||||
"${{ needs.prepare.outputs.visual_comparison_tag }}" \
|
||||
release-note-visuals
|
||||
|
||||
- name: Upload release-note visual artifact
|
||||
if: ${{ needs.prepare.outputs.visual_capture_count != '0' }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: release-note-visuals-${{ github.sha }}
|
||||
path: release-note-visuals/*.png
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
create_release:
|
||||
needs:
|
||||
- prepare
|
||||
- build_release_candidate
|
||||
- release_note_visuals
|
||||
# Draft metadata and immutable assets are inert staging. Qualification is
|
||||
# joined at release_readiness before any activation boundary can open.
|
||||
if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' && always() && needs.prepare.result == 'success' && needs.build_release_candidate.result == 'success' }}
|
||||
if: ${{ needs.prepare.outputs.historical_asset_backfill_only != 'true' && always() && needs.prepare.result == 'success' && needs.build_release_candidate.result == 'success' && needs.release_note_visuals.result == 'success' }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
@@ -689,6 +760,13 @@ jobs:
|
||||
name: ${{ needs.build_release_candidate.outputs.manifest_artifact_name }}
|
||||
path: release-candidate-manifest
|
||||
|
||||
- name: Download release-note visuals
|
||||
if: ${{ needs.prepare.outputs.visual_capture_count != '0' }}
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: release-note-visuals-${{ github.sha }}
|
||||
path: release-note-visuals
|
||||
|
||||
- name: Verify immutable release candidate
|
||||
run: |
|
||||
python3 scripts/release_candidate_manifest.py verify-local \
|
||||
@@ -715,9 +793,21 @@ jobs:
|
||||
fi
|
||||
|
||||
RENDERED_NOTES_FILE=$(mktemp)
|
||||
VISUAL_PLAN_FILE=$(mktemp)
|
||||
VISUAL_MARKDOWN_FILE=$(mktemp)
|
||||
if ! jq -er '.inputs.release_screenshot_plan | select(type == "string" and length > 0)' \
|
||||
"$GITHUB_EVENT_PATH" > "$VISUAL_PLAN_FILE"; then
|
||||
printf '%s\n' '{"schema_version":1,"captures":[]}' > "$VISUAL_PLAN_FILE"
|
||||
fi
|
||||
python3 scripts/release_control/release_note_visuals.py render \
|
||||
--plan "$VISUAL_PLAN_FILE" \
|
||||
--repository "${{ github.repository }}" \
|
||||
--tag "${{ needs.prepare.outputs.tag }}" \
|
||||
--output "$VISUAL_MARKDOWN_FILE"
|
||||
python3 scripts/release_control/render_release_body.py \
|
||||
--version "$VERSION" \
|
||||
--release-notes-file "$NOTES_FILE" \
|
||||
--release-visuals-file "$VISUAL_MARKDOWN_FILE" \
|
||||
--output "$RENDERED_NOTES_FILE" \
|
||||
--promotion-channel "${{ needs.prepare.outputs.is_prerelease == 'true' && 'rc' || 'stable' }}" \
|
||||
--candidate-tag "${{ needs.prepare.outputs.tag }}" \
|
||||
@@ -934,6 +1024,41 @@ jobs:
|
||||
release_upload_with_retry "${TAG}" release/*.sshsig --clobber
|
||||
fi
|
||||
|
||||
- name: Upload release-note visuals
|
||||
if: ${{ needs.prepare.outputs.visual_capture_count != '0' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${{ needs.prepare.outputs.tag }}"
|
||||
PLAN_FILE=$(mktemp)
|
||||
jq -er '.inputs.release_screenshot_plan' "$GITHUB_EVENT_PATH" > "$PLAN_FILE"
|
||||
release_upload_with_retry() {
|
||||
local attempt=1
|
||||
local max_attempts=5
|
||||
local wait_seconds=15
|
||||
while true; do
|
||||
if gh release upload "$@"; then
|
||||
return 0
|
||||
fi
|
||||
if [ "$attempt" -ge "$max_attempts" ]; then
|
||||
echo "::error::gh release upload failed after ${max_attempts} attempts: $*"
|
||||
return 1
|
||||
fi
|
||||
sleep "$wait_seconds"
|
||||
attempt=$((attempt + 1))
|
||||
wait_seconds=$((wait_seconds * 2))
|
||||
if [ "$wait_seconds" -gt 120 ]; then
|
||||
wait_seconds=120
|
||||
fi
|
||||
done
|
||||
}
|
||||
while IFS= read -r asset_name; do
|
||||
test -f "release-note-visuals/${asset_name}"
|
||||
release_upload_with_retry "$TAG" "release-note-visuals/${asset_name}" --clobber
|
||||
done < <(python3 scripts/release_control/release_note_visuals.py \
|
||||
assets --plan "$PLAN_FILE")
|
||||
|
||||
- name: Upload release assets
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
@@ -1602,6 +1727,19 @@ jobs:
|
||||
curl -fsSL --retry 12 --retry-delay 5 --retry-all-errors \
|
||||
-o /dev/null "${base}/${asset_name}"
|
||||
done
|
||||
visual_plan=$(mktemp)
|
||||
if jq -er '.inputs.release_screenshot_plan | select(type == "string" and length > 0)' \
|
||||
"$GITHUB_EVENT_PATH" > "$visual_plan"; then
|
||||
while IFS= read -r asset_name; do
|
||||
curl -fsSL --retry 12 --retry-delay 5 --retry-all-errors \
|
||||
-o /dev/null "${base}/${asset_name}"
|
||||
done < <(jq -r '
|
||||
.captures[] |
|
||||
(if .before == null then empty else "release-note-\(.id)-before.png" end),
|
||||
"release-note-\(.id)-now.png"
|
||||
' "$visual_plan")
|
||||
fi
|
||||
rm -f "$visual_plan"
|
||||
|
||||
# Close the dispatch-to-commit race: the exact durable convergence
|
||||
# owner must still be queued or running immediately before the marker
|
||||
|
||||
@@ -51,6 +51,17 @@ New customer release notes use plain punctuation and fail validation when they
|
||||
contain a semicolon or em dash. Already-published packets remain historical
|
||||
artifacts and retain the punctuation with which they were released.
|
||||
|
||||
Visual release-note evidence follows the same model-led boundary. After the
|
||||
customer story is settled, a model may select no visual views or a bounded set
|
||||
of views that materially improve it. The harness supplies only safe same-origin
|
||||
navigation, accessible click and wait actions, deterministic generated data,
|
||||
and identical rendering of the channel-specific comparison tag and candidate.
|
||||
It does not prescribe product areas or visual themes. Selected before and now
|
||||
images are staged as draft release assets, linked from a `See the difference`
|
||||
section, and must be publicly retrievable before the activation marker commits
|
||||
publication. A current-only image is permitted when a truthful before view is
|
||||
not available.
|
||||
|
||||
Customer-facing notes use one outcome list for features and fixes. Each visible
|
||||
change is described once under `What's improved`; a parallel `Fixes` section is
|
||||
forbidden for packets from `v6.4.0-rc.6` onward because it encourages the same
|
||||
@@ -153,6 +164,9 @@ release-latency optimization.
|
||||
39. `scripts/run-release-backend-tests.sh`
|
||||
40. `scripts/shard_go_tests.py`
|
||||
37. `scripts/generate-release-notes.sh`
|
||||
37a. `scripts/capture-release-note-visuals.sh`
|
||||
37b. `scripts/release_control/capture_release_note_visuals.mjs`
|
||||
37c. `scripts/release_control/release_note_visuals.py`
|
||||
37. `scripts/check-workflow-dispatch-inputs.py`
|
||||
38. `scripts/clean-mock-alerts.sh`
|
||||
39. `scripts/com.pulse.hot-dev.plist.template`
|
||||
@@ -549,7 +563,7 @@ artifact-selection behaviour.
|
||||
## Extension Points
|
||||
|
||||
1. Add or change deployment-type detection, update planning, or apply behavior through `internal/updates/`
|
||||
2. Add or change release-build metadata injection, Docker build-context allowlists, release artifact assembly, governed promotion metadata resolution, artifact release-line validation, post-install live-runtime claim proof, the canonical version file, operator-facing release packet content, prerelease feedback intake wording, historical published-release integrity backfill, release asset validation status publication, download endpoint checksum/signature header proof, end-to-end install.sh smoke against staged or published release assets, or the canonical in-repo v6 upgrade guide through `scripts/build-release.sh`, `scripts/build-release-binaries.sh`, `scripts/release_build_targets.sh`, `scripts/run-release-backend-tests.sh`, `scripts/shard_go_tests.py`, `scripts/release_asset_common.sh`, `scripts/backfill-release-assets.sh`, `scripts/release_ldflags.sh`, `scripts/check-workflow-dispatch-inputs.py`, `scripts/release-preflight-worker.sh`, `scripts/run-release-preflight.sh`, `scripts/release_control/live_runtime_proof.py`, `scripts/release_control/live_runtime_proof_test.py`, `scripts/release_control/mobile_release_gate.py`, `scripts/release_control/render_release_body.py`, `scripts/release_control/resolve_release_promotion.py`, `scripts/release_control/validate_artifact_release_line.py`, `scripts/release_control/record_rc_to_ga_rehearsal.py`, `scripts/release_control/internal/record_rc_to_ga_rehearsal.py`, `scripts/release_control/release_promotion_policy_support.py`, `pulse-enterprise:scripts/build-pro-binaries.sh`, `pulse-enterprise:scripts/build-pro-release.sh`, `pulse-enterprise:scripts/validate-pro-release-line.sh`, `.dockerignore`, `Dockerfile`, `.github/ISSUE_TEMPLATE/v6_rc_feedback.yml`, `docs/RELEASE_NOTES.md`, `docs/releases/`, `docs/UPGRADE_v6.md`, `docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md`, `docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md`, `docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md`, `scripts/validate-release.sh`, `scripts/validate-published-release.sh`, the operator dispatch helpers `scripts/trigger-release.sh` and `scripts/trigger-release-dry-run.sh`, and the governed release workflows `.github/workflows/backfill-release-assets.yml`, `.github/workflows/build-release-candidate.yml`, `.github/workflows/create-release.yml`, `.github/workflows/deploy-demo-server.yml`, `.github/workflows/helm-pages.yml`, `.github/workflows/install-sh-smoke.yml`, `.github/workflows/promote-floating-tags.yml`, `.github/workflows/promote-private-pro-runtime.yml`, `.github/workflows/publish-docker.yml`, `.github/workflows/publish-helm-chart.yml`, `.github/workflows/release-convergence.yml`, `.github/workflows/release-dry-run.yml`, `.github/workflows/retry-release-convergence.yml`, `.github/workflows/update-demo-server.yml`, `.github/workflows/validate-release-assets.yml`, and `pulse-enterprise:.github/workflows/build-pro-release.yml`
|
||||
2. Add or change release-build metadata injection, Docker build-context allowlists, release artifact assembly, governed promotion metadata resolution, artifact release-line validation, post-install live-runtime claim proof, the canonical version file, operator-facing release packet content, model-selected visual release-note capture, prerelease feedback intake wording, historical published-release integrity backfill, release asset validation status publication, download endpoint checksum/signature header proof, end-to-end install.sh smoke against staged or published release assets, or the canonical in-repo v6 upgrade guide through `scripts/build-release.sh`, `scripts/build-release-binaries.sh`, `scripts/release_build_targets.sh`, `scripts/run-release-backend-tests.sh`, `scripts/shard_go_tests.py`, `scripts/release_asset_common.sh`, `scripts/backfill-release-assets.sh`, `scripts/release_ldflags.sh`, `scripts/check-workflow-dispatch-inputs.py`, `scripts/capture-release-note-visuals.sh`, `scripts/release-preflight-worker.sh`, `scripts/run-release-preflight.sh`, `scripts/release_control/capture_release_note_visuals.mjs`, `scripts/release_control/release_note_visuals.py`, `scripts/release_control/live_runtime_proof.py`, `scripts/release_control/live_runtime_proof_test.py`, `scripts/release_control/mobile_release_gate.py`, `scripts/release_control/render_release_body.py`, `scripts/release_control/resolve_release_promotion.py`, `scripts/release_control/validate_artifact_release_line.py`, `scripts/release_control/record_rc_to_ga_rehearsal.py`, `scripts/release_control/internal/record_rc_to_ga_rehearsal.py`, `scripts/release_control/release_promotion_policy_support.py`, `pulse-enterprise:scripts/build-pro-binaries.sh`, `pulse-enterprise:scripts/build-pro-release.sh`, `pulse-enterprise:scripts/validate-pro-release-line.sh`, `.dockerignore`, `Dockerfile`, `.github/ISSUE_TEMPLATE/v6_rc_feedback.yml`, `docs/RELEASE_NOTES.md`, `docs/releases/`, `docs/UPGRADE_v6.md`, `docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md`, `docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md`, `docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md`, `scripts/validate-release.sh`, `scripts/validate-published-release.sh`, the operator dispatch helpers `scripts/trigger-release.sh` and `scripts/trigger-release-dry-run.sh`, and the governed release workflows `.github/workflows/backfill-release-assets.yml`, `.github/workflows/build-release-candidate.yml`, `.github/workflows/create-release.yml`, `.github/workflows/deploy-demo-server.yml`, `.github/workflows/helm-pages.yml`, `.github/workflows/install-sh-smoke.yml`, `.github/workflows/promote-floating-tags.yml`, `.github/workflows/promote-private-pro-runtime.yml`, `.github/workflows/publish-docker.yml`, `.github/workflows/publish-helm-chart.yml`, `.github/workflows/release-convergence.yml`, `.github/workflows/release-dry-run.yml`, `.github/workflows/retry-release-convergence.yml`, `.github/workflows/update-demo-server.yml`, `.github/workflows/validate-release-assets.yml`, and `pulse-enterprise:.github/workflows/build-pro-release.yml`
|
||||
The governed release-build surface also includes
|
||||
`scripts/prepare-release-container-context.sh` for exact-candidate container
|
||||
assembly.
|
||||
@@ -1846,8 +1860,7 @@ persistence. The rc.11 corrective set extends that boundary to token creation,
|
||||
agent-install issuance, and agent-removal revocation; corrects version-specific
|
||||
Anthropic cost estimates; merges complete cross-source disk identities; retains
|
||||
uniquely resolved dismissed TrueNAS SMART risk; and stabilizes narrow alert
|
||||
investigation, shared chart interaction, and responsive inline View preferences.
|
||||
It also checkpoints active-state
|
||||
investigation plus shared chart interaction. It also checkpoints active-state
|
||||
recovery synchronously after durable lifecycle failure and prevents any Pulse
|
||||
host interface from satisfying an external dead-man signal. The changes since
|
||||
`v6.4.0-rc.6` add the canonical `alert_fired`
|
||||
|
||||
Executable
+124
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
PLAN_FILE=${1:-}
|
||||
COMPARISON_TAG=${2:-}
|
||||
OUTPUT_DIR=${3:-}
|
||||
|
||||
if [ -z "$PLAN_FILE" ] || [ -z "$COMPARISON_TAG" ] || [ -z "$OUTPUT_DIR" ]; then
|
||||
echo "Usage: $0 <visual-plan.json> <comparison-tag> <output-directory>" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
ROOT_DIR=$(git rev-parse --show-toplevel)
|
||||
PLAN_FILE=$(cd "$(dirname "$PLAN_FILE")" && pwd)/$(basename "$PLAN_FILE")
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
OUTPUT_DIR=$(cd "$OUTPUT_DIR" && pwd)
|
||||
|
||||
python3 "$ROOT_DIR/scripts/release_control/release_note_visuals.py" \
|
||||
validate --plan "$PLAN_FILE" >/dev/null
|
||||
CAPTURE_COUNT=$(python3 "$ROOT_DIR/scripts/release_control/release_note_visuals.py" \
|
||||
count --plan "$PLAN_FILE")
|
||||
if [ "$CAPTURE_COUNT" = "0" ]; then
|
||||
exit 0
|
||||
fi
|
||||
BEFORE_CAPTURE_COUNT=$(python3 "$ROOT_DIR/scripts/release_control/release_note_visuals.py" \
|
||||
before-count --plan "$PLAN_FILE")
|
||||
|
||||
if ! git rev-parse -q --verify "${COMPARISON_TAG}^{commit}" >/dev/null; then
|
||||
echo "Comparison tag ${COMPARISON_TAG} does not exist" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TEMP_ROOT=$(mktemp -d)
|
||||
PREVIOUS_TREE="$TEMP_ROOT/previous"
|
||||
RUN_KEY="${GITHUB_RUN_ID:-$$}-${GITHUB_RUN_ATTEMPT:-1}"
|
||||
BEFORE_IMAGE="pulse-release-visual-before-${RUN_KEY}"
|
||||
AFTER_IMAGE="pulse-release-visual-after-${RUN_KEY}"
|
||||
BEFORE_CONTAINER="pulse-release-visual-before-${RUN_KEY}"
|
||||
AFTER_CONTAINER="pulse-release-visual-after-${RUN_KEY}"
|
||||
BEFORE_PORT=${PULSE_RELEASE_VISUAL_BEFORE_PORT:-17655}
|
||||
AFTER_PORT=${PULSE_RELEASE_VISUAL_AFTER_PORT:-17656}
|
||||
|
||||
cleanup() {
|
||||
docker rm -f "$BEFORE_CONTAINER" "$AFTER_CONTAINER" >/dev/null 2>&1 || true
|
||||
docker image rm -f "$BEFORE_IMAGE" "$AFTER_IMAGE" >/dev/null 2>&1 || true
|
||||
git -C "$ROOT_DIR" worktree remove --force "$PREVIOUS_TREE" >/dev/null 2>&1 || true
|
||||
rm -rf "$TEMP_ROOT"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
build_visual_image() {
|
||||
local source_dir=$1
|
||||
local image_name=$2
|
||||
docker build \
|
||||
--target e2e_runtime \
|
||||
--build-arg BUILD_AGENT=0 \
|
||||
--build-arg GO_BUILD_TAGS= \
|
||||
--tag "$image_name" \
|
||||
"$source_dir"
|
||||
}
|
||||
|
||||
if [ "$BEFORE_CAPTURE_COUNT" -gt 0 ]; then
|
||||
git -C "$ROOT_DIR" worktree add --detach "$PREVIOUS_TREE" "$COMPARISON_TAG"
|
||||
build_visual_image "$PREVIOUS_TREE" "$BEFORE_IMAGE"
|
||||
fi
|
||||
build_visual_image "$ROOT_DIR" "$AFTER_IMAGE"
|
||||
|
||||
start_visual_container() {
|
||||
local image_name=$1
|
||||
local container_name=$2
|
||||
local port=$3
|
||||
docker run --detach --name "$container_name" \
|
||||
--publish "127.0.0.1:${port}:7655" \
|
||||
--env PULSE_MOCK_MODE=true \
|
||||
--env PULSE_MOCK_RANDOM_METRICS=false \
|
||||
--env PULSE_MOCK_TRENDS_SEED_DURATION=24h \
|
||||
--env PULSE_AUTH_USER=admin \
|
||||
--env PULSE_AUTH_PASS=adminadminadmin \
|
||||
--env PULSE_DEV=true \
|
||||
"$image_name" >/dev/null
|
||||
}
|
||||
|
||||
wait_for_runtime() {
|
||||
local port=$1
|
||||
local container_name=$2
|
||||
local attempts=60
|
||||
while [ "$attempts" -gt 0 ]; do
|
||||
if curl -fsS "http://127.0.0.1:${port}/api/health" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
if [ "$(docker inspect --format '{{.State.Running}}' "$container_name" 2>/dev/null || true)" != "true" ]; then
|
||||
docker logs "$container_name" >&2 || true
|
||||
return 1
|
||||
fi
|
||||
sleep 2
|
||||
attempts=$((attempts - 1))
|
||||
done
|
||||
docker logs "$container_name" >&2 || true
|
||||
echo "Timed out waiting for ${container_name}" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
if [ "$BEFORE_CAPTURE_COUNT" -gt 0 ]; then
|
||||
start_visual_container "$BEFORE_IMAGE" "$BEFORE_CONTAINER" "$BEFORE_PORT"
|
||||
fi
|
||||
start_visual_container "$AFTER_IMAGE" "$AFTER_CONTAINER" "$AFTER_PORT"
|
||||
if [ "$BEFORE_CAPTURE_COUNT" -gt 0 ]; then
|
||||
wait_for_runtime "$BEFORE_PORT" "$BEFORE_CONTAINER"
|
||||
fi
|
||||
wait_for_runtime "$AFTER_PORT" "$AFTER_CONTAINER"
|
||||
|
||||
node "$ROOT_DIR/scripts/release_control/capture_release_note_visuals.mjs" \
|
||||
"$PLAN_FILE" \
|
||||
"http://127.0.0.1:${BEFORE_PORT}" \
|
||||
"http://127.0.0.1:${AFTER_PORT}" \
|
||||
"$OUTPUT_DIR"
|
||||
|
||||
while IFS= read -r asset_name; do
|
||||
[ -f "$OUTPUT_DIR/$asset_name" ] || {
|
||||
echo "Expected visual asset was not produced: $asset_name" >&2
|
||||
exit 1
|
||||
}
|
||||
done < <(python3 "$ROOT_DIR/scripts/release_control/release_note_visuals.py" \
|
||||
assets --plan "$PLAN_FILE")
|
||||
@@ -10,6 +10,7 @@
|
||||
#
|
||||
# Usage: ./scripts/generate-release-notes.sh <version> [comparison-tag]
|
||||
# ./scripts/generate-release-notes.sh --resolve-base <version>
|
||||
# ./scripts/generate-release-notes.sh --visual-plan <version> <notes-file>
|
||||
#
|
||||
# Contract: the release notes markdown is written to STDOUT (trigger-release.sh
|
||||
# captures it); all progress/diagnostics go to STDERR. SAVE_TO_FILE=1 also
|
||||
@@ -19,6 +20,7 @@
|
||||
# RELEASE_NOTES_ENGINE=claude|codex force an engine (default: claude, codex fallback)
|
||||
# RELEASE_NOTES_MODEL=<model> model for the claude engine (default: opus)
|
||||
# RELEASE_NOTES_TRACE_DIR=<directory> retain each pass for inspection
|
||||
# RELEASE_NOTE_VISUAL_PLAN_FILE=<path> write a validated optional visual plan
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -26,18 +28,33 @@ MODE=generate
|
||||
if [ "${1:-}" = "--resolve-base" ]; then
|
||||
MODE=resolve-base
|
||||
shift
|
||||
elif [ "${1:-}" = "--visual-plan" ]; then
|
||||
MODE=visual-plan
|
||||
shift
|
||||
fi
|
||||
|
||||
VERSION=${1:-}
|
||||
REQUESTED_COMPARISON_TAG=${2:-}
|
||||
VISUAL_NOTES_FILE=""
|
||||
if [ "$MODE" = "visual-plan" ]; then
|
||||
VISUAL_NOTES_FILE=${2:-}
|
||||
REQUESTED_COMPARISON_TAG=${3:-}
|
||||
else
|
||||
REQUESTED_COMPARISON_TAG=${2:-}
|
||||
fi
|
||||
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "Usage: $0 <version> [comparison-tag]" >&2
|
||||
echo " $0 --resolve-base <version>" >&2
|
||||
echo " $0 --visual-plan <version> <notes-file> [comparison-tag]" >&2
|
||||
echo "Example: $0 6.4.0-rc.6" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$MODE" = "visual-plan" ] && [ ! -s "$VISUAL_NOTES_FILE" ]; then
|
||||
echo "Visual planning requires a non-empty release-notes file" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
|
||||
VERSION=${VERSION#v}
|
||||
@@ -107,7 +124,9 @@ else
|
||||
RELEASE_RANGE_GUIDANCE="This stable release covers ${PREVIOUS_TAG}..HEAD. The same-version RC packets under docs/releases/ are available evidence."
|
||||
fi
|
||||
|
||||
echo "Generating release notes for v${VERSION} (changes since ${PREVIOUS_TAG})..." >&2
|
||||
if [ "$MODE" = "generate" ]; then
|
||||
echo "Generating release notes for v${VERSION} (changes since ${PREVIOUS_TAG})..." >&2
|
||||
fi
|
||||
|
||||
read -r -d '' RESEARCH_PROMPT <<EOF || true
|
||||
Create a comprehensive factual account of the user-observable differences in
|
||||
@@ -217,6 +236,99 @@ save_trace() {
|
||||
printf '%s\n' "$content" > "$RELEASE_NOTES_TRACE_DIR/$name"
|
||||
}
|
||||
|
||||
clean_visual_plan() {
|
||||
sed -e 's/^```json$//' -e 's/^```$//'
|
||||
}
|
||||
|
||||
generate_visual_plan() {
|
||||
local notes=$1
|
||||
local plan validation_error
|
||||
read -r -d '' VISUAL_PROMPT <<EOF || true
|
||||
Decide whether screenshots would materially improve the customer release notes
|
||||
for Pulse v${VERSION}. The comparison range is ${PREVIOUS_TAG}..HEAD.
|
||||
|
||||
Use the available read-only repository and GitHub tools as you judge useful to
|
||||
understand both source states and identify views that communicate the release
|
||||
better than text alone. Select no views when screenshots would add little. The
|
||||
capture system can open same-origin routes and use accessible click or wait
|
||||
steps. It will render the exact comparison tag and current HEAD with identical
|
||||
generated demo data.
|
||||
|
||||
Return only JSON in this shape, with at most three captures:
|
||||
{
|
||||
"schema_version": 1,
|
||||
"captures": [
|
||||
{
|
||||
"id": "lower-case-hyphenated-id",
|
||||
"title": "Short customer-facing title",
|
||||
"description": "Why this visible difference matters",
|
||||
"viewport": {"width": 1440, "height": 900},
|
||||
"before": {
|
||||
"route": "/same-origin-path",
|
||||
"steps": [
|
||||
{
|
||||
"action": "click",
|
||||
"locator": {
|
||||
"kind": "role",
|
||||
"role": "button",
|
||||
"name": "Accessible name",
|
||||
"exact": true,
|
||||
"nth": 0
|
||||
}
|
||||
}
|
||||
],
|
||||
"ready": {"kind": "text", "value": "Visible text", "exact": true, "nth": 0}
|
||||
},
|
||||
"after": {
|
||||
"route": "/same-origin-path",
|
||||
"steps": [],
|
||||
"ready": {"kind": "text", "value": "Visible text", "exact": true, "nth": 0}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
The before state may be null when a truthful comparison is unavailable and a
|
||||
current-state image is still useful. Locator kind may be role, text, label, or
|
||||
testid. Actions may be click or wait. Role locators use role and name. Other
|
||||
locators use value. Every state needs a ready locator for content that must be
|
||||
visible in the finished image. Use no semicolon or em dash characters in public
|
||||
text.
|
||||
|
||||
Customer release notes:
|
||||
|
||||
${notes}
|
||||
EOF
|
||||
|
||||
echo "Selecting useful release-note visuals..." >&2
|
||||
plan=$(generate_notes "$VISUAL_PROMPT") || return 1
|
||||
plan=$(printf '%s\n' "$plan" | clean_visual_plan)
|
||||
if ! validation_error=$(printf '%s\n' "$plan" | \
|
||||
python3 scripts/release_control/release_note_visuals.py validate --plan - 2>&1); then
|
||||
echo "Visual plan failed validation; requesting one constrained revision..." >&2
|
||||
echo "$validation_error" >&2
|
||||
read -r -d '' VISUAL_REPAIR_PROMPT <<EOF || true
|
||||
Correct the JSON visual plan below so it passes this exact validation error:
|
||||
|
||||
${validation_error}
|
||||
|
||||
Preserve the selected views and their meaning. Reply only with corrected JSON.
|
||||
|
||||
${plan}
|
||||
EOF
|
||||
plan=$(generate_notes "$VISUAL_REPAIR_PROMPT") || return 1
|
||||
plan=$(printf '%s\n' "$plan" | clean_visual_plan)
|
||||
fi
|
||||
printf '%s\n' "$plan" | \
|
||||
python3 scripts/release_control/release_note_visuals.py validate --plan -
|
||||
}
|
||||
|
||||
if [ "$MODE" = "visual-plan" ]; then
|
||||
VISUAL_NOTES=$(cat "$VISUAL_NOTES_FILE")
|
||||
generate_visual_plan "$VISUAL_NOTES"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Researching the complete release range..." >&2
|
||||
RESEARCH_BRIEF=$(generate_notes "$RESEARCH_PROMPT") || exit 1
|
||||
|
||||
@@ -321,3 +433,8 @@ if [ "${SAVE_TO_FILE:-}" = "1" ]; then
|
||||
printf '%s\n' "$RELEASE_NOTES" > "$OUTPUT_FILE"
|
||||
echo "Saved to ${OUTPUT_FILE}" >&2
|
||||
fi
|
||||
|
||||
if [ -n "${RELEASE_NOTE_VISUAL_PLAN_FILE:-}" ]; then
|
||||
generate_visual_plan "$RELEASE_NOTES" > "$RELEASE_NOTE_VISUAL_PLAN_FILE"
|
||||
echo "Visual plan saved to ${RELEASE_NOTE_VISUAL_PLAN_FILE}" >&2
|
||||
fi
|
||||
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { chromium } from '@playwright/test';
|
||||
|
||||
const [, , planPath, beforeBaseURL, afterBaseURL, outputDirectory] = process.argv;
|
||||
if (!planPath || !beforeBaseURL || !afterBaseURL || !outputDirectory) {
|
||||
throw new Error(
|
||||
'usage: capture_release_note_visuals.mjs PLAN BEFORE_BASE_URL AFTER_BASE_URL OUTPUT_DIRECTORY',
|
||||
);
|
||||
}
|
||||
|
||||
const plan = JSON.parse(await fs.readFile(planPath, 'utf8'));
|
||||
await fs.mkdir(outputDirectory, { recursive: true });
|
||||
|
||||
const username = process.env.PULSE_RELEASE_VISUAL_USERNAME || 'admin';
|
||||
const password = process.env.PULSE_RELEASE_VISUAL_PASSWORD || 'adminadminadmin';
|
||||
|
||||
function sameOriginURL(route, baseURL) {
|
||||
const base = new URL(baseURL);
|
||||
const destination = new URL(route, base);
|
||||
if (destination.origin !== base.origin) {
|
||||
throw new Error(`capture route escaped the application origin: ${route}`);
|
||||
}
|
||||
return destination.toString();
|
||||
}
|
||||
|
||||
function locatorFor(page, descriptor) {
|
||||
let locator;
|
||||
switch (descriptor.kind) {
|
||||
case 'role':
|
||||
locator = page.getByRole(descriptor.role, {
|
||||
name: descriptor.name,
|
||||
exact: descriptor.exact,
|
||||
});
|
||||
break;
|
||||
case 'text':
|
||||
locator = page.getByText(descriptor.value, { exact: descriptor.exact });
|
||||
break;
|
||||
case 'label':
|
||||
locator = page.getByLabel(descriptor.value, { exact: descriptor.exact });
|
||||
break;
|
||||
case 'testid':
|
||||
locator = page.getByTestId(descriptor.value);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`unsupported locator kind: ${descriptor.kind}`);
|
||||
}
|
||||
return locator.nth(descriptor.nth || 0);
|
||||
}
|
||||
|
||||
async function authenticate(page, baseURL) {
|
||||
await page.goto(new URL('/', baseURL).toString(), { waitUntil: 'domcontentloaded' });
|
||||
const usernameInput = page.locator('input[name="username"]');
|
||||
if (await usernameInput.isVisible({ timeout: 15_000 }).catch(() => false)) {
|
||||
await usernameInput.fill(username);
|
||||
await page.locator('input[name="password"]').fill(password);
|
||||
await page.locator('button[type="submit"]').click();
|
||||
}
|
||||
await page
|
||||
.locator('input[name="username"]')
|
||||
.waitFor({ state: 'hidden', timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function captureState(browser, baseURL, capture, state, suffix) {
|
||||
const context = await browser.newContext({
|
||||
viewport: capture.viewport,
|
||||
colorScheme: 'dark',
|
||||
reducedMotion: 'reduce',
|
||||
locale: 'en-GB',
|
||||
timezoneId: 'UTC',
|
||||
});
|
||||
const page = await context.newPage();
|
||||
try {
|
||||
await authenticate(page, baseURL);
|
||||
const response = await page.goto(sameOriginURL(state.route, baseURL), {
|
||||
waitUntil: 'domcontentloaded',
|
||||
});
|
||||
if (!response || !response.ok()) {
|
||||
throw new Error(`capture route did not load successfully: ${state.route}`);
|
||||
}
|
||||
for (const step of state.steps || []) {
|
||||
const locator = locatorFor(page, step.locator);
|
||||
if (step.action === 'click') {
|
||||
await locator.click({ timeout: 15_000 });
|
||||
} else {
|
||||
await locator.waitFor({ state: 'visible', timeout: 15_000 });
|
||||
}
|
||||
}
|
||||
await locatorFor(page, state.ready).waitFor({ state: 'visible', timeout: 20_000 });
|
||||
await page.waitForTimeout(750);
|
||||
const outputPath = path.join(
|
||||
outputDirectory,
|
||||
`release-note-${capture.id}-${suffix}.png`,
|
||||
);
|
||||
await page.screenshot({
|
||||
path: outputPath,
|
||||
fullPage: false,
|
||||
animations: 'disabled',
|
||||
caret: 'hide',
|
||||
});
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
for (const capture of plan.captures) {
|
||||
if (capture.before) {
|
||||
await captureState(browser, beforeBaseURL, capture, capture.before, 'before');
|
||||
}
|
||||
await captureState(browser, afterBaseURL, capture, capture.after, 'now');
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
+299
@@ -0,0 +1,299 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate and render the model-selected visual release-note plan."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
MAX_CAPTURES = 3
|
||||
MAX_STEPS = 12
|
||||
ALLOWED_LOCATOR_KINDS = {"role", "text", "label", "testid"}
|
||||
ALLOWED_ACTIONS = {"click", "wait"}
|
||||
ALLOWED_ROLES = {
|
||||
"button",
|
||||
"checkbox",
|
||||
"dialog",
|
||||
"heading",
|
||||
"link",
|
||||
"menuitem",
|
||||
"option",
|
||||
"radio",
|
||||
"row",
|
||||
"tab",
|
||||
}
|
||||
ID_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
||||
|
||||
|
||||
class PlanError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _text(value: Any, field: str, *, maximum: int, required: bool = True) -> str:
|
||||
if not isinstance(value, str):
|
||||
raise PlanError(f"{field} must be a string")
|
||||
value = value.strip()
|
||||
if required and not value:
|
||||
raise PlanError(f"{field} must not be empty")
|
||||
if len(value) > maximum:
|
||||
raise PlanError(f"{field} must be {maximum} characters or fewer")
|
||||
if any(ord(character) < 32 for character in value):
|
||||
raise PlanError(f"{field} must be one line without control characters")
|
||||
if ";" in value or "\u2014" in value:
|
||||
raise PlanError(f"{field} must not contain semicolons or em dashes")
|
||||
return value
|
||||
|
||||
|
||||
def _public_text(value: Any, field: str, *, maximum: int, required: bool = True) -> str:
|
||||
value = _text(value, field, maximum=maximum, required=required)
|
||||
if any(character in value for character in ("[", "]", "<", ">", "|")):
|
||||
raise PlanError(f"{field} must be plain text without Markdown or HTML delimiters")
|
||||
return value
|
||||
|
||||
|
||||
def _locator(value: Any, field: str) -> dict[str, Any]:
|
||||
if not isinstance(value, dict):
|
||||
raise PlanError(f"{field} must be an object")
|
||||
allowed = {"kind", "value", "role", "name", "exact", "nth"}
|
||||
unknown = set(value) - allowed
|
||||
if unknown:
|
||||
raise PlanError(f"{field} has unsupported fields: {', '.join(sorted(unknown))}")
|
||||
kind = value.get("kind")
|
||||
if kind not in ALLOWED_LOCATOR_KINDS:
|
||||
raise PlanError(f"{field}.kind must be one of {', '.join(sorted(ALLOWED_LOCATOR_KINDS))}")
|
||||
normalized: dict[str, Any] = {"kind": kind}
|
||||
if kind == "role":
|
||||
role = _text(value.get("role"), f"{field}.role", maximum=32)
|
||||
if role not in ALLOWED_ROLES:
|
||||
raise PlanError(f"{field}.role is not supported")
|
||||
normalized["role"] = role
|
||||
normalized["name"] = _text(value.get("name"), f"{field}.name", maximum=120)
|
||||
else:
|
||||
normalized["value"] = _text(value.get("value"), f"{field}.value", maximum=160)
|
||||
exact = value.get("exact", True)
|
||||
if not isinstance(exact, bool):
|
||||
raise PlanError(f"{field}.exact must be a boolean")
|
||||
normalized["exact"] = exact
|
||||
nth = value.get("nth", 0)
|
||||
if not isinstance(nth, int) or isinstance(nth, bool) or not 0 <= nth <= 20:
|
||||
raise PlanError(f"{field}.nth must be an integer from 0 to 20")
|
||||
normalized["nth"] = nth
|
||||
return normalized
|
||||
|
||||
|
||||
def _state(value: Any, field: str) -> dict[str, Any]:
|
||||
if not isinstance(value, dict):
|
||||
raise PlanError(f"{field} must be an object")
|
||||
unknown = set(value) - {"route", "steps", "ready"}
|
||||
if unknown:
|
||||
raise PlanError(f"{field} has unsupported fields: {', '.join(sorted(unknown))}")
|
||||
route = _text(value.get("route"), f"{field}.route", maximum=240)
|
||||
if (
|
||||
not route.startswith("/")
|
||||
or route.startswith("//")
|
||||
or "://" in route
|
||||
or "\\" in route
|
||||
):
|
||||
raise PlanError(f"{field}.route must be a same-origin absolute path")
|
||||
steps = value.get("steps", [])
|
||||
if not isinstance(steps, list) or len(steps) > MAX_STEPS:
|
||||
raise PlanError(f"{field}.steps must be a list with at most {MAX_STEPS} entries")
|
||||
normalized_steps = []
|
||||
for index, step in enumerate(steps):
|
||||
step_field = f"{field}.steps[{index}]"
|
||||
if not isinstance(step, dict):
|
||||
raise PlanError(f"{step_field} must be an object")
|
||||
unknown_step = set(step) - {"action", "locator"}
|
||||
if unknown_step:
|
||||
raise PlanError(
|
||||
f"{step_field} has unsupported fields: {', '.join(sorted(unknown_step))}"
|
||||
)
|
||||
action = step.get("action")
|
||||
if action not in ALLOWED_ACTIONS:
|
||||
raise PlanError(f"{step_field}.action must be click or wait")
|
||||
normalized_steps.append(
|
||||
{"action": action, "locator": _locator(step.get("locator"), f"{step_field}.locator")}
|
||||
)
|
||||
if value.get("ready") is None:
|
||||
raise PlanError(f"{field}.ready must identify visible content in the captured view")
|
||||
return {
|
||||
"route": route,
|
||||
"steps": normalized_steps,
|
||||
"ready": _locator(value["ready"], f"{field}.ready"),
|
||||
}
|
||||
|
||||
|
||||
def validate_plan(raw: Any) -> dict[str, Any]:
|
||||
if not isinstance(raw, dict):
|
||||
raise PlanError("visual plan must be a JSON object")
|
||||
unknown = set(raw) - {"schema_version", "captures"}
|
||||
if unknown:
|
||||
raise PlanError(f"visual plan has unsupported fields: {', '.join(sorted(unknown))}")
|
||||
if raw.get("schema_version") != 1:
|
||||
raise PlanError("visual plan schema_version must be 1")
|
||||
captures = raw.get("captures")
|
||||
if not isinstance(captures, list) or len(captures) > MAX_CAPTURES:
|
||||
raise PlanError(f"visual plan captures must be a list with at most {MAX_CAPTURES} entries")
|
||||
|
||||
normalized_captures = []
|
||||
seen_ids: set[str] = set()
|
||||
for index, capture in enumerate(captures):
|
||||
field = f"captures[{index}]"
|
||||
if not isinstance(capture, dict):
|
||||
raise PlanError(f"{field} must be an object")
|
||||
unknown_capture = set(capture) - {
|
||||
"id",
|
||||
"title",
|
||||
"description",
|
||||
"viewport",
|
||||
"before",
|
||||
"after",
|
||||
}
|
||||
if unknown_capture:
|
||||
raise PlanError(
|
||||
f"{field} has unsupported fields: {', '.join(sorted(unknown_capture))}"
|
||||
)
|
||||
capture_id = _text(capture.get("id"), f"{field}.id", maximum=48)
|
||||
if not ID_PATTERN.fullmatch(capture_id):
|
||||
raise PlanError(f"{field}.id must be lower-case words separated by hyphens")
|
||||
if capture_id in seen_ids:
|
||||
raise PlanError(f"duplicate capture id: {capture_id}")
|
||||
seen_ids.add(capture_id)
|
||||
|
||||
viewport = capture.get("viewport")
|
||||
if not isinstance(viewport, dict) or set(viewport) != {"width", "height"}:
|
||||
raise PlanError(f"{field}.viewport must contain only width and height")
|
||||
width = viewport.get("width")
|
||||
height = viewport.get("height")
|
||||
if not isinstance(width, int) or isinstance(width, bool) or not 320 <= width <= 1920:
|
||||
raise PlanError(f"{field}.viewport.width must be an integer from 320 to 1920")
|
||||
if not isinstance(height, int) or isinstance(height, bool) or not 568 <= height <= 1440:
|
||||
raise PlanError(f"{field}.viewport.height must be an integer from 568 to 1440")
|
||||
|
||||
before = capture.get("before")
|
||||
normalized_captures.append(
|
||||
{
|
||||
"id": capture_id,
|
||||
"title": _public_text(capture.get("title"), f"{field}.title", maximum=90),
|
||||
"description": _public_text(
|
||||
capture.get("description", ""),
|
||||
f"{field}.description",
|
||||
maximum=240,
|
||||
required=False,
|
||||
),
|
||||
"viewport": {"width": width, "height": height},
|
||||
"before": None if before is None else _state(before, f"{field}.before"),
|
||||
"after": _state(capture.get("after"), f"{field}.after"),
|
||||
}
|
||||
)
|
||||
return {"schema_version": 1, "captures": normalized_captures}
|
||||
|
||||
|
||||
def load_plan(path: str) -> dict[str, Any]:
|
||||
if path == "-":
|
||||
raw_text = sys.stdin.read()
|
||||
else:
|
||||
raw_text = Path(path).read_text(encoding="utf-8")
|
||||
try:
|
||||
raw = json.loads(raw_text)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise PlanError(f"visual plan is not valid JSON: {exc}") from exc
|
||||
return validate_plan(raw)
|
||||
|
||||
|
||||
def asset_names(plan: dict[str, Any]) -> list[str]:
|
||||
names: list[str] = []
|
||||
for capture in plan["captures"]:
|
||||
if capture["before"] is not None:
|
||||
names.append(f"release-note-{capture['id']}-before.png")
|
||||
names.append(f"release-note-{capture['id']}-now.png")
|
||||
return names
|
||||
|
||||
|
||||
def render_markdown(plan: dict[str, Any], repository: str, tag: str) -> str:
|
||||
if not plan["captures"]:
|
||||
return ""
|
||||
base = f"https://github.com/{repository}/releases/download/{tag}"
|
||||
lines = ["## See the difference", ""]
|
||||
for capture in plan["captures"]:
|
||||
lines.extend([f"### {capture['title']}", ""])
|
||||
if capture["description"]:
|
||||
lines.extend([capture["description"], ""])
|
||||
now_name = f"release-note-{capture['id']}-now.png"
|
||||
if capture["before"] is None:
|
||||
lines.extend(
|
||||
[f"![{capture['title']}]({base}/{now_name})", ""]
|
||||
)
|
||||
continue
|
||||
before_name = f"release-note-{capture['id']}-before.png"
|
||||
lines.extend(
|
||||
[
|
||||
"| Before | Now |",
|
||||
"| --- | --- |",
|
||||
(
|
||||
f"| ![{capture['title']} before]({base}/{before_name}) "
|
||||
f"| ![{capture['title']} now]({base}/{now_name}) |"
|
||||
),
|
||||
"",
|
||||
]
|
||||
)
|
||||
return "\n".join(lines).rstrip() + "\n"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
validate_parser = subparsers.add_parser("validate")
|
||||
validate_parser.add_argument("--plan", required=True)
|
||||
validate_parser.add_argument("--output")
|
||||
|
||||
count_parser = subparsers.add_parser("count")
|
||||
count_parser.add_argument("--plan", required=True)
|
||||
|
||||
before_count_parser = subparsers.add_parser("before-count")
|
||||
before_count_parser.add_argument("--plan", required=True)
|
||||
|
||||
assets_parser = subparsers.add_parser("assets")
|
||||
assets_parser.add_argument("--plan", required=True)
|
||||
|
||||
render_parser = subparsers.add_parser("render")
|
||||
render_parser.add_argument("--plan", required=True)
|
||||
render_parser.add_argument("--repository", required=True)
|
||||
render_parser.add_argument("--tag", required=True)
|
||||
render_parser.add_argument("--output")
|
||||
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
plan = load_plan(args.plan)
|
||||
if args.command == "validate":
|
||||
output = json.dumps(plan, indent=2) + "\n"
|
||||
elif args.command == "count":
|
||||
output = f"{len(plan['captures'])}\n"
|
||||
elif args.command == "before-count":
|
||||
output = f"{sum(capture['before'] is not None for capture in plan['captures'])}\n"
|
||||
elif args.command == "assets":
|
||||
output = "".join(f"{name}\n" for name in asset_names(plan))
|
||||
else:
|
||||
if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", args.repository):
|
||||
raise PlanError("repository must be in owner/name form")
|
||||
if not re.fullmatch(r"v[0-9A-Za-z][0-9A-Za-z._-]*", args.tag):
|
||||
raise PlanError("tag is not a safe release tag")
|
||||
output = render_markdown(plan, args.repository, args.tag)
|
||||
if getattr(args, "output", None):
|
||||
Path(args.output).write_text(output, encoding="utf-8")
|
||||
else:
|
||||
sys.stdout.write(output)
|
||||
return 0
|
||||
except (OSError, PlanError) as exc:
|
||||
print(f"release-note visuals: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,184 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import importlib.util
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MODULE_PATH = Path(__file__).with_name("release_note_visuals.py")
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
|
||||
SPEC = importlib.util.spec_from_file_location("release_note_visuals", MODULE_PATH)
|
||||
assert SPEC and SPEC.loader
|
||||
visuals = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(visuals)
|
||||
|
||||
RENDERER_PATH = Path(__file__).with_name("render_release_body.py")
|
||||
RENDERER_SPEC = importlib.util.spec_from_file_location("render_release_body", RENDERER_PATH)
|
||||
assert RENDERER_SPEC and RENDERER_SPEC.loader
|
||||
renderer = importlib.util.module_from_spec(RENDERER_SPEC)
|
||||
RENDERER_SPEC.loader.exec_module(renderer)
|
||||
|
||||
|
||||
def valid_plan():
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"captures": [
|
||||
{
|
||||
"id": "responsive-settings",
|
||||
"title": "Settings fit smaller screens",
|
||||
"description": "Controls remain readable without horizontal scrolling.",
|
||||
"viewport": {"width": 390, "height": 844},
|
||||
"before": {
|
||||
"route": "/settings/general",
|
||||
"steps": [],
|
||||
"ready": {
|
||||
"kind": "role",
|
||||
"role": "heading",
|
||||
"name": "General",
|
||||
"exact": True,
|
||||
"nth": 0,
|
||||
},
|
||||
},
|
||||
"after": {
|
||||
"route": "/settings/general",
|
||||
"steps": [
|
||||
{
|
||||
"action": "wait",
|
||||
"locator": {
|
||||
"kind": "text",
|
||||
"value": "Appearance",
|
||||
"exact": True,
|
||||
"nth": 0,
|
||||
},
|
||||
}
|
||||
],
|
||||
"ready": {
|
||||
"kind": "text",
|
||||
"value": "Appearance",
|
||||
"exact": True,
|
||||
"nth": 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class ReleaseNoteVisualPlanTest(unittest.TestCase):
|
||||
def test_normalizes_a_safe_accessible_capture_plan(self):
|
||||
plan = visuals.validate_plan(valid_plan())
|
||||
self.assertEqual(plan["captures"][0]["viewport"], {"width": 390, "height": 844})
|
||||
self.assertEqual(
|
||||
visuals.asset_names(plan),
|
||||
[
|
||||
"release-note-responsive-settings-before.png",
|
||||
"release-note-responsive-settings-now.png",
|
||||
],
|
||||
)
|
||||
|
||||
def test_current_only_capture_has_one_asset(self):
|
||||
raw = valid_plan()
|
||||
raw["captures"][0]["before"] = None
|
||||
plan = visuals.validate_plan(raw)
|
||||
self.assertEqual(
|
||||
sum(capture["before"] is not None for capture in plan["captures"]),
|
||||
0,
|
||||
)
|
||||
self.assertEqual(
|
||||
visuals.asset_names(plan),
|
||||
["release-note-responsive-settings-now.png"],
|
||||
)
|
||||
|
||||
def test_rejects_external_routes_and_arbitrary_selectors(self):
|
||||
raw = valid_plan()
|
||||
raw["captures"][0]["after"]["route"] = "https://example.com/"
|
||||
with self.assertRaisesRegex(visuals.PlanError, "same-origin"):
|
||||
visuals.validate_plan(raw)
|
||||
|
||||
raw = valid_plan()
|
||||
raw["captures"][0]["after"]["route"] = "/\\\\example.com/"
|
||||
with self.assertRaisesRegex(visuals.PlanError, "same-origin"):
|
||||
visuals.validate_plan(raw)
|
||||
|
||||
raw = valid_plan()
|
||||
raw["captures"][0]["after"]["steps"][0]["locator"]["kind"] = "css"
|
||||
with self.assertRaisesRegex(visuals.PlanError, "kind must be"):
|
||||
visuals.validate_plan(raw)
|
||||
|
||||
def test_requires_visible_content_for_every_capture_state(self):
|
||||
raw = valid_plan()
|
||||
del raw["captures"][0]["after"]["ready"]
|
||||
with self.assertRaisesRegex(visuals.PlanError, "visible content"):
|
||||
visuals.validate_plan(raw)
|
||||
|
||||
def test_rejects_public_punctuation_disallowed_by_release_notes(self):
|
||||
raw = valid_plan()
|
||||
raw["captures"][0]["description"] = "Before; now"
|
||||
with self.assertRaisesRegex(visuals.PlanError, "semicolons"):
|
||||
visuals.validate_plan(raw)
|
||||
|
||||
def test_renders_release_asset_links_as_before_and_now(self):
|
||||
plan = visuals.validate_plan(valid_plan())
|
||||
markdown = visuals.render_markdown(plan, "rcourtman/Pulse", "v6.4.0")
|
||||
self.assertIn("## See the difference", markdown)
|
||||
self.assertIn("| Before | Now |", markdown)
|
||||
self.assertIn(
|
||||
"https://github.com/rcourtman/Pulse/releases/download/v6.4.0/"
|
||||
"release-note-responsive-settings-before.png",
|
||||
markdown,
|
||||
)
|
||||
self.assertNotIn(";", markdown)
|
||||
self.assertNotIn("\u2014", markdown)
|
||||
|
||||
def test_release_body_accepts_visuals_between_notes_and_installation(self):
|
||||
notes = "\n".join(
|
||||
[
|
||||
"# Pulse v6.4.0 Release Notes",
|
||||
"",
|
||||
"Pulse is easier to use across the devices you already carry.",
|
||||
"",
|
||||
"## What's improved",
|
||||
"",
|
||||
"- **Responsive settings** - Controls now fit smaller screens cleanly.",
|
||||
]
|
||||
)
|
||||
visual_markdown = visuals.render_markdown(
|
||||
visuals.validate_plan(valid_plan()), "rcourtman/Pulse", "v6.4.0"
|
||||
).strip()
|
||||
rollback = renderer.build_rollback_section(
|
||||
type(
|
||||
"Args",
|
||||
(),
|
||||
{
|
||||
"rollback_target": "v6.3.2",
|
||||
"rollback_command": "./scripts/install.sh --version v6.3.2",
|
||||
},
|
||||
)()
|
||||
)
|
||||
body = "\n\n".join(
|
||||
[
|
||||
notes,
|
||||
visual_markdown,
|
||||
renderer.build_installation_section("6.4.0"),
|
||||
rollback,
|
||||
]
|
||||
) + "\n"
|
||||
self.assertEqual(renderer.validate_release_body_shape(body, "6.4.0"), body)
|
||||
|
||||
def test_release_pipeline_captures_uploads_and_verifies_selected_visuals(self):
|
||||
workflow = (REPOSITORY_ROOT / ".github/workflows/create-release.yml").read_text()
|
||||
self.assertIn("release_screenshot_plan:", workflow)
|
||||
self.assertIn("release_note_visuals:", workflow)
|
||||
self.assertIn("scripts/capture-release-note-visuals.sh", workflow)
|
||||
self.assertIn('--release-visuals-file "$VISUAL_MARKDOWN_FILE"', workflow)
|
||||
self.assertIn('release_upload_with_retry "$TAG" "release-note-visuals/${asset_name}"', workflow)
|
||||
self.assertIn(r'"release-note-\(.id)-now.png"', workflow)
|
||||
|
||||
for trigger_name in ("trigger-release.sh", "trigger-stable-patch.sh"):
|
||||
trigger = (REPOSITORY_ROOT / "scripts" / trigger_name).read_text()
|
||||
self.assertIn("--rawfile release_screenshot_plan", trigger)
|
||||
self.assertIn("release_screenshot_plan: $release_screenshot_plan", trigger)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -405,7 +405,13 @@ def validate_release_body_shape(
|
||||
raise ReleaseBodyIntegrityError(
|
||||
"published release body has no authored section before Install"
|
||||
)
|
||||
validate_release_notes_shape(authored_prefix, version)
|
||||
visual_heading = "\n## See the difference\n"
|
||||
if authored_prefix.count(visual_heading) > 1:
|
||||
raise ReleaseBodyIntegrityError(
|
||||
"published release body must contain at most one See the difference section"
|
||||
)
|
||||
authored_notes = authored_prefix.split(visual_heading, 1)[0]
|
||||
validate_release_notes_shape(authored_notes, version)
|
||||
|
||||
if expected_body is not None:
|
||||
expected_clean = strip_validation_status_block(expected_body)
|
||||
@@ -511,6 +517,7 @@ def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--version", required=True)
|
||||
parser.add_argument("--release-notes-file")
|
||||
parser.add_argument("--release-visuals-file")
|
||||
parser.add_argument("--validate-notes-file")
|
||||
parser.add_argument("--validate-body-file")
|
||||
parser.add_argument("--expected-body-file")
|
||||
@@ -588,11 +595,23 @@ def main() -> int:
|
||||
raw_text = Path(args.release_notes_file).read_text(encoding="utf-8")
|
||||
validate_release_notes_shape(raw_text, args.version)
|
||||
sanitized = sanitize_release_notes(raw_text, args.version).rstrip("\n")
|
||||
sections = [
|
||||
sanitized,
|
||||
build_installation_section(args.version),
|
||||
build_rollback_section(args),
|
||||
]
|
||||
sections = [sanitized]
|
||||
if args.release_visuals_file:
|
||||
release_visuals = Path(args.release_visuals_file).read_text(
|
||||
encoding="utf-8"
|
||||
).strip()
|
||||
if release_visuals:
|
||||
if not release_visuals.startswith("## See the difference\n"):
|
||||
raise ReleaseBodyIntegrityError(
|
||||
"release visuals must begin with '## See the difference'"
|
||||
)
|
||||
sections.append(release_visuals)
|
||||
sections.extend(
|
||||
[
|
||||
build_installation_section(args.version),
|
||||
build_rollback_section(args),
|
||||
]
|
||||
)
|
||||
rendered = "\n\n".join(sections) + "\n"
|
||||
validate_release_body_shape(rendered, args.version)
|
||||
Path(args.output).write_text(rendered, encoding="utf-8")
|
||||
|
||||
@@ -499,6 +499,47 @@ Old metadata section.
|
||||
self.assertIn("./scripts/install.sh --version v5.1.28", body)
|
||||
render_release_body.validate_release_body_shape(body, "6.0.0-rc.2")
|
||||
|
||||
def test_release_body_accepts_visual_evidence_before_installation(self) -> None:
|
||||
notes = """# Pulse v6.4.0 Release Notes
|
||||
|
||||
Pulse is easier to use on the screens operators already carry.
|
||||
|
||||
## What's improved
|
||||
|
||||
- **Clearer small-screen controls** - Settings remain readable on phones.
|
||||
"""
|
||||
visuals = """## See the difference
|
||||
|
||||
### Settings fit smaller screens
|
||||
|
||||
Controls remain readable without horizontal scrolling.
|
||||
|
||||
| Before | Now |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
"""
|
||||
rollback_args = type(
|
||||
"Args",
|
||||
(),
|
||||
{
|
||||
"rollback_target": "v6.3.2",
|
||||
"rollback_command": "./scripts/install.sh --version v6.3.2",
|
||||
},
|
||||
)()
|
||||
body = "\n\n".join(
|
||||
[
|
||||
notes.strip(),
|
||||
visuals.strip(),
|
||||
render_release_body.build_installation_section("6.4.0"),
|
||||
render_release_body.build_rollback_section(rollback_args),
|
||||
]
|
||||
) + "\n"
|
||||
|
||||
self.assertEqual(
|
||||
render_release_body.validate_release_body_shape(body, "6.4.0"),
|
||||
body,
|
||||
)
|
||||
|
||||
def test_flattened_release_notes_fail_closed(self) -> None:
|
||||
flattened = (
|
||||
"# Pulse v6.1.0-rc.2 Release Notes"
|
||||
|
||||
@@ -94,6 +94,7 @@ python3 scripts/check-workflow-dispatch-inputs.py \
|
||||
--branch "$CURRENT_BRANCH" \
|
||||
--require version \
|
||||
--require release_notes \
|
||||
--require release_screenshot_plan \
|
||||
--require promoted_from_tag \
|
||||
--require rollback_version \
|
||||
--require ga_date \
|
||||
@@ -138,6 +139,13 @@ echo ""
|
||||
|
||||
# Check 5: Release notes file
|
||||
NOTES_FILE="${NOTES_FILE_ARG:-/tmp/release_notes_${VERSION}.md}"
|
||||
VISUAL_PLAN_SIDECAR="${NOTES_FILE}.visuals.json"
|
||||
if [ -s "$VISUAL_PLAN_SIDECAR" ]; then
|
||||
VISUAL_PLAN_FILE="$VISUAL_PLAN_SIDECAR"
|
||||
else
|
||||
VISUAL_PLAN_FILE=$(mktemp)
|
||||
rm -f "$VISUAL_PLAN_FILE"
|
||||
fi
|
||||
if [ -f "$NOTES_FILE" ]; then
|
||||
echo "Found release notes file: ${NOTES_FILE}"
|
||||
echo ""
|
||||
@@ -156,7 +164,8 @@ else
|
||||
echo ""
|
||||
if [[ ! $REPLY =~ ^[Nn]$ ]]; then
|
||||
echo "Generating release notes..."
|
||||
if ./scripts/generate-release-notes.sh "$VERSION" > "$NOTES_FILE"; then
|
||||
if RELEASE_NOTE_VISUAL_PLAN_FILE="$VISUAL_PLAN_FILE" \
|
||||
./scripts/generate-release-notes.sh "$VERSION" > "$NOTES_FILE"; then
|
||||
echo "Release notes generated at ${NOTES_FILE}"
|
||||
echo ""
|
||||
# Show first few lines
|
||||
@@ -168,6 +177,7 @@ else
|
||||
if [[ $REPLY =~ ^[Nn]$ ]]; then
|
||||
echo "Release notes rejected."
|
||||
rm "$NOTES_FILE"
|
||||
rm -f "$VISUAL_PLAN_FILE"
|
||||
NOTES_FILE=""
|
||||
fi
|
||||
else
|
||||
@@ -192,6 +202,27 @@ python3 scripts/release_control/render_release_body.py \
|
||||
--validate-notes-file "$NOTES_FILE"
|
||||
echo "✓ Release-note Markdown structure validated"
|
||||
|
||||
if [ ! -s "$VISUAL_PLAN_FILE" ]; then
|
||||
./scripts/generate-release-notes.sh --visual-plan "$VERSION" "$NOTES_FILE" > "$VISUAL_PLAN_FILE"
|
||||
fi
|
||||
python3 scripts/release_control/release_note_visuals.py \
|
||||
validate --plan "$VISUAL_PLAN_FILE" --output "$VISUAL_PLAN_FILE"
|
||||
VISUAL_CAPTURE_COUNT=$(python3 scripts/release_control/release_note_visuals.py \
|
||||
count --plan "$VISUAL_PLAN_FILE")
|
||||
if [ "$VISUAL_CAPTURE_COUNT" -gt 0 ]; then
|
||||
echo ""
|
||||
echo "Model-selected release-note visual plan:"
|
||||
cat "$VISUAL_PLAN_FILE"
|
||||
echo ""
|
||||
read -p "Use this visual plan? [Y/n] " -n 1 -r
|
||||
echo ""
|
||||
if [[ $REPLY =~ ^[Nn]$ ]]; then
|
||||
printf '%s\n' '{"schema_version":1,"captures":[]}' > "$VISUAL_PLAN_FILE"
|
||||
VISUAL_CAPTURE_COUNT=0
|
||||
fi
|
||||
fi
|
||||
echo "✓ Release-note visual plan validated (${VISUAL_CAPTURE_COUNT} capture(s))"
|
||||
|
||||
ROLLBACK_VERSION=""
|
||||
ROLLBACK_COMMAND=""
|
||||
PROMOTED_FROM_TAG=""
|
||||
@@ -313,6 +344,7 @@ if [ -n "$NOTES_FILE" ]; then
|
||||
jq -n \
|
||||
--arg version "$VERSION" \
|
||||
--rawfile release_notes "$NOTES_FILE" \
|
||||
--rawfile release_screenshot_plan "$VISUAL_PLAN_FILE" \
|
||||
--arg rollback_version "$ROLLBACK_VERSION" \
|
||||
--arg promoted_from_tag "$PROMOTED_FROM_TAG" \
|
||||
--arg ga_date "$GA_DATE" \
|
||||
@@ -327,6 +359,7 @@ if [ -n "$NOTES_FILE" ]; then
|
||||
'{
|
||||
version: $version,
|
||||
release_notes: $release_notes,
|
||||
release_screenshot_plan: $release_screenshot_plan,
|
||||
rollback_version: $rollback_version,
|
||||
promoted_from_tag: $promoted_from_tag,
|
||||
ga_date: $ga_date,
|
||||
|
||||
@@ -107,6 +107,13 @@ if [ ! -s "$NOTES_FILE" ]; then
|
||||
echo "Canonical release notes are required at ${NOTES_FILE}." >&2
|
||||
exit 1
|
||||
fi
|
||||
VISUAL_PLAN_SIDECAR="${NOTES_FILE}.visuals.json"
|
||||
if [ -s "$VISUAL_PLAN_SIDECAR" ]; then
|
||||
VISUAL_PLAN_FILE="$VISUAL_PLAN_SIDECAR"
|
||||
else
|
||||
VISUAL_PLAN_FILE=$(mktemp)
|
||||
rm -f "$VISUAL_PLAN_FILE"
|
||||
fi
|
||||
|
||||
RESOLVER_ARGS=(
|
||||
--version "$VERSION"
|
||||
@@ -190,11 +197,18 @@ else
|
||||
--version "$VERSION" \
|
||||
--validate-notes-file "$NOTES_FILE"
|
||||
|
||||
if [ ! -s "$VISUAL_PLAN_FILE" ]; then
|
||||
./scripts/generate-release-notes.sh --visual-plan "$VERSION" "$NOTES_FILE" > "$VISUAL_PLAN_FILE"
|
||||
fi
|
||||
python3 scripts/release_control/release_note_visuals.py \
|
||||
validate --plan "$VISUAL_PLAN_FILE" --output "$VISUAL_PLAN_FILE"
|
||||
|
||||
python3 scripts/check-workflow-dispatch-inputs.py \
|
||||
--workflow-path .github/workflows/create-release.yml \
|
||||
--branch "$CURRENT_BRANCH" \
|
||||
--require version \
|
||||
--require release_notes \
|
||||
--require release_screenshot_plan \
|
||||
--require promoted_from_tag \
|
||||
--require rollback_version \
|
||||
--require ga_date \
|
||||
@@ -210,6 +224,7 @@ else
|
||||
jq -n \
|
||||
--arg version "$VERSION" \
|
||||
--rawfile release_notes "$NOTES_FILE" \
|
||||
--rawfile release_screenshot_plan "$VISUAL_PLAN_FILE" \
|
||||
--arg promoted_from_tag "" \
|
||||
--arg rollback_version "$ROLLBACK_TAG" \
|
||||
--arg ga_date "" \
|
||||
@@ -224,6 +239,7 @@ else
|
||||
'{
|
||||
version: $version,
|
||||
release_notes: $release_notes,
|
||||
release_screenshot_plan: $release_screenshot_plan,
|
||||
promoted_from_tag: $promoted_from_tag,
|
||||
rollback_version: $rollback_version,
|
||||
ga_date: $ga_date,
|
||||
|
||||
Reference in New Issue
Block a user