diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index f70f646c0..a6b1b2bce 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 950d122a8..8dd7d48d1 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -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` diff --git a/scripts/capture-release-note-visuals.sh b/scripts/capture-release-note-visuals.sh new file mode 100755 index 000000000..b89a32491 --- /dev/null +++ b/scripts/capture-release-note-visuals.sh @@ -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 " >&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") diff --git a/scripts/generate-release-notes.sh b/scripts/generate-release-notes.sh index 300eb2512..eacf4c9c3 100755 --- a/scripts/generate-release-notes.sh +++ b/scripts/generate-release-notes.sh @@ -10,6 +10,7 @@ # # Usage: ./scripts/generate-release-notes.sh [comparison-tag] # ./scripts/generate-release-notes.sh --resolve-base +# ./scripts/generate-release-notes.sh --visual-plan # # 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 for the claude engine (default: opus) # RELEASE_NOTES_TRACE_DIR= retain each pass for inspection +# RELEASE_NOTE_VISUAL_PLAN_FILE= 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 [comparison-tag]" >&2 echo " $0 --resolve-base " >&2 + echo " $0 --visual-plan [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 < "$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 <&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 <&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 diff --git a/scripts/release_control/capture_release_note_visuals.mjs b/scripts/release_control/capture_release_note_visuals.mjs new file mode 100755 index 000000000..8b1b45c48 --- /dev/null +++ b/scripts/release_control/capture_release_note_visuals.mjs @@ -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(); +} diff --git a/scripts/release_control/release_note_visuals.py b/scripts/release_control/release_note_visuals.py new file mode 100755 index 000000000..7326510de --- /dev/null +++ b/scripts/release_control/release_note_visuals.py @@ -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()) diff --git a/scripts/release_control/release_note_visuals_test.py b/scripts/release_control/release_note_visuals_test.py new file mode 100644 index 000000000..2bfc4767d --- /dev/null +++ b/scripts/release_control/release_note_visuals_test.py @@ -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() diff --git a/scripts/release_control/render_release_body.py b/scripts/release_control/render_release_body.py index 654399828..9d8281cd0 100644 --- a/scripts/release_control/render_release_body.py +++ b/scripts/release_control/render_release_body.py @@ -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") diff --git a/scripts/release_control/render_release_body_test.py b/scripts/release_control/render_release_body_test.py index 2dfe7f93c..3310142fa 100644 --- a/scripts/release_control/render_release_body_test.py +++ b/scripts/release_control/render_release_body_test.py @@ -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 | +| --- | --- | +| ![Settings before](https://github.com/rcourtman/Pulse/releases/download/v6.4.0/release-note-settings-before.png) | ![Settings now](https://github.com/rcourtman/Pulse/releases/download/v6.4.0/release-note-settings-now.png) | +""" + 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" diff --git a/scripts/trigger-release.sh b/scripts/trigger-release.sh index 181a1c40f..569bd7967 100755 --- a/scripts/trigger-release.sh +++ b/scripts/trigger-release.sh @@ -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, diff --git a/scripts/trigger-stable-patch.sh b/scripts/trigger-stable-patch.sh index 33fbd8ea3..9343bba44 100755 --- a/scripts/trigger-stable-patch.sh +++ b/scripts/trigger-stable-patch.sh @@ -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,