feat(release): add visual changelog comparisons

This commit is contained in:
Pulse Test
2026-08-28 11:29:44 +01:00
parent e1bc14bd4a
commit 354c1e6ffd
11 changed files with 1116 additions and 13 deletions
+139 -1
View File
@@ -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`
+124
View File
@@ -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")
+119 -2
View 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
View File
@@ -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
View File
@@ -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()
+25 -6
View File
@@ -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 |
| --- | --- |
| ![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"
+34 -1
View File
@@ -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,
+16
View File
@@ -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,