From 3d43b27288db20cb7b4d7ae962385d179ad799cb Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Thu, 12 Nov 2026 01:33:07 +0530 Subject: [PATCH 1/6] chore(release): add fail-closed Mac 2.1.0 recovery tooling and tests --- scripts/release/recover-mac-release.sh | 196 +++++++++++++++++ .../release/test/recover_mac_release_test.rb | 208 ++++++++++++++++++ 2 files changed, 404 insertions(+) create mode 100755 scripts/release/recover-mac-release.sh create mode 100644 scripts/release/test/recover_mac_release_test.rb diff --git a/scripts/release/recover-mac-release.sh b/scripts/release/recover-mac-release.sh new file mode 100755 index 000000000..e8b1d98cf --- /dev/null +++ b/scripts/release/recover-mac-release.sh @@ -0,0 +1,196 @@ +#!/usr/bin/env bash +# recover-mac-release.sh: Safely verify and recover a Mac release from an immutable draft. +# +# Usage: +# recover-mac-release.sh [--verify | --publish] +# +# Modes: +# --verify (default) Download draft assets, verify SHA-256 hashes against approved +# release-state records, and verify Developer ID code signing, Gatekeeper +# notarization, stapled tickets, designated requirements, and Sparkle feed +# Ed25519 signatures against the published baseline public key. +# --publish Perform all verifications, publish the draft as Latest, verify live feeds, +# and auto-revert on live feed verification failure. +# +# Security & Safety: +# - Pinned to exact VERSION, CANDIDATE_SHA, and DRAFT_ID. +# - Fail-closed: refuses to execute if any argument or live state deviates. +# - Rollback trap: if publication occurs but live feeds fail to serve v$VERSION within +# 5 minutes (10 attempts), Latest is reverted to the previous release and v$VERSION +# is restored to draft state. +# - Protected environment & branch protection: does not bypass GitHub App rulesets. +# Reconciliation of release-state must be completed via the authorized release App token. +set -euo pipefail + +VERSION="${VERSION:-2.1.0}" +CANDIDATE_SHA="${CANDIDATE_SHA:-94c796fc34fd699776764d67d2f26013909a308e}" +DRAFT_ID="${DRAFT_ID:-442117184}" +REPO="${GITHUB_REPOSITORY:-raiseCatError/meowdisplay}" +EXPECTED_SHA256SUMS_DIGEST="279e22555e9a84a4fc59bd7d529f4efca6ccf2a8c986ac9fc939d627368bc1b7" + +mode="verify" +if [ "${1:-}" = "--publish" ]; then + mode="publish" +fi + +echo "=== MeowDisplay Mac Release Recovery ===" +echo "Version: $VERSION" +echo "Candidate SHA: $CANDIDATE_SHA" +echo "Draft ID: $DRAFT_ID" +echo "Mode: $mode" +echo "Repository: $REPO" +echo "" + +# Strict guard on inputs +[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "::error::bad version '$VERSION'"; exit 1; } +[[ "$CANDIDATE_SHA" =~ ^[0-9a-f]{40}$ ]] || { echo "::error::bad candidate SHA '$CANDIDATE_SHA'"; exit 1; } +[[ "$DRAFT_ID" =~ ^[0-9]+$ ]] || { echo "::error::bad draft ID '$DRAFT_ID'"; exit 1; } + +here="$(cd "$(dirname "$0")" && pwd)" +work=$(mktemp -d "${TMPDIR:-/tmp}/meow-recover.XXXXXX") +trap 'rm -rf "$work"' EXIT + +# 1. Inspect live draft release +echo "1. Checking draft release identity..." +gh api "repos/$REPO/releases/$DRAFT_ID" > "$work/draft.json" +tag_name=$(jq -r .tag_name "$work/draft.json") +is_draft=$(jq -r .draft "$work/draft.json") +target_commitish=$(jq -r .target_commitish "$work/draft.json") + +[ "$tag_name" = "v$VERSION" ] || { echo "::error::draft tag is '$tag_name', expected 'v$VERSION'"; exit 1; } +[ "$is_draft" = "true" ] || { echo "::error::release $DRAFT_ID is not a draft (draft=$is_draft)"; exit 1; } +[ "$target_commitish" = "$CANDIDATE_SHA" ] || { echo "::error::target_commitish is '$target_commitish', expected '$CANDIDATE_SHA'"; exit 1; } +echo "✓ Draft $DRAFT_ID targets approved candidate $CANDIDATE_SHA" + +# 2. Confirm iOS is already published +echo "2. Confirming iOS release status in release-state..." +git -C "$here/../.." fetch -q origin release-state 2>/dev/null || true +ios_stage=$(git -C "$here/../.." show origin/release-state:releases/"$VERSION".json | jq -r .platforms.ios.stage) +ios_store=$(git -C "$here/../.." show origin/release-state:releases/"$VERSION".json | jq -r .platforms.ios.storeState) +ios_sha=$(git -C "$here/../.." show origin/release-state:releases/"$VERSION".json | jq -r .platforms.ios.sha) +[ "$ios_stage" = "published" ] || { echo "::error::iOS is not published (stage=$ios_stage)"; exit 1; } +[ "$ios_store" = "READY_FOR_DISTRIBUTION" ] || { echo "::error::iOS storeState is '$ios_store', expected READY_FOR_DISTRIBUTION"; exit 1; } +[ "$ios_sha" = "$CANDIDATE_SHA" ] || { echo "::error::iOS sha '$ios_sha' does not match candidate '$CANDIDATE_SHA'"; exit 1; } +echo "✓ iOS $VERSION is published ($ios_store) against candidate $CANDIDATE_SHA" + +# 3. Check exact 5 assets +echo "3. Checking draft assets list with deterministic ASCII collation..." +actual_names=$(jq -r '[.assets[].name] | sort | join(" ")' "$work/draft.json") +expected_names=$(printf '%s\n' MeowDisplay.dmg MeowDisplayReceiver.dmg SHA256SUMS.txt appcast.xml appcast-receiver.xml | LC_ALL=C sort | paste -sd' ' -) +[ "$actual_names" = "$expected_names" ] || { echo "::error::draft assets are '$actual_names', expected '$expected_names'"; exit 1; } +echo "✓ Draft contains exactly the 5 expected assets in byte-order" + +# 4. Download and verify hashes +echo "4. Downloading draft assets and checking SHA-256 hashes..." +mkdir -p "$work/draft" +gh release download "v$VERSION" -R "$REPO" -D "$work/draft" + +# 4a. Verify SHA256SUMS.txt itself +actual_sums_hash=$(shasum -a 256 "$work/draft/SHA256SUMS.txt" | cut -d' ' -f1) +[ "$actual_sums_hash" = "$EXPECTED_SHA256SUMS_DIGEST" ] || { + echo "::error::SHA256SUMS.txt hash $actual_sums_hash != expected $EXPECTED_SHA256SUMS_DIGEST" + exit 1 +} +echo "✓ SHA256SUMS.txt digest verified independently ($actual_sums_hash)" + +# 4b. Verify draft assets against release-state records +git -C "$here/../.." show origin/release-state:releases/"$VERSION".json > "$work/state.json" +jq -r '.platforms.mac.artifacts[] | "\(.sha256) \(.name)"' "$work/state.json" > "$work/expected.sha256" +echo "$EXPECTED_SHA256SUMS_DIGEST SHA256SUMS.txt" >> "$work/expected.sha256" + +(cd "$work/draft" && shasum -a 256 -c "$work/expected.sha256") +(cd "$work/draft" && shasum -a 256 -c SHA256SUMS.txt) +echo "✓ All asset SHA-256 hashes match approved release-state records and SHA256SUMS.txt" + +# 5. Baseline and Mac app / Sparkle feed verification +echo "5. Verifying signing, notarization, version, build, and Sparkle feeds against baseline..." +. "$here/baseline.env" +"$here/published-baseline.sh" "$work/baseline" + +for role in sender receiver; do + select_app "$role" + echo "--- Verifying $role ($DMG) ---" + "$here/check-build-number.sh" "$VERSION" "$LIVE_FEED_BASE/$FEED" + "$here/verify-mac-artifact.sh" "$work/draft/$DMG" "$role" "$VERSION" "$VERSION" "$work/baseline" + echo "--- Verifying Feed ($FEED) ---" + "$here/verify-feed.sh" "$work/draft/$FEED" "$work/draft/$DMG" "$(cat "$work/baseline/$role.key")" "$VERSION" "$VERSION" \ + "https://github.com/$REPO/releases/download/v$VERSION/$DMG" +done +echo "✓ All Mac binaries, Gatekeeper, notarization, and Sparkle feed signatures verified" + +if [ "$mode" = "verify" ]; then + echo "" + echo "=================================================================" + echo "VERIFICATION PASSED: Draft v$VERSION is safe and ready to publish." + echo "To publish, re-run with: $0 --publish" + echo "=================================================================" + exit 0 +fi + +# Mode is publish +echo "" +echo "6. Publishing draft release as Latest..." +previous=$(gh api "repos/$REPO/releases/latest" --jq .tag_name) +[ "$previous" != "v$VERSION" ] || { echo "::error::v$VERSION is already Latest; cannot publish again"; exit 1; } + +# Re-check live draft immediately before publication +gh api "repos/$REPO/releases/$DRAFT_ID" > "$work/final_check.json" +[ "$(jq -r .draft "$work/final_check.json")" = "true" ] || { echo "::error::draft status changed"; exit 1; } +[ "$(jq -r .target_commitish "$work/final_check.json")" = "$CANDIDATE_SHA" ] || { echo "::error::target_commitish changed"; exit 1; } + +rollback() { + echo "::error::Publication failed live verification! Restoring Latest to $previous and returning v$VERSION to draft..." + gh release edit "$previous" -R "$REPO" --latest || true + gh release edit "v$VERSION" -R "$REPO" --draft=true || true +} +trap rollback ERR + +gh release edit "v$VERSION" -R "$REPO" --draft=false --latest + +echo "7. Verifying published tag and Latest status..." +tagged_sha=$(gh api "repos/$REPO/commits/v$VERSION" --jq .sha) +[ "$tagged_sha" = "$CANDIDATE_SHA" ] || { echo "::error::tag v$VERSION points to $tagged_sha, not $CANDIDATE_SHA"; exit 1; } + +latest_tag=$(gh api "repos/$REPO/releases/latest" --jq .tag_name) +[ "$latest_tag" = "v$VERSION" ] || { echo "::error::Latest is $latest_tag, expected v$VERSION"; exit 1; } + +echo "8. Verifying live feeds serve v$VERSION..." +live_ok() { + for feed in "$SENDER_FEED" "$RECEIVER_FEED"; do + want=$(shasum -a 256 "$work/draft/$feed" | cut -c1-64) + for url in "https://github.com/$REPO/releases/latest/download/$feed" "$LIVE_FEED_BASE/$feed"; do + got=$(curl -fsSL --max-time 30 "$url" | shasum -a 256 | cut -c1-64) || return 1 + [ "$got" = "$want" ] || return 1 + done + done +} + +success=false +for attempt in 1 2 3 4 5 6 7 8 9 10; do + echo "Checking live feeds (attempt $attempt/10)..." + if live_ok; then success=true; echo "Live feeds serve v$VERSION!"; break; fi + sleep 30 +done + +[ "$success" = "true" ] || { echo "::error::Live feeds did not serve v$VERSION after 5 minutes"; exit 1; } + +trap - ERR + +# 9. Release-state reconciliation +echo "9. Reconciling release-state..." +if [ -n "${RELEASE_STATE_TOKEN:-}" ]; then + "$here/release-state.sh" "$VERSION" publish start STATE --config "$here/../../.github/release-config.json" --branch-head "$CANDIDATE_SHA" || true + "$here/release-state.sh" "$VERSION" publish mac-published STATE --config "$here/../../.github/release-config.json" \ + --url "https://github.com/$REPO/releases/tag/v$VERSION" + echo "✓ release-state updated with release App token" +else + echo "NOTE: RELEASE_STATE_TOKEN not set in current shell." + echo "Reconcile release-state via the release App in GitHub Actions or dispatch the release-state reconciliation step." +fi + +echo "" +echo "=================================================================" +echo "SUCCESS: MeowDisplay Mac $VERSION is published and live!" +echo "Tag v$VERSION is Latest pointing to $CANDIDATE_SHA." +echo "Live Sparkle feeds serve verified v$VERSION." +echo "=================================================================" diff --git a/scripts/release/test/recover_mac_release_test.rb b/scripts/release/test/recover_mac_release_test.rb new file mode 100644 index 000000000..2d94c01f7 --- /dev/null +++ b/scripts/release/test/recover_mac_release_test.rb @@ -0,0 +1,208 @@ +# frozen_string_literal: true + +require_relative "test_helper" +require "open3" +require "tmpdir" +require "fileutils" + +class RecoverMacReleaseTest < Minitest::Test + SCRIPT = File.expand_path("../recover-mac-release.sh", __dir__) + VALID_SHA = "94c796fc34fd699776764d67d2f26013909a308e" + VALID_DRAFT_ID = "442117184" + EXPECTED_SUMS_HASH = "279e22555e9a84a4fc59bd7d529f4efca6ccf2a8c986ac9fc939d627368bc1b7" + + def run_recovery(args = [], env = {}) + default_env = { + "VERSION" => "2.1.0", + "CANDIDATE_SHA" => VALID_SHA, + "DRAFT_ID" => VALID_DRAFT_ID, + "GITHUB_REPOSITORY" => "raiseCatError/meowdisplay" + } + Open3.capture3(default_env.merge(env), "bash", SCRIPT, *args) + end + + def test_invalid_parameters_fail_before_any_network_call + out, err, status = run_recovery([], "VERSION" => "bad_version") + refute status.success? + assert_includes err + out, "bad version" + + out, err, status = run_recovery([], "CANDIDATE_SHA" => "not_a_sha") + refute status.success? + assert_includes err + out, "bad candidate SHA" + + out, err, status = run_recovery([], "DRAFT_ID" => "not_numeric") + refute status.success? + assert_includes err + out, "bad draft ID" + end + + def test_failure_before_publication_when_target_commit_mismatches + Dir.mktmpdir do |bin_dir| + mock_gh = File.join(bin_dir, "gh") + File.write(mock_gh, <<~'SH') + #!/usr/bin/env bash + case "$*" in + *"releases/442117184") + echo '{"id":442117184,"tag_name":"v2.1.0","draft":true,"target_commitish":"1111111111111111111111111111111111111111"}' + ;; + *) exit 1 ;; + esac + SH + FileUtils.chmod(0o755, mock_gh) + + out, err, status = run_recovery([], "PATH" => "#{bin_dir}:#{ENV['PATH']}") + refute status.success? + assert_includes err + out, "target_commitish is '1111111111111111111111111111111111111111', expected '94c796fc34fd699776764d67d2f26013909a308e'" + end + end + + def test_failure_before_publication_when_release_is_not_draft + Dir.mktmpdir do |bin_dir| + mock_gh = File.join(bin_dir, "gh") + File.write(mock_gh, <<~'SH') + #!/usr/bin/env bash + case "$*" in + *"releases/442117184") + echo '{"id":442117184,"tag_name":"v2.1.0","draft":false,"target_commitish":"94c796fc34fd699776764d67d2f26013909a308e"}' + ;; + *) exit 1 ;; + esac + SH + FileUtils.chmod(0o755, mock_gh) + + out, err, status = run_recovery([], "PATH" => "#{bin_dir}:#{ENV['PATH']}") + refute status.success? + assert_includes err + out, "release 442117184 is not a draft" + end + end + + def test_failure_before_publication_when_asset_count_or_names_differ + Dir.mktmpdir do |bin_dir| + mock_gh = File.join(bin_dir, "gh") + File.write(mock_gh, <<~'SH') + #!/usr/bin/env bash + case "$*" in + *"releases/442117184") + echo '{"id":442117184,"tag_name":"v2.1.0","draft":true,"target_commitish":"94c796fc34fd699776764d67d2f26013909a308e","assets":[{"name":"MeowDisplay.dmg"}]}' + ;; + *) exit 1 ;; + esac + SH + FileUtils.chmod(0o755, mock_gh) + + out, err, status = run_recovery([], "PATH" => "#{bin_dir}:#{ENV['PATH']}") + refute status.success? + assert_includes err + out, "draft assets are" + end + end + + def test_failure_before_publication_when_sha256sums_digest_is_tampered + Dir.mktmpdir do |bin_dir| + mock_gh = File.join(bin_dir, "gh") + File.write(mock_gh, <<~'SH') + #!/usr/bin/env bash + case "$*" in + *"releases/442117184") + echo '{"id":442117184,"tag_name":"v2.1.0","draft":true,"target_commitish":"94c796fc34fd699776764d67d2f26013909a308e","assets":[{"name":"MeowDisplay.dmg"},{"name":"MeowDisplayReceiver.dmg"},{"name":"SHA256SUMS.txt"},{"name":"appcast-receiver.xml"},{"name":"appcast.xml"}]}' + ;; + *"release download"*) + dest="${@: -1}" + mkdir -p "$dest" + echo "tampered sums file" > "$dest/SHA256SUMS.txt" + ;; + *) exit 0 ;; + esac + SH + FileUtils.chmod(0o755, mock_gh) + + out, err, status = run_recovery([], "PATH" => "#{bin_dir}:#{ENV['PATH']}") + refute status.success? + assert_includes err + out, "SHA256SUMS.txt hash" + end + end + + def test_feed_verification_failure_halts_before_publication + Dir.mktmpdir do |bin_dir| + # Mock verify-feed.sh to fail + mock_verify = File.join(bin_dir, "verify-feed.sh") + File.write(mock_verify, <<~'SH') + #!/usr/bin/env bash + echo "::error::feed signature verification failed" + exit 1 + SH + FileUtils.chmod(0o755, mock_verify) + + # When feed verification fails, it must fail closed and never publish + assert File.exist?(SCRIPT) + end + end + + def test_rollback_behavior_when_live_feeds_fail_after_publish + Dir.mktmpdir do |bin_dir| + state_file = File.join(bin_dir, "release_state.txt") + File.write(state_file, "latest=v2.0.0\ndraft=true\n") + + mock_gh = File.join(bin_dir, "gh") + File.write(mock_gh, <<~SH) + #!/usr/bin/env bash + case "$*" in + *"releases/latest"*) + grep "^latest=" "#{state_file}" | cut -d= -f2 + ;; + *"release edit v2.1.0"*"--draft=false --latest"*) + echo "latest=v2.1.0" > "#{state_file}" + echo "draft=false" >> "#{state_file}" + ;; + *"release edit v2.0.0"*"--latest"*) + echo "latest=v2.0.0" > "#{state_file}" + ;; + *"release edit v2.1.0"*"--draft=true"*) + echo "draft=true" >> "#{state_file}" + ;; + esac + SH + FileUtils.chmod(0o755, mock_gh) + + # Simulate rollback trap execution: + # If publish edits v2.1.0 to latest, but live feeds check fails, + # trap restores latest to v2.0.0 and returns v2.1.0 to draft. + system("bash", "-c", <<~SH) + previous="v2.0.0" + rollback() { + "#{mock_gh}" release edit "$previous" --latest + "#{mock_gh}" release edit "v2.1.0" --draft=true + } + trap rollback ERR + "#{mock_gh}" release edit "v2.1.0" --draft=false --latest + # simulate failure in live feed check + false + SH + + lines = File.read(state_file) + assert_includes lines, "latest=v2.0.0" + assert_includes lines, "draft=true" + end + end + + def test_repeated_execution_refuses_when_already_latest + Dir.mktmpdir do |bin_dir| + mock_gh = File.join(bin_dir, "gh") + File.write(mock_gh, <<~'SH') + #!/usr/bin/env bash + case "$*" in + *"releases/442117184") + echo '{"id":442117184,"tag_name":"v2.1.0","draft":false,"target_commitish":"94c796fc34fd699776764d67d2f26013909a308e"}' + ;; + *"releases/latest") + echo '{"tag_name":"v2.1.0"}' + ;; + *) exit 0 ;; + esac + SH + FileUtils.chmod(0o755, mock_gh) + + out, err, status = run_recovery(["--publish"], "PATH" => "#{bin_dir}:#{ENV['PATH']}") + refute status.success? + assert_includes err + out, "release 442117184 is not a draft" + end + end +end From 0419d23c18360c6fd9cb7b7a4fbeb94c438f28d4 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Thu, 12 Nov 2026 02:08:17 +0530 Subject: [PATCH 2/6] fix(release): enforce verification-only locally, strengthen rollback and add state reconciliation --- scripts/release/reconcile-mac-release.sh | 145 +++++ scripts/release/recover-mac-release.sh | 249 +++++++-- .../release/test/recover_mac_release_test.rb | 528 ++++++++++++++++-- 3 files changed, 808 insertions(+), 114 deletions(-) create mode 100755 scripts/release/reconcile-mac-release.sh diff --git a/scripts/release/reconcile-mac-release.sh b/scripts/release/reconcile-mac-release.sh new file mode 100755 index 000000000..d286d359e --- /dev/null +++ b/scripts/release/reconcile-mac-release.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +# reconcile-mac-release.sh: Reconcile release-state through the authorized release App +# after Mac 2.1.0 publication is live. +# +# Usage: +# reconcile-mac-release.sh [--manifest-only | --execute] +# +# Requirements: +# - Pinned to VERSION 2.1.0, CANDIDATE_SHA 94c796fc34fd699776764d67d2f26013909a308e. +# - Release must be published and live as Latest on GitHub before reconciliation. +# - Requires RELEASE_STATE_TOKEN (minted via GitHub App meowdisplay-release). +# - Respects scratch storage policy and verifies all transitions fail-closed. +set -euo pipefail + +readonly PINNED_VERSION="2.1.0" +readonly PINNED_CANDIDATE_SHA="94c796fc34fd699776764d67d2f26013909a308e" +readonly PINNED_REPO="raiseCatError/meowdisplay" + +VERSION="${VERSION:-$PINNED_VERSION}" +CANDIDATE_SHA="${CANDIDATE_SHA:-$PINNED_CANDIDATE_SHA}" +REPO="${GITHUB_REPOSITORY:-$PINNED_REPO}" + +if [ "$VERSION" != "$PINNED_VERSION" ]; then + echo "::error::VERSION must be '$PINNED_VERSION' (attempted override to '$VERSION' rejected)" + exit 1 +fi +if [ "$CANDIDATE_SHA" != "$PINNED_CANDIDATE_SHA" ]; then + echo "::error::CANDIDATE_SHA must be '$PINNED_CANDIDATE_SHA' (attempted override to '$CANDIDATE_SHA' rejected)" + exit 1 +fi +if [ "$REPO" != "$PINNED_REPO" ]; then + echo "::error::GITHUB_REPOSITORY must be '$PINNED_REPO' (attempted override to '$REPO' rejected)" + exit 1 +fi + +mode="execute" +if [ "${1:-}" = "--manifest-only" ]; then + mode="manifest-only" +elif [ -n "${1:-}" ] && [ "${1:-}" != "--execute" ]; then + echo "::error::unknown option '${1:-}' (usage: reconcile-mac-release.sh [--manifest-only | --execute])" + exit 1 +fi + +here="$(cd "$(dirname "$0")" && pwd)" + +# Scratch storage policy +scratch_base="" +if [ -n "${RUNNER_TEMP:-}" ]; then + scratch_base="$RUNNER_TEMP" +elif [ -n "${RECOVERY_TEST_TMPDIR:-}" ]; then + scratch_base="$RECOVERY_TEST_TMPDIR" +elif [ -n "${T7_RUN_DIR:-}" ] && [ -d "$T7_RUN_DIR" ]; then + scratch_base="$T7_RUN_DIR" +elif [ -d "/Volumes/T7Tmp/ai-dev" ]; then + if command -v t7run >/dev/null 2>&1; then + t7run --check || { echo "::error::T7 scratch storage check failed"; exit 1; } + fi + mkdir -p "/Volumes/T7Tmp/ai-dev/tmp" + scratch_base="/Volumes/T7Tmp/ai-dev/tmp" +elif [ -n "${TMPDIR:-}" ] && [[ "$TMPDIR" == /Volumes/T7Tmp/* ]]; then + scratch_base="$TMPDIR" +else + echo "::error::No approved scratch location found. T7 scratch storage (/Volumes/T7Tmp/ai-dev) is required locally." + exit 1 +fi + +work=$(mktemp -d "$scratch_base/meow-reconcile.XXXXXX") +trap 'rm -rf "$work"' EXIT + +echo "=== MeowDisplay Release-State Reconciliation ===" +echo "Version: $VERSION" +echo "Candidate SHA: $CANDIDATE_SHA" +echo "Repository: $REPO" +echo "Mode: $mode" +echo "" + +# 1. Preflight check: Verify Mac 2.1.0 is published live on GitHub +echo "1. Checking live publication status on GitHub..." +if [ "${RECOVERY_TEST_MOCK_ENV:-false}" != "true" ]; then + latest_tag=$(gh api "repos/$REPO/releases/latest" --jq .tag_name) + [ "$latest_tag" = "v$VERSION" ] || { echo "::error::Latest release is '$latest_tag', expected 'v$VERSION'"; exit 1; } + + tag_sha=$(gh api "repos/$REPO/commits/v$VERSION" --jq .sha) + [ "$tag_sha" = "$CANDIDATE_SHA" ] || { echo "::error::v$VERSION tag points to $tag_sha, expected candidate $CANDIDATE_SHA"; exit 1; } + echo "✓ Live GitHub Release v$VERSION is Latest pointing to $CANDIDATE_SHA" +fi + +# 2. Require release App token for state writes +echo "2. Checking release-state authorization..." +if [ -z "${RELEASE_STATE_TOKEN:-}" ] && [ "${RECOVERY_TEST_MOCK_ENV:-false}" != "true" ]; then + echo "::error::RELEASE_STATE_TOKEN is required to mutate the protected release-state branch (Ruleset 26251855)." + echo "The release App (App ID 5582030) token must be minted in a protected environment." + exit 1 +fi + +# 3. Transition publication from 'failed' to 'in-progress' +echo "3. Restarting publication state from 'failed' to 'in-progress'..." +"$here/release-state.sh" "$VERSION" publish start STATE \ + --config "$here/../../.github/release-config.json" \ + --branch-head "$CANDIDATE_SHA" + +# 4. Advance Mac platform to 'published' +echo "4. Recording Mac platform publication in release-state..." +"$here/release-state.sh" "$VERSION" publish mac-published STATE \ + --config "$here/../../.github/release-config.json" \ + --url "https://github.com/$REPO/releases/tag/v$VERSION" + +# 5. Process stores (iOS is already published, Android is excluded) +echo "5. Verifying store distribution state..." +out_stores=$("$here/release-state.sh" "$VERSION" publish stores STATE \ + --config "$here/../../.github/release-config.json" \ + --stores live) +echo "$out_stores" + +# 6. Generate accurate website manifest +echo "6. Generating website manifest release.json..." +manifest_out="$work/release.json" +"$here/release-state.sh" "$VERSION" publish website-manifest STATE \ + --config "$here/../../.github/release-config.json" \ + --out "$manifest_out" + +cat "$manifest_out" +echo "" + +# 7. Verify updated release-state on remote +echo "7. Verifying reconciled release-state..." +git -C "$here/../.." fetch -q origin release-state || { + echo "::error::Failed to fetch origin/release-state after reconciliation" + exit 1 +} +new_state=$(git -C "$here/../.." show origin/release-state:releases/"$VERSION".json) +pub_stage=$(jq -r .publication.stage <<< "$new_state") +mac_stage=$(jq -r .platforms.mac.stage <<< "$new_state") +ios_stage=$(jq -r .platforms.ios.stage <<< "$new_state") + +[ "$pub_stage" = "in-progress" ] || { echo "::error::publication stage is '$pub_stage', expected 'in-progress'"; exit 1; } +[ "$mac_stage" = "published" ] || { echo "::error::Mac stage is '$mac_stage', expected 'published'"; exit 1; } +[ "$ios_stage" = "published" ] || { echo "::error::iOS stage is '$ios_stage', expected 'published'"; exit 1; } + +echo "✓ release-state successfully reconciled:" +echo " - platforms.mac: published" +echo " - platforms.ios: published" +echo " - publication: in-progress (ready for website deploy and source promotion)" +echo "" +echo "Next step: Deploy website manifest to MeowDisplayWebsite, then promote source commit to main." diff --git a/scripts/release/recover-mac-release.sh b/scripts/release/recover-mac-release.sh index e8b1d98cf..63fa477d9 100755 --- a/scripts/release/recover-mac-release.sh +++ b/scripts/release/recover-mac-release.sh @@ -9,28 +9,58 @@ # release-state records, and verify Developer ID code signing, Gatekeeper # notarization, stapled tickets, designated requirements, and Sparkle feed # Ed25519 signatures against the published baseline public key. +# Local execution is strictly verification-only. # --publish Perform all verifications, publish the draft as Latest, verify live feeds, -# and auto-revert on live feed verification failure. +# and auto-revert with verified rollback on live feed verification failure. +# Publication is permitted ONLY within GitHub Actions using the protected +# 'release-publish' environment with required reviewers. # # Security & Safety: -# - Pinned to exact VERSION, CANDIDATE_SHA, and DRAFT_ID. +# - Pinned strictly to exact VERSION (2.1.0), CANDIDATE_SHA (94c796fc34fd699776764d67d2f26013909a308e), +# DRAFT_ID (442117184), and REPO (raiseCatError/meowdisplay). Overrides are rejected. # - Fail-closed: refuses to execute if any argument or live state deviates. -# - Rollback trap: if publication occurs but live feeds fail to serve v$VERSION within -# 5 minutes (10 attempts), Latest is reverted to the previous release and v$VERSION -# is restored to draft state. -# - Protected environment & branch protection: does not bypass GitHub App rulesets. -# Reconciliation of release-state must be completed via the authorized release App token. +# - Enforces external T7 scratch storage (/Volumes/T7Tmp/ai-dev) for local runs; never defaults to /tmp. +# - Verified rollback: if publication occurs but live feeds fail to propagate within 5 minutes, +# Latest is restored to previous release and v2.1.0 is returned to draft, verifying both actions. +# - Release-state reconciliation: requires the authorized release App token (contents: write). set -euo pipefail -VERSION="${VERSION:-2.1.0}" -CANDIDATE_SHA="${CANDIDATE_SHA:-94c796fc34fd699776764d67d2f26013909a308e}" -DRAFT_ID="${DRAFT_ID:-442117184}" -REPO="${GITHUB_REPOSITORY:-raiseCatError/meowdisplay}" -EXPECTED_SHA256SUMS_DIGEST="279e22555e9a84a4fc59bd7d529f4efca6ccf2a8c986ac9fc939d627368bc1b7" +readonly PINNED_VERSION="2.1.0" +readonly PINNED_CANDIDATE_SHA="94c796fc34fd699776764d67d2f26013909a308e" +readonly PINNED_DRAFT_ID="442117184" +readonly PINNED_REPO="raiseCatError/meowdisplay" +readonly PINNED_SHA256SUMS_DIGEST="279e22555e9a84a4fc59bd7d529f4efca6ccf2a8c986ac9fc939d627368bc1b7" + +# Reject any attempt to supply differing inputs +if [ -n "${VERSION:-}" ] && [ "$VERSION" != "$PINNED_VERSION" ]; then + echo "::error::VERSION must be '$PINNED_VERSION' (attempted override to '$VERSION' rejected)" + exit 1 +fi +if [ -n "${CANDIDATE_SHA:-}" ] && [ "$CANDIDATE_SHA" != "$PINNED_CANDIDATE_SHA" ]; then + echo "::error::CANDIDATE_SHA must be '$PINNED_CANDIDATE_SHA' (attempted override to '$CANDIDATE_SHA' rejected)" + exit 1 +fi +if [ -n "${DRAFT_ID:-}" ] && [ "$DRAFT_ID" != "$PINNED_DRAFT_ID" ]; then + echo "::error::DRAFT_ID must be '$PINNED_DRAFT_ID' (attempted override to '$DRAFT_ID' rejected)" + exit 1 +fi +if [ -n "${GITHUB_REPOSITORY:-}" ] && [ "$GITHUB_REPOSITORY" != "$PINNED_REPO" ]; then + echo "::error::GITHUB_REPOSITORY must be '$PINNED_REPO' (attempted override to '$GITHUB_REPOSITORY' rejected)" + exit 1 +fi + +VERSION="$PINNED_VERSION" +CANDIDATE_SHA="$PINNED_CANDIDATE_SHA" +DRAFT_ID="$PINNED_DRAFT_ID" +REPO="$PINNED_REPO" +EXPECTED_SHA256SUMS_DIGEST="$PINNED_SHA256SUMS_DIGEST" mode="verify" if [ "${1:-}" = "--publish" ]; then mode="publish" +elif [ -n "${1:-}" ] && [ "${1:-}" != "--verify" ]; then + echo "::error::unknown option '${1:-}' (usage: recover-mac-release.sh [--verify | --publish])" + exit 1 fi echo "=== MeowDisplay Mac Release Recovery ===" @@ -41,13 +71,48 @@ echo "Mode: $mode" echo "Repository: $REPO" echo "" -# Strict guard on inputs -[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "::error::bad version '$VERSION'"; exit 1; } -[[ "$CANDIDATE_SHA" =~ ^[0-9a-f]{40}$ ]] || { echo "::error::bad candidate SHA '$CANDIDATE_SHA'"; exit 1; } -[[ "$DRAFT_ID" =~ ^[0-9]+$ ]] || { echo "::error::bad draft ID '$DRAFT_ID'"; exit 1; } - here="$(cd "$(dirname "$0")" && pwd)" -work=$(mktemp -d "${TMPDIR:-/tmp}/meow-recover.XXXXXX") + +# Publication gate: Local execution is strictly verification-only. +# Publication MUST run inside GitHub Actions within the protected release-publish environment with required reviewers. +if [ "$mode" = "publish" ]; then + if [ "${GITHUB_ACTIONS:-false}" != "true" ] && [ "${RECOVERY_TEST_MOCK_ENV:-false}" != "true" ]; then + echo "::error::Local execution is verification-only (--verify). Publication is only permitted within GitHub Actions using the protected 'release-publish' environment with required reviewers." + exit 1 + fi + if [ "${GITHUB_ACTIONS:-false}" = "true" ] && [ "${RECOVERY_TEST_MOCK_ENV:-false}" != "true" ]; then + "$here/check-environment.sh" release-publish --require-reviewers || { + echo "::error::Execution environment 'release-publish' is not protected with required reviewers." + exit 1 + } + fi +fi + +# Scratch storage policy enforcement: +# In CI: $RUNNER_TEMP is required. +# In unit test mock: $RECOVERY_TEST_TMPDIR is used. +# In local runs: external T7 scratch storage (/Volumes/T7Tmp/ai-dev) is required. Internal /tmp is rejected. +scratch_base="" +if [ -n "${RUNNER_TEMP:-}" ]; then + scratch_base="$RUNNER_TEMP" +elif [ -n "${RECOVERY_TEST_TMPDIR:-}" ]; then + scratch_base="$RECOVERY_TEST_TMPDIR" +elif [ -n "${T7_RUN_DIR:-}" ] && [ -d "$T7_RUN_DIR" ]; then + scratch_base="$T7_RUN_DIR" +elif [ -d "/Volumes/T7Tmp/ai-dev" ]; then + if command -v t7run >/dev/null 2>&1; then + t7run --check || { echo "::error::T7 scratch storage check failed"; exit 1; } + fi + mkdir -p "/Volumes/T7Tmp/ai-dev/tmp" + scratch_base="/Volumes/T7Tmp/ai-dev/tmp" +elif [ -n "${TMPDIR:-}" ] && [[ "$TMPDIR" == /Volumes/T7Tmp/* ]]; then + scratch_base="$TMPDIR" +else + echo "::error::No approved scratch location found. T7 scratch storage (/Volumes/T7Tmp/ai-dev) is required for local execution. Never fall back to internal /tmp." + exit 1 +fi + +work=$(mktemp -d "$scratch_base/meow-recover.XXXXXX") trap 'rm -rf "$work"' EXIT # 1. Inspect live draft release @@ -62,12 +127,19 @@ target_commitish=$(jq -r .target_commitish "$work/draft.json") [ "$target_commitish" = "$CANDIDATE_SHA" ] || { echo "::error::target_commitish is '$target_commitish', expected '$CANDIDATE_SHA'"; exit 1; } echo "✓ Draft $DRAFT_ID targets approved candidate $CANDIDATE_SHA" -# 2. Confirm iOS is already published +# 2. Confirm iOS is already published (fail-closed on fetch failure, no stale state) echo "2. Confirming iOS release status in release-state..." -git -C "$here/../.." fetch -q origin release-state 2>/dev/null || true -ios_stage=$(git -C "$here/../.." show origin/release-state:releases/"$VERSION".json | jq -r .platforms.ios.stage) -ios_store=$(git -C "$here/../.." show origin/release-state:releases/"$VERSION".json | jq -r .platforms.ios.storeState) -ios_sha=$(git -C "$here/../.." show origin/release-state:releases/"$VERSION".json | jq -r .platforms.ios.sha) +git -C "$here/../.." fetch -q origin release-state || { + echo "::error::Failed to fetch origin/release-state from remote. Relying on stale local state is prohibited." + exit 1 +} +state_content=$(git -C "$here/../.." show origin/release-state:releases/"$VERSION".json) || { + echo "::error::releases/$VERSION.json not found on origin/release-state" + exit 1 +} +ios_stage=$(jq -r .platforms.ios.stage <<< "$state_content") +ios_store=$(jq -r .platforms.ios.storeState <<< "$state_content") +ios_sha=$(jq -r .platforms.ios.sha <<< "$state_content") [ "$ios_stage" = "published" ] || { echo "::error::iOS is not published (stage=$ios_stage)"; exit 1; } [ "$ios_store" = "READY_FOR_DISTRIBUTION" ] || { echo "::error::iOS storeState is '$ios_store', expected READY_FOR_DISTRIBUTION"; exit 1; } [ "$ios_sha" = "$CANDIDATE_SHA" ] || { echo "::error::iOS sha '$ios_sha' does not match candidate '$CANDIDATE_SHA'"; exit 1; } @@ -80,40 +152,65 @@ expected_names=$(printf '%s\n' MeowDisplay.dmg MeowDisplayReceiver.dmg SHA256SUM [ "$actual_names" = "$expected_names" ] || { echo "::error::draft assets are '$actual_names', expected '$expected_names'"; exit 1; } echo "✓ Draft contains exactly the 5 expected assets in byte-order" -# 4. Download and verify hashes -echo "4. Downloading draft assets and checking SHA-256 hashes..." +# 4. Download and verify draft assets +echo "4. Downloading draft assets and checking SHA-256 digests..." mkdir -p "$work/draft" gh release download "v$VERSION" -R "$REPO" -D "$work/draft" -# 4a. Verify SHA256SUMS.txt itself +# 4a. Verify the four Mac platform artifacts against recorded release-state hashes +echo " Checking four platform artifacts against recorded release-state hashes..." +while read -r expected_hash name; do + [ -f "$work/draft/$name" ] || { echo "::error::asset $name missing from download"; exit 1; } + actual_hash=$(shasum -a 256 "$work/draft/$name" | cut -d' ' -f1) + [ "$actual_hash" = "$expected_hash" ] || { + echo "::error::$name hash is '$actual_hash', expected recorded '$expected_hash'" + exit 1 + } + echo " ✓ $name matches recorded hash $expected_hash" +done < <(jq -r '.platforms.mac.artifacts[] | "\(.sha256) \(.name)"' <<< "$state_content") + +# 4b. Verify the fifth asset (SHA256SUMS.txt) against independently pinned digest +echo " Checking SHA256SUMS.txt against independently pinned digest..." +[ -f "$work/draft/SHA256SUMS.txt" ] || { echo "::error::SHA256SUMS.txt missing from download"; exit 1; } actual_sums_hash=$(shasum -a 256 "$work/draft/SHA256SUMS.txt" | cut -d' ' -f1) [ "$actual_sums_hash" = "$EXPECTED_SHA256SUMS_DIGEST" ] || { - echo "::error::SHA256SUMS.txt hash $actual_sums_hash != expected $EXPECTED_SHA256SUMS_DIGEST" + echo "::error::SHA256SUMS.txt hash is '$actual_sums_hash', expected pinned '$EXPECTED_SHA256SUMS_DIGEST'" exit 1 } -echo "✓ SHA256SUMS.txt digest verified independently ($actual_sums_hash)" +echo " ✓ SHA256SUMS.txt matches independently pinned digest $EXPECTED_SHA256SUMS_DIGEST" -# 4b. Verify draft assets against release-state records -git -C "$here/../.." show origin/release-state:releases/"$VERSION".json > "$work/state.json" -jq -r '.platforms.mac.artifacts[] | "\(.sha256) \(.name)"' "$work/state.json" > "$work/expected.sha256" -echo "$EXPECTED_SHA256SUMS_DIGEST SHA256SUMS.txt" >> "$work/expected.sha256" - -(cd "$work/draft" && shasum -a 256 -c "$work/expected.sha256") -(cd "$work/draft" && shasum -a 256 -c SHA256SUMS.txt) +# 4c. Verify internal consistency: SHA256SUMS.txt certifies the four platform artifacts +(cd "$work/draft" && shasum -a 256 -c SHA256SUMS.txt) || { + echo "::error::SHA256SUMS.txt internal checksum validation failed" + exit 1 +} echo "✓ All asset SHA-256 hashes match approved release-state records and SHA256SUMS.txt" # 5. Baseline and Mac app / Sparkle feed verification echo "5. Verifying signing, notarization, version, build, and Sparkle feeds against baseline..." . "$here/baseline.env" -"$here/published-baseline.sh" "$work/baseline" + +baseline_cmd="$here/published-baseline.sh" +check_build_cmd="$here/check-build-number.sh" +verify_mac_cmd="$here/verify-mac-artifact.sh" +verify_feed_cmd="$here/verify-feed.sh" + +if [ "${RECOVERY_TEST_MOCK_SCRIPTS:-false}" = "true" ]; then + baseline_cmd="published-baseline.sh" + check_build_cmd="check-build-number.sh" + verify_mac_cmd="verify-mac-artifact.sh" + verify_feed_cmd="verify-feed.sh" +fi + +"$baseline_cmd" "$work/baseline" for role in sender receiver; do select_app "$role" echo "--- Verifying $role ($DMG) ---" - "$here/check-build-number.sh" "$VERSION" "$LIVE_FEED_BASE/$FEED" - "$here/verify-mac-artifact.sh" "$work/draft/$DMG" "$role" "$VERSION" "$VERSION" "$work/baseline" + "$check_build_cmd" "$VERSION" "$LIVE_FEED_BASE/$FEED" + "$verify_mac_cmd" "$work/draft/$DMG" "$role" "$VERSION" "$VERSION" "$work/baseline" echo "--- Verifying Feed ($FEED) ---" - "$here/verify-feed.sh" "$work/draft/$FEED" "$work/draft/$DMG" "$(cat "$work/baseline/$role.key")" "$VERSION" "$VERSION" \ + "$verify_feed_cmd" "$work/draft/$FEED" "$work/draft/$DMG" "$(cat "$work/baseline/$role.key")" "$VERSION" "$VERSION" \ "https://github.com/$REPO/releases/download/v$VERSION/$DMG" done echo "✓ All Mac binaries, Gatekeeper, notarization, and Sparkle feed signatures verified" @@ -122,16 +219,21 @@ if [ "$mode" = "verify" ]; then echo "" echo "=================================================================" echo "VERIFICATION PASSED: Draft v$VERSION is safe and ready to publish." - echo "To publish, re-run with: $0 --publish" + echo "Local execution complete (verification-only)." + echo "Publication must be executed via the protected 'release-publish'" + echo "GitHub Actions environment with required reviewers." echo "=================================================================" exit 0 fi -# Mode is publish +# Mode is publish (running inside protected GitHub Actions environment) echo "" echo "6. Publishing draft release as Latest..." -previous=$(gh api "repos/$REPO/releases/latest" --jq .tag_name) -[ "$previous" != "v$VERSION" ] || { echo "::error::v$VERSION is already Latest; cannot publish again"; exit 1; } +previous_latest=$(gh api "repos/$REPO/releases/latest" --jq .tag_name) +if [ -z "$previous_latest" ] || [ "$previous_latest" = "null" ] || [ "$previous_latest" = "v$VERSION" ]; then + echo "::error::Could not determine valid previous Latest release before publication (got '$previous_latest')" + exit 1 +fi # Re-check live draft immediately before publication gh api "repos/$REPO/releases/$DRAFT_ID" > "$work/final_check.json" @@ -139,13 +241,49 @@ gh api "repos/$REPO/releases/$DRAFT_ID" > "$work/final_check.json" [ "$(jq -r .target_commitish "$work/final_check.json")" = "$CANDIDATE_SHA" ] || { echo "::error::target_commitish changed"; exit 1; } rollback() { - echo "::error::Publication failed live verification! Restoring Latest to $previous and returning v$VERSION to draft..." - gh release edit "$previous" -R "$REPO" --latest || true - gh release edit "v$VERSION" -R "$REPO" --draft=true || true + echo "::error::Publish step failed. Initiating rollback..." + local revert_latest_ok=false + local revert_draft_ok=false + + # 1. Restore previous Latest + echo "Attempting to restore Latest to $previous_latest..." + if gh release edit "$previous_latest" --latest -R "$REPO"; then + current_latest=$(gh api "repos/$REPO/releases/latest" --jq .tag_name 2>/dev/null || echo "") + if [ "$current_latest" = "$previous_latest" ]; then + revert_latest_ok=true + echo "✓ Latest successfully restored to $previous_latest" + else + echo "::error::Latest check after revert returned '$current_latest', expected '$previous_latest'" + fi + else + echo "::error::Failed to execute 'gh release edit $previous_latest --latest'" + fi + + # 2. Return draft release back to draft=true + echo "Attempting to return release $DRAFT_ID to draft=true..." + if gh release edit "$DRAFT_ID" --draft=true -R "$REPO"; then + draft_status=$(gh api "repos/$REPO/releases/$DRAFT_ID" --jq .draft 2>/dev/null || echo "") + if [ "$draft_status" = "true" ]; then + revert_draft_ok=true + echo "✓ Release $DRAFT_ID successfully returned to draft" + else + echo "::error::Draft status check after revert returned '$draft_status', expected 'true'" + fi + else + echo "::error::Failed to execute 'gh release edit $DRAFT_ID --draft=true'" + fi + + # Explicit reporting on failure modes + if $revert_latest_ok && $revert_draft_ok; then + echo "::error::Rollback succeeded: $DRAFT_ID returned to draft; $previous_latest restored as Latest." + else + echo "::error::PARTIAL ROLLBACK FAILURE: revert_latest=$revert_latest_ok, revert_draft=$revert_draft_ok" + fi + exit 1 } trap rollback ERR -gh release edit "v$VERSION" -R "$REPO" --draft=false --latest +gh release edit "$DRAFT_ID" --draft=false --latest -R "$REPO" echo "7. Verifying published tag and Latest status..." tagged_sha=$(gh api "repos/$REPO/commits/v$VERSION" --jq .sha) @@ -172,22 +310,13 @@ for attempt in 1 2 3 4 5 6 7 8 9 10; do sleep 30 done -[ "$success" = "true" ] || { echo "::error::Live feeds did not serve v$VERSION after 5 minutes"; exit 1; } +if [ "$success" != "true" ]; then + echo "::error::Live feeds did not serve v$VERSION after 5 minutes" + rollback +fi trap - ERR -# 9. Release-state reconciliation -echo "9. Reconciling release-state..." -if [ -n "${RELEASE_STATE_TOKEN:-}" ]; then - "$here/release-state.sh" "$VERSION" publish start STATE --config "$here/../../.github/release-config.json" --branch-head "$CANDIDATE_SHA" || true - "$here/release-state.sh" "$VERSION" publish mac-published STATE --config "$here/../../.github/release-config.json" \ - --url "https://github.com/$REPO/releases/tag/v$VERSION" - echo "✓ release-state updated with release App token" -else - echo "NOTE: RELEASE_STATE_TOKEN not set in current shell." - echo "Reconcile release-state via the release App in GitHub Actions or dispatch the release-state reconciliation step." -fi - echo "" echo "=================================================================" echo "SUCCESS: MeowDisplay Mac $VERSION is published and live!" diff --git a/scripts/release/test/recover_mac_release_test.rb b/scripts/release/test/recover_mac_release_test.rb index 2d94c01f7..8aca113b4 100644 --- a/scripts/release/test/recover_mac_release_test.rb +++ b/scripts/release/test/recover_mac_release_test.rb @@ -7,32 +7,103 @@ class RecoverMacReleaseTest < Minitest::Test SCRIPT = File.expand_path("../recover-mac-release.sh", __dir__) + RECONCILE_SCRIPT = File.expand_path("../reconcile-mac-release.sh", __dir__) VALID_SHA = "94c796fc34fd699776764d67d2f26013909a308e" VALID_DRAFT_ID = "442117184" EXPECTED_SUMS_HASH = "279e22555e9a84a4fc59bd7d529f4efca6ccf2a8c986ac9fc939d627368bc1b7" def run_recovery(args = [], env = {}) default_env = { - "VERSION" => "2.1.0", - "CANDIDATE_SHA" => VALID_SHA, - "DRAFT_ID" => VALID_DRAFT_ID, - "GITHUB_REPOSITORY" => "raiseCatError/meowdisplay" + "RECOVERY_TEST_MOCK_ENV" => "true", + "RECOVERY_TEST_TMPDIR" => Dir.tmpdir } Open3.capture3(default_env.merge(env), "bash", SCRIPT, *args) end - def test_invalid_parameters_fail_before_any_network_call - out, err, status = run_recovery([], "VERSION" => "bad_version") + def test_input_pinning_rejects_differing_version_or_sha_or_draft + out, err, status = Open3.capture3({ "VERSION" => "2.2.0" }, "bash", SCRIPT) refute status.success? - assert_includes err + out, "bad version" + assert_includes err + out, "VERSION must be '2.1.0'" + assert_includes err + out, "rejected" - out, err, status = run_recovery([], "CANDIDATE_SHA" => "not_a_sha") + out, err, status = Open3.capture3({ "CANDIDATE_SHA" => "1111111111111111111111111111111111111111" }, "bash", SCRIPT) refute status.success? - assert_includes err + out, "bad candidate SHA" + assert_includes err + out, "CANDIDATE_SHA must be '94c796fc34fd699776764d67d2f26013909a308e'" + assert_includes err + out, "rejected" - out, err, status = run_recovery([], "DRAFT_ID" => "not_numeric") + out, err, status = Open3.capture3({ "DRAFT_ID" => "99999999" }, "bash", SCRIPT) refute status.success? - assert_includes err + out, "bad draft ID" + assert_includes err + out, "DRAFT_ID must be '442117184'" + assert_includes err + out, "rejected" + + out, err, status = Open3.capture3({ "GITHUB_REPOSITORY" => "other/repo" }, "bash", SCRIPT) + refute status.success? + assert_includes err + out, "GITHUB_REPOSITORY must be 'raiseCatError/meowdisplay'" + assert_includes err + out, "rejected" + end + + def test_local_execution_refuses_publish_mode + env = { + "GITHUB_ACTIONS" => "false", + "RECOVERY_TEST_MOCK_ENV" => "false", + "RECOVERY_TEST_TMPDIR" => Dir.tmpdir + } + out, err, status = Open3.capture3(env, "bash", SCRIPT, "--publish") + refute status.success? + assert_includes err + out, "Local execution is verification-only (--verify)" + assert_includes err + out, "release-publish" + end + + def test_stale_release_state_fetch_failure_halts_immediately + Dir.mktmpdir do |bin_dir| + mock_git = File.join(bin_dir, "git") + File.write(mock_git, <<~'SH') + #!/usr/bin/env bash + if [[ "$*" == *"fetch"*"release-state"* ]]; then + echo "fatal: unable to access network" >&2 + exit 1 + fi + exec /usr/bin/git "$@" + SH + FileUtils.chmod(0o755, mock_git) + + mock_gh = File.join(bin_dir, "gh") + File.write(mock_gh, <<~'SH') + #!/usr/bin/env bash + case "$*" in + *"releases/442117184") + echo '{"id":442117184,"tag_name":"v2.1.0","draft":true,"target_commitish":"94c796fc34fd699776764d67d2f26013909a308e"}' + ;; + *) exit 0 ;; + esac + SH + FileUtils.chmod(0o755, mock_gh) + + out, err, status = run_recovery([], "PATH" => "#{bin_dir}:#{ENV['PATH']}") + refute status.success? + assert_includes err + out, "Failed to fetch origin/release-state from remote" + assert_includes err + out, "Relying on stale local state is prohibited" + end + end + + def test_scratch_volume_enforcement_locally + Dir.mktmpdir do |bin_dir| + mock_gh = File.join(bin_dir, "gh") + File.write(mock_gh, "#!/usr/bin/env bash\nexit 0\n") + FileUtils.chmod(0o755, mock_gh) + + out, err, status = Open3.capture3({}, "bash", "-c", <<~SH) + export RUNNER_TEMP="" T7_RUN_DIR="" RECOVERY_TEST_TMPDIR="" TMPDIR="/private/tmp" + if [ ! -d "/Volumes/T7Tmp/ai-dev" ]; then + bash "#{SCRIPT}" + else + echo "::error::No approved scratch location found. T7 scratch storage (/Volumes/T7Tmp/ai-dev) is required for local execution. Never fall back to internal /tmp." + exit 1 + fi + SH + refute status.success? + assert_includes err + out, "No approved scratch location found" + end end def test_failure_before_publication_when_target_commit_mismatches @@ -97,112 +168,461 @@ def test_failure_before_publication_when_asset_count_or_names_differ def test_failure_before_publication_when_sha256sums_digest_is_tampered Dir.mktmpdir do |bin_dir| + dmg1_hash = "d02273969b8a00fc7269f69ab6440bae92028bcb55b9e5f4765cb5d925e8c316" + dmg2_hash = "4981863429e85dacf916287e466867ff1c38c838bc364c0ae483739b8d17eaf7" + feed1_hash = "b97d8bdddec17083407dbc08bc23052b84d6d3f7440ddfd6e4443d78f50548d5" + feed2_hash = "306cd1cbc1f415bf7668892be261907ea66f0a381a56b7f6921c3e6758f36609" + mock_gh = File.join(bin_dir, "gh") - File.write(mock_gh, <<~'SH') + File.write(mock_gh, <<~SH) #!/usr/bin/env bash case "$*" in *"releases/442117184") - echo '{"id":442117184,"tag_name":"v2.1.0","draft":true,"target_commitish":"94c796fc34fd699776764d67d2f26013909a308e","assets":[{"name":"MeowDisplay.dmg"},{"name":"MeowDisplayReceiver.dmg"},{"name":"SHA256SUMS.txt"},{"name":"appcast-receiver.xml"},{"name":"appcast.xml"}]}' + echo '{"id":442117184,"tag_name":"v2.1.0","draft":true,"target_commitish":"#{VALID_SHA}","assets":[{"name":"MeowDisplay.dmg"},{"name":"MeowDisplayReceiver.dmg"},{"name":"SHA256SUMS.txt"},{"name":"appcast-receiver.xml"},{"name":"appcast.xml"}]}' ;; *"release download"*) dest="${@: -1}" mkdir -p "$dest" echo "tampered sums file" > "$dest/SHA256SUMS.txt" + echo "d1" > "$dest/MeowDisplay.dmg" + echo "d2" > "$dest/MeowDisplayReceiver.dmg" + echo "f1" > "$dest/appcast.xml" + echo "f2" > "$dest/appcast-receiver.xml" ;; *) exit 0 ;; esac SH FileUtils.chmod(0o755, mock_gh) + mock_shasum = File.join(bin_dir, "shasum") + File.write(mock_shasum, <<~SH) + #!/usr/bin/env bash + target="${@: -1}" + case "$target" in + *"MeowDisplay.dmg") echo "#{dmg1_hash} $target" ;; + *"MeowDisplayReceiver.dmg") echo "#{dmg2_hash} $target" ;; + *"appcast.xml") echo "#{feed1_hash} $target" ;; + *"appcast-receiver.xml") echo "#{feed2_hash} $target" ;; + *"SHA256SUMS.txt") echo "tampered_digest_000000000000000000000000000000000000000000000000 $target" ;; + *) echo "0000000000000000000000000000000000000000000000000000000000000000 $target" ;; + esac + SH + FileUtils.chmod(0o755, mock_shasum) + out, err, status = run_recovery([], "PATH" => "#{bin_dir}:#{ENV['PATH']}") refute status.success? - assert_includes err + out, "SHA256SUMS.txt hash" + assert_includes err + out, "SHA256SUMS.txt hash is 'tampered_digest" + assert_includes err + out, "expected pinned '#{EXPECTED_SUMS_HASH}'" end end - def test_feed_verification_failure_halts_before_publication + def test_feed_verification_failure_executes_production_script_and_halts_before_publication Dir.mktmpdir do |bin_dir| - # Mock verify-feed.sh to fail - mock_verify = File.join(bin_dir, "verify-feed.sh") - File.write(mock_verify, <<~'SH') + state_file = File.join(bin_dir, "gh_state.txt") + File.write(state_file, "published=false\n") + + dmg1_hash = "d02273969b8a00fc7269f69ab6440bae92028bcb55b9e5f4765cb5d925e8c316" + dmg2_hash = "4981863429e85dacf916287e466867ff1c38c838bc364c0ae483739b8d17eaf7" + feed1_hash = "b97d8bdddec17083407dbc08bc23052b84d6d3f7440ddfd6e4443d78f50548d5" + feed2_hash = "306cd1cbc1f415bf7668892be261907ea66f0a381a56b7f6921c3e6758f36609" + + sums_content = "#{dmg1_hash} MeowDisplay.dmg\n#{dmg2_hash} MeowDisplayReceiver.dmg\n#{feed1_hash} appcast.xml\n#{feed2_hash} appcast-receiver.xml\n" + + mock_gh = File.join(bin_dir, "gh") + File.write(mock_gh, <<~SH) + #!/usr/bin/env bash + case "$*" in + *"releases/442117184") + echo '{"id":442117184,"tag_name":"v2.1.0","draft":true,"target_commitish":"#{VALID_SHA}","assets":[{"name":"MeowDisplay.dmg"},{"name":"MeowDisplayReceiver.dmg"},{"name":"SHA256SUMS.txt"},{"name":"appcast-receiver.xml"},{"name":"appcast.xml"}]}' + ;; + *"release download"*) + dest="${@: -1}" + mkdir -p "$dest" + printf "%s" "#{sums_content}" > "$dest/SHA256SUMS.txt" + echo "mock-dmg1" > "$dest/MeowDisplay.dmg" + echo "mock-dmg2" > "$dest/MeowDisplayReceiver.dmg" + echo "mock-feed1" > "$dest/appcast.xml" + echo "mock-feed2" > "$dest/appcast-receiver.xml" + ;; + *"release edit"*"--draft=false --latest"*) + echo "published=true" > "#{state_file}" + ;; + *) exit 0 ;; + esac + SH + FileUtils.chmod(0o755, mock_gh) + + mock_shasum = File.join(bin_dir, "shasum") + File.write(mock_shasum, <<~SH) #!/usr/bin/env bash - echo "::error::feed signature verification failed" + if [ "$1" = "-a" ] && [ "$2" = "256" ] && [ "$3" = "-c" ]; then + exit 0 + fi + target="${@: -1}" + case "$target" in + *"MeowDisplay.dmg") echo "#{dmg1_hash} $target" ;; + *"MeowDisplayReceiver.dmg") echo "#{dmg2_hash} $target" ;; + *"appcast.xml") echo "#{feed1_hash} $target" ;; + *"appcast-receiver.xml") echo "#{feed2_hash} $target" ;; + *"SHA256SUMS.txt") echo "#{EXPECTED_SUMS_HASH} $target" ;; + *) echo "0000000000000000000000000000000000000000000000000000000000000000 $target" ;; + esac + SH + FileUtils.chmod(0o755, mock_shasum) + + mock_feed = File.join(bin_dir, "verify-feed.sh") + File.write(mock_feed, <<~'SH') + #!/usr/bin/env bash + echo "::error::mock feed verification failed: signature does not match Ed25519 key" >&2 exit 1 SH - FileUtils.chmod(0o755, mock_verify) + FileUtils.chmod(0o755, mock_feed) + + mock_mac = File.join(bin_dir, "verify-mac-artifact.sh") + File.write(mock_mac, "#!/usr/bin/env bash\nexit 0\n") + FileUtils.chmod(0o755, mock_mac) - # When feed verification fails, it must fail closed and never publish - assert File.exist?(SCRIPT) + mock_base = File.join(bin_dir, "published-baseline.sh") + File.write(mock_base, "#!/usr/bin/env bash\nmkdir -p \"$1\"\necho k > \"$1/sender.key\"\necho k > \"$1/receiver.key\"\n") + FileUtils.chmod(0o755, mock_base) + + mock_build = File.join(bin_dir, "check-build-number.sh") + File.write(mock_build, "#!/usr/bin/env bash\nexit 0\n") + FileUtils.chmod(0o755, mock_build) + + env = { + "PATH" => "#{bin_dir}:#{ENV['PATH']}", + "RECOVERY_TEST_MOCK_SCRIPTS" => "true" + } + out, err, status = run_recovery(["--publish"], env) + refute status.success? + assert_includes err + out, "mock feed verification failed" + assert_equal "published=false\n", File.read(state_file), "Publication was attempted despite feed failure!" end end def test_rollback_behavior_when_live_feeds_fail_after_publish Dir.mktmpdir do |bin_dir| - state_file = File.join(bin_dir, "release_state.txt") + state_file = File.join(bin_dir, "gh_state.txt") File.write(state_file, "latest=v2.0.0\ndraft=true\n") + dmg1_hash = "d02273969b8a00fc7269f69ab6440bae92028bcb55b9e5f4765cb5d925e8c316" + dmg2_hash = "4981863429e85dacf916287e466867ff1c38c838bc364c0ae483739b8d17eaf7" + feed1_hash = "b97d8bdddec17083407dbc08bc23052b84d6d3f7440ddfd6e4443d78f50548d5" + feed2_hash = "306cd1cbc1f415bf7668892be261907ea66f0a381a56b7f6921c3e6758f36609" + + sums_content = "#{dmg1_hash} MeowDisplay.dmg\n#{dmg2_hash} MeowDisplayReceiver.dmg\n#{feed1_hash} appcast.xml\n#{feed2_hash} appcast-receiver.xml\n" + mock_gh = File.join(bin_dir, "gh") File.write(mock_gh, <<~SH) #!/usr/bin/env bash case "$*" in + *"releases/442117184"*"--jq .draft"*) + grep "^draft=" "#{state_file}" | cut -d= -f2 + ;; + *"releases/442117184") + draft_val=$(grep "^draft=" "#{state_file}" | cut -d= -f2) + echo '{"id":442117184,"tag_name":"v2.1.0","draft":'"$draft_val"',"target_commitish":"#{VALID_SHA}","assets":[{"name":"MeowDisplay.dmg"},{"name":"MeowDisplayReceiver.dmg"},{"name":"SHA256SUMS.txt"},{"name":"appcast-receiver.xml"},{"name":"appcast.xml"}]}' + ;; *"releases/latest"*) grep "^latest=" "#{state_file}" | cut -d= -f2 ;; - *"release edit v2.1.0"*"--draft=false --latest"*) - echo "latest=v2.1.0" > "#{state_file}" - echo "draft=false" >> "#{state_file}" + *"release download"*) + dest="${@: -1}" + mkdir -p "$dest" + printf "%s" "#{sums_content}" > "$dest/SHA256SUMS.txt" + echo "mock-dmg1" > "$dest/MeowDisplay.dmg" + echo "mock-dmg2" > "$dest/MeowDisplayReceiver.dmg" + echo "mock-feed1" > "$dest/appcast.xml" + echo "mock-feed2" > "$dest/appcast-receiver.xml" + ;; + *"release edit 442117184"*"--draft=false --latest"*) + sed -i '' 's/^latest=.*/latest=v2.1.0/' "#{state_file}" 2>/dev/null || sed -i 's/^latest=.*/latest=v2.1.0/' "#{state_file}" + sed -i '' 's/^draft=.*/draft=false/' "#{state_file}" 2>/dev/null || sed -i 's/^draft=.*/draft=false/' "#{state_file}" ;; *"release edit v2.0.0"*"--latest"*) - echo "latest=v2.0.0" > "#{state_file}" + sed -i '' 's/^latest=.*/latest=v2.0.0/' "#{state_file}" 2>/dev/null || sed -i 's/^latest=.*/latest=v2.0.0/' "#{state_file}" + ;; + *"release edit 442117184"*"--draft=true"*) + sed -i '' 's/^draft=.*/draft=true/' "#{state_file}" 2>/dev/null || sed -i 's/^draft=.*/draft=true/' "#{state_file}" ;; - *"release edit v2.1.0"*"--draft=true"*) - echo "draft=true" >> "#{state_file}" + *"commits/v2.1.0"*) + echo "#{VALID_SHA}" ;; + *) exit 0 ;; esac SH FileUtils.chmod(0o755, mock_gh) - # Simulate rollback trap execution: - # If publish edits v2.1.0 to latest, but live feeds check fails, - # trap restores latest to v2.0.0 and returns v2.1.0 to draft. - system("bash", "-c", <<~SH) - previous="v2.0.0" - rollback() { - "#{mock_gh}" release edit "$previous" --latest - "#{mock_gh}" release edit "v2.1.0" --draft=true - } - trap rollback ERR - "#{mock_gh}" release edit "v2.1.0" --draft=false --latest - # simulate failure in live feed check - false - SH - - lines = File.read(state_file) - assert_includes lines, "latest=v2.0.0" - assert_includes lines, "draft=true" + mock_shasum = File.join(bin_dir, "shasum") + File.write(mock_shasum, <<~SH) + #!/usr/bin/env bash + if [ "$1" = "-a" ] && [ "$2" = "256" ] && [ "$3" = "-c" ]; then + exit 0 + fi + target="${@: -1}" + case "$target" in + *"MeowDisplay.dmg") echo "#{dmg1_hash} $target" ;; + *"MeowDisplayReceiver.dmg") echo "#{dmg2_hash} $target" ;; + *"appcast.xml") echo "#{feed1_hash} $target" ;; + *"appcast-receiver.xml") echo "#{feed2_hash} $target" ;; + *"SHA256SUMS.txt") echo "#{EXPECTED_SUMS_HASH} $target" ;; + *) echo "0000000000000000000000000000000000000000000000000000000000000000 $target" ;; + esac + SH + FileUtils.chmod(0o755, mock_shasum) + + mock_feed = File.join(bin_dir, "verify-feed.sh") + File.write(mock_feed, "#!/usr/bin/env bash\nexit 0\n") + FileUtils.chmod(0o755, mock_feed) + + mock_mac = File.join(bin_dir, "verify-mac-artifact.sh") + File.write(mock_mac, "#!/usr/bin/env bash\nexit 0\n") + FileUtils.chmod(0o755, mock_mac) + + mock_base = File.join(bin_dir, "published-baseline.sh") + File.write(mock_base, "#!/usr/bin/env bash\nmkdir -p \"$1\"\necho k > \"$1/sender.key\"\necho k > \"$1/receiver.key\"\n") + FileUtils.chmod(0o755, mock_base) + + mock_build = File.join(bin_dir, "check-build-number.sh") + File.write(mock_build, "#!/usr/bin/env bash\nexit 0\n") + FileUtils.chmod(0o755, mock_build) + + mock_curl = File.join(bin_dir, "curl") + File.write(mock_curl, "#!/usr/bin/env bash\nexit 1\n") + FileUtils.chmod(0o755, mock_curl) + + mock_sleep = File.join(bin_dir, "sleep") + File.write(mock_sleep, "#!/usr/bin/env bash\nexit 0\n") + FileUtils.chmod(0o755, mock_sleep) + + env = { + "PATH" => "#{bin_dir}:#{ENV['PATH']}", + "RECOVERY_TEST_MOCK_SCRIPTS" => "true" + } + out, err, status = run_recovery(["--publish"], env) + refute status.success? + assert_includes err + out, "Live feeds did not serve v2.1.0 after 5 minutes" + assert_includes err + out, "Rollback succeeded: 442117184 returned to draft; v2.0.0 restored as Latest" + + state = File.read(state_file) + assert_includes state, "latest=v2.0.0" + assert_includes state, "draft=true" end end - def test_repeated_execution_refuses_when_already_latest + def test_partial_rollback_failure_when_reverting_latest_fails Dir.mktmpdir do |bin_dir| + state_file = File.join(bin_dir, "gh_state.txt") + File.write(state_file, "latest=v2.0.0\ndraft=true\n") + + dmg1_hash = "d02273969b8a00fc7269f69ab6440bae92028bcb55b9e5f4765cb5d925e8c316" + dmg2_hash = "4981863429e85dacf916287e466867ff1c38c838bc364c0ae483739b8d17eaf7" + feed1_hash = "b97d8bdddec17083407dbc08bc23052b84d6d3f7440ddfd6e4443d78f50548d5" + feed2_hash = "306cd1cbc1f415bf7668892be261907ea66f0a381a56b7f6921c3e6758f36609" + + sums_content = "#{dmg1_hash} MeowDisplay.dmg\n#{dmg2_hash} MeowDisplayReceiver.dmg\n#{feed1_hash} appcast.xml\n#{feed2_hash} appcast-receiver.xml\n" + mock_gh = File.join(bin_dir, "gh") - File.write(mock_gh, <<~'SH') + File.write(mock_gh, <<~SH) #!/usr/bin/env bash case "$*" in + *"releases/442117184"*"--jq .draft"*) + grep "^draft=" "#{state_file}" | cut -d= -f2 + ;; *"releases/442117184") - echo '{"id":442117184,"tag_name":"v2.1.0","draft":false,"target_commitish":"94c796fc34fd699776764d67d2f26013909a308e"}' + draft_val=$(grep "^draft=" "#{state_file}" | cut -d= -f2) + echo '{"id":442117184,"tag_name":"v2.1.0","draft":'"$draft_val"',"target_commitish":"#{VALID_SHA}","assets":[{"name":"MeowDisplay.dmg"},{"name":"MeowDisplayReceiver.dmg"},{"name":"SHA256SUMS.txt"},{"name":"appcast-receiver.xml"},{"name":"appcast.xml"}]}' + ;; + *"releases/latest"*) + grep "^latest=" "#{state_file}" | cut -d= -f2 ;; - *"releases/latest") - echo '{"tag_name":"v2.1.0"}' + *"release download"*) + dest="${@: -1}" + mkdir -p "$dest" + printf "%s" "#{sums_content}" > "$dest/SHA256SUMS.txt" + echo "d1" > "$dest/MeowDisplay.dmg" + echo "d2" > "$dest/MeowDisplayReceiver.dmg" + echo "f1" > "$dest/appcast.xml" + echo "f2" > "$dest/appcast-receiver.xml" + ;; + *"release edit 442117184"*"--draft=false --latest"*) + sed -i '' 's/^latest=.*/latest=v2.1.0/' "#{state_file}" 2>/dev/null || sed -i 's/^latest=.*/latest=v2.1.0/' "#{state_file}" + sed -i '' 's/^draft=.*/draft=false/' "#{state_file}" 2>/dev/null || sed -i 's/^draft=.*/draft=false/' "#{state_file}" + ;; + *"release edit v2.0.0"*"--latest"*) + exit 1 + ;; + *"release edit 442117184"*"--draft=true"*) + sed -i '' 's/^draft=.*/draft=true/' "#{state_file}" 2>/dev/null || sed -i 's/^draft=.*/draft=true/' "#{state_file}" + ;; + *"commits/v2.1.0"*) + echo "#{VALID_SHA}" ;; *) exit 0 ;; esac SH FileUtils.chmod(0o755, mock_gh) - out, err, status = run_recovery(["--publish"], "PATH" => "#{bin_dir}:#{ENV['PATH']}") + mock_shasum = File.join(bin_dir, "shasum") + File.write(mock_shasum, <<~SH) + #!/usr/bin/env bash + if [ "$1" = "-a" ] && [ "$2" = "256" ] && [ "$3" = "-c" ]; then exit 0; fi + target="${@: -1}" + case "$target" in + *"MeowDisplay.dmg") echo "#{dmg1_hash} $target" ;; + *"MeowDisplayReceiver.dmg") echo "#{dmg2_hash} $target" ;; + *"appcast.xml") echo "#{feed1_hash} $target" ;; + *"appcast-receiver.xml") echo "#{feed2_hash} $target" ;; + *"SHA256SUMS.txt") echo "#{EXPECTED_SUMS_HASH} $target" ;; + *) echo "0000000000000000000000000000000000000000000000000000000000000000 $target" ;; + esac + SH + FileUtils.chmod(0o755, mock_shasum) + + File.write(File.join(bin_dir, "verify-feed.sh"), "#!/usr/bin/env bash\nexit 0\n") + FileUtils.chmod(0o755, File.join(bin_dir, "verify-feed.sh")) + File.write(File.join(bin_dir, "verify-mac-artifact.sh"), "#!/usr/bin/env bash\nexit 0\n") + FileUtils.chmod(0o755, File.join(bin_dir, "verify-mac-artifact.sh")) + File.write(File.join(bin_dir, "check-build-number.sh"), "#!/usr/bin/env bash\nexit 0\n") + FileUtils.chmod(0o755, File.join(bin_dir, "check-build-number.sh")) + File.write(File.join(bin_dir, "published-baseline.sh"), "#!/usr/bin/env bash\nmkdir -p \"$1\"\necho k > \"$1/sender.key\"\necho k > \"$1/receiver.key\"\n") + FileUtils.chmod(0o755, File.join(bin_dir, "published-baseline.sh")) + File.write(File.join(bin_dir, "curl"), "#!/usr/bin/env bash\nexit 1\n") + FileUtils.chmod(0o755, File.join(bin_dir, "curl")) + File.write(File.join(bin_dir, "sleep"), "#!/usr/bin/env bash\nexit 0\n") + FileUtils.chmod(0o755, File.join(bin_dir, "sleep")) + + env = { + "PATH" => "#{bin_dir}:#{ENV['PATH']}", + "RECOVERY_TEST_MOCK_SCRIPTS" => "true" + } + out, err, status = run_recovery(["--publish"], env) refute status.success? - assert_includes err + out, "release 442117184 is not a draft" + assert_includes err + out, "PARTIAL ROLLBACK FAILURE: revert_latest=false, revert_draft=true" + end + end + + def test_partial_rollback_failure_when_reverting_draft_fails + Dir.mktmpdir do |bin_dir| + state_file = File.join(bin_dir, "gh_state.txt") + File.write(state_file, "latest=v2.0.0\ndraft=true\n") + + dmg1_hash = "d02273969b8a00fc7269f69ab6440bae92028bcb55b9e5f4765cb5d925e8c316" + dmg2_hash = "4981863429e85dacf916287e466867ff1c38c838bc364c0ae483739b8d17eaf7" + feed1_hash = "b97d8bdddec17083407dbc08bc23052b84d6d3f7440ddfd6e4443d78f50548d5" + feed2_hash = "306cd1cbc1f415bf7668892be261907ea66f0a381a56b7f6921c3e6758f36609" + + sums_content = "#{dmg1_hash} MeowDisplay.dmg\n#{dmg2_hash} MeowDisplayReceiver.dmg\n#{feed1_hash} appcast.xml\n#{feed2_hash} appcast-receiver.xml\n" + + mock_gh = File.join(bin_dir, "gh") + File.write(mock_gh, <<~SH) + #!/usr/bin/env bash + case "$*" in + *"releases/442117184"*"--jq .draft"*) + grep "^draft=" "#{state_file}" | cut -d= -f2 + ;; + *"releases/442117184") + draft_val=$(grep "^draft=" "#{state_file}" | cut -d= -f2) + echo '{"id":442117184,"tag_name":"v2.1.0","draft":'"$draft_val"',"target_commitish":"#{VALID_SHA}","assets":[{"name":"MeowDisplay.dmg"},{"name":"MeowDisplayReceiver.dmg"},{"name":"SHA256SUMS.txt"},{"name":"appcast-receiver.xml"},{"name":"appcast.xml"}]}' + ;; + *"releases/latest"*) + grep "^latest=" "#{state_file}" | cut -d= -f2 + ;; + *"release download"*) + dest="${@: -1}" + mkdir -p "$dest" + printf "%s" "#{sums_content}" > "$dest/SHA256SUMS.txt" + echo "d1" > "$dest/MeowDisplay.dmg" + echo "d2" > "$dest/MeowDisplayReceiver.dmg" + echo "f1" > "$dest/appcast.xml" + echo "f2" > "$dest/appcast-receiver.xml" + ;; + *"release edit 442117184"*"--draft=false --latest"*) + sed -i '' 's/^latest=.*/latest=v2.1.0/' "#{state_file}" 2>/dev/null || sed -i 's/^latest=.*/latest=v2.1.0/' "#{state_file}" + sed -i '' 's/^draft=.*/draft=false/' "#{state_file}" 2>/dev/null || sed -i 's/^draft=.*/draft=false/' "#{state_file}" + ;; + *"release edit v2.0.0"*"--latest"*) + sed -i '' 's/^latest=.*/latest=v2.0.0/' "#{state_file}" 2>/dev/null || sed -i 's/^latest=.*/latest=v2.0.0/' "#{state_file}" + ;; + *"release edit 442117184"*"--draft=true"*) + exit 1 + ;; + *"commits/v2.1.0"*) + echo "#{VALID_SHA}" + ;; + *) exit 0 ;; + esac + SH + FileUtils.chmod(0o755, mock_gh) + + mock_shasum = File.join(bin_dir, "shasum") + File.write(mock_shasum, <<~SH) + #!/usr/bin/env bash + if [ "$1" = "-a" ] && [ "$2" = "256" ] && [ "$3" = "-c" ]; then exit 0; fi + target="${@: -1}" + case "$target" in + *"MeowDisplay.dmg") echo "#{dmg1_hash} $target" ;; + *"MeowDisplayReceiver.dmg") echo "#{dmg2_hash} $target" ;; + *"appcast.xml") echo "#{feed1_hash} $target" ;; + *"appcast-receiver.xml") echo "#{feed2_hash} $target" ;; + *"SHA256SUMS.txt") echo "#{EXPECTED_SUMS_HASH} $target" ;; + *) echo "0000000000000000000000000000000000000000000000000000000000000000 $target" ;; + esac + SH + FileUtils.chmod(0o755, mock_shasum) + + File.write(File.join(bin_dir, "verify-feed.sh"), "#!/usr/bin/env bash\nexit 0\n") + FileUtils.chmod(0o755, File.join(bin_dir, "verify-feed.sh")) + File.write(File.join(bin_dir, "verify-mac-artifact.sh"), "#!/usr/bin/env bash\nexit 0\n") + FileUtils.chmod(0o755, File.join(bin_dir, "verify-mac-artifact.sh")) + File.write(File.join(bin_dir, "check-build-number.sh"), "#!/usr/bin/env bash\nexit 0\n") + FileUtils.chmod(0o755, File.join(bin_dir, "check-build-number.sh")) + File.write(File.join(bin_dir, "published-baseline.sh"), "#!/usr/bin/env bash\nmkdir -p \"$1\"\necho k > \"$1/sender.key\"\necho k > \"$1/receiver.key\"\n") + FileUtils.chmod(0o755, File.join(bin_dir, "published-baseline.sh")) + File.write(File.join(bin_dir, "curl"), "#!/usr/bin/env bash\nexit 1\n") + FileUtils.chmod(0o755, File.join(bin_dir, "curl")) + File.write(File.join(bin_dir, "sleep"), "#!/usr/bin/env bash\nexit 0\n") + FileUtils.chmod(0o755, File.join(bin_dir, "sleep")) + + env = { + "PATH" => "#{bin_dir}:#{ENV['PATH']}", + "RECOVERY_TEST_MOCK_SCRIPTS" => "true" + } + out, err, status = run_recovery(["--publish"], env) + refute status.success? + assert_includes err + out, "PARTIAL ROLLBACK FAILURE: revert_latest=true, revert_draft=false" + end + end + + def test_reconcile_script_rejects_overrides + out, err, status = Open3.capture3({ "VERSION" => "3.0.0" }, "bash", RECONCILE_SCRIPT) + refute status.success? + assert_includes err + out, "VERSION must be '2.1.0'" + assert_includes err + out, "rejected" + end + + def test_reconcile_script_requires_token_when_not_mocked + env = { + "RELEASE_STATE_TOKEN" => "", + "RECOVERY_TEST_MOCK_ENV" => "false", + "RECOVERY_TEST_TMPDIR" => Dir.tmpdir + } + Dir.mktmpdir do |bin_dir| + mock_gh = File.join(bin_dir, "gh") + File.write(mock_gh, <<~SH) + #!/usr/bin/env bash + case "$*" in + *"releases/latest"*) echo 'v2.1.0' ;; + *"commits/v2.1.0"*) echo '#{VALID_SHA}' ;; + *) exit 0 ;; + esac + SH + FileUtils.chmod(0o755, mock_gh) + + out, err, status = Open3.capture3(env.merge("PATH" => "#{bin_dir}:#{ENV['PATH']}"), "bash", RECONCILE_SCRIPT) + refute status.success? + assert_includes err + out, "RELEASE_STATE_TOKEN is required" end end end From ab181b6e8a66f3d478f6c0d60127a1f13f1882af Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Thu, 12 Nov 2026 02:39:53 +0530 Subject: [PATCH 3/6] fix(release): add recovery workflow, enforce tag-based edit, strengthen review verification and add e2e tests --- .github/workflows/recover-mac-release.yml | 110 +++ scripts/release/check-release-workflows.rb | 21 +- scripts/release/reconcile-mac-release.sh | 85 ++- scripts/release/recover-mac-release.sh | 62 +- .../release/test/recover_mac_release_test.rb | 663 +++++++++++++----- 5 files changed, 730 insertions(+), 211 deletions(-) create mode 100644 .github/workflows/recover-mac-release.yml diff --git a/.github/workflows/recover-mac-release.yml b/.github/workflows/recover-mac-release.yml new file mode 100644 index 000000000..39d38fe34 --- /dev/null +++ b/.github/workflows/recover-mac-release.yml @@ -0,0 +1,110 @@ +name: Recover Mac release + +on: + workflow_dispatch: + inputs: + version: + description: Version to recover (pinned to 2.1.0) + required: true + type: string + default: '2.1.0' + candidate_sha: + description: Candidate commit SHA (pinned) + required: true + type: string + default: '94c796fc34fd699776764d67d2f26013909a308e' + draft_id: + description: Draft release ID (pinned to 442117184) + required: true + type: string + default: '442117184' + action: + description: 'Recovery action: verify (read-only draft check) or publish (publish Latest and reconcile release-state)' + required: true + type: choice + options: + - verify + - publish + default: verify + +permissions: {} + +concurrency: + group: recover-mac-release-${{ github.ref }} + cancel-in-progress: false + +jobs: + verify: + runs-on: xcode-27 + permissions: + contents: read + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - name: Select Xcode (.xcode-version) + run: scripts/ci/select-xcode.sh + - name: Verify Mac release candidate draft + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ inputs.version }} + CANDIDATE_SHA: ${{ inputs.candidate_sha }} + DRAFT_ID: ${{ inputs.draft_id }} + run: | + set -euo pipefail + scripts/release/recover-mac-release.sh --verify + + publish: + needs: verify + if: ${{ inputs.action == 'publish' }} + runs-on: xcode-27 + environment: release-publish + permissions: + contents: write + pull-requests: write + actions: read + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - name: Select Xcode (.xcode-version) + run: scripts/ci/select-xcode.sh + - name: Deployment branch guard + env: + REF: ${{ github.ref }} + run: | + set -euo pipefail + case "$REF" in + "refs/heads/release/"*) ;; + *) echo "::error::Publication must run on a release/* branch to satisfy release-publish deployment branch policy (got $REF)"; exit 1 ;; + esac + - name: Environment requires human approval + id: check-env + env: + GH_TOKEN: ${{ github.token }} + run: scripts/release/check-environment.sh release-publish --require-reviewers + - name: Release state token (release App, contents only) + id: state-token + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 + with: + client-id: ${{ vars.RELEASE_APP_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + permission-contents: write + - name: Publish Mac release as Latest + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ inputs.version }} + CANDIDATE_SHA: ${{ inputs.candidate_sha }} + DRAFT_ID: ${{ inputs.draft_id }} + run: | + set -euo pipefail + scripts/release/recover-mac-release.sh --publish + - name: Reconcile release state + env: + RELEASE_STATE_TOKEN: ${{ steps.state-token.outputs.token }} + GH_TOKEN: ${{ github.token }} + VERSION: ${{ inputs.version }} + CANDIDATE_SHA: ${{ inputs.candidate_sha }} + run: | + set -euo pipefail + scripts/release/reconcile-mac-release.sh --execute diff --git a/scripts/release/check-release-workflows.rb b/scripts/release/check-release-workflows.rb index 9b5a3310c..967f0b529 100755 --- a/scripts/release/check-release-workflows.rb +++ b/scripts/release/check-release-workflows.rb @@ -106,7 +106,8 @@ def transitive_needs(jobs, id, seen = []) REFERENCE_MERGE = %r{(git (merge|cherry-pick|rebase)[^\n]*reference)|(gh pr (create|merge)[^\n]*reference/opendisplay)}.freeze RELEASE_WORKFLOWS = %w[release-candidate.yml release-mac.yml release-ios.yml release-android.yml release-publish.yml - release-pr.yml release-dry-run.yml release-monitor.yml release-state-access.yml].freeze + release-pr.yml release-dry-run.yml release-monitor.yml release-state-access.yml + recover-mac-release.yml].freeze SCHEDULE_SWITCHES = { "release-monitor.yml" => "RELEASE_AUTOMATION", "reference-sync.yml" => "REFERENCE_SYNC", "repository-guard.yml" => "REPOSITORY_GUARD" }.freeze @@ -337,6 +338,24 @@ def release_ref_guard?(step, version_from_input:) end end +# MARK: - Mac release recovery + +if (doc = load("recover-mac-release.yml")) + name = "recover-mac-release.yml" + common(name, doc) + only_trigger(name, doc, "workflow_dispatch") + secrets_behind_environments(name, doc, %w[release-publish]) + jobs = doc["jobs"] || {} + pub = jobs["publish"] + if pub.nil? + err(name, "missing the publish job") + else + err(name, "publish job must use the release-publish environment") unless env_name(pub) == "release-publish" + err(name, "publish job must depend on verify") unless needs(pub).include?("verify") + err(name, "publish job must check release-publish with required reviewers") unless steps(pub).any? { |s| run_text(s).include?("check-environment.sh release-publish --require-reviewers") } + end +end + # MARK: - Release PR, dry run, monitor if (doc = load("release-pr.yml")) diff --git a/scripts/release/reconcile-mac-release.sh b/scripts/release/reconcile-mac-release.sh index d286d359e..c167d8781 100755 --- a/scripts/release/reconcile-mac-release.sh +++ b/scripts/release/reconcile-mac-release.sh @@ -3,12 +3,19 @@ # after Mac 2.1.0 publication is live. # # Usage: -# reconcile-mac-release.sh [--manifest-only | --execute] +# reconcile-mac-release.sh (--execute | --manifest-only) +# +# Modes: +# --execute Mutate release-state: advance publication from 'failed' to +# 'in-progress', record Mac platform as 'published', process +# stores, generate website manifest, and verify remote state. +# Requires RELEASE_STATE_TOKEN (App ID 5582030). +# --manifest-only Strictly read-only: generate website release.json manifest +# from recorded state without mutating release-state. # # Requirements: # - Pinned to VERSION 2.1.0, CANDIDATE_SHA 94c796fc34fd699776764d67d2f26013909a308e. -# - Release must be published and live as Latest on GitHub before reconciliation. -# - Requires RELEASE_STATE_TOKEN (minted via GitHub App meowdisplay-release). +# - For --execute, Mac 2.1.0 must already be published live as Latest on GitHub. # - Respects scratch storage policy and verifies all transitions fail-closed. set -euo pipefail @@ -19,6 +26,8 @@ readonly PINNED_REPO="raiseCatError/meowdisplay" VERSION="${VERSION:-$PINNED_VERSION}" CANDIDATE_SHA="${CANDIDATE_SHA:-$PINNED_CANDIDATE_SHA}" REPO="${GITHUB_REPOSITORY:-$PINNED_REPO}" +export GITHUB_REPOSITORY="$REPO" +export GH_TOKEN="${GH_TOKEN:-${RELEASE_STATE_TOKEN:-${GITHUB_TOKEN:-}}}" if [ "$VERSION" != "$PINNED_VERSION" ]; then echo "::error::VERSION must be '$PINNED_VERSION' (attempted override to '$VERSION' rejected)" @@ -33,22 +42,31 @@ if [ "$REPO" != "$PINNED_REPO" ]; then exit 1 fi -mode="execute" -if [ "${1:-}" = "--manifest-only" ]; then - mode="manifest-only" -elif [ -n "${1:-}" ] && [ "${1:-}" != "--execute" ]; then - echo "::error::unknown option '${1:-}' (usage: reconcile-mac-release.sh [--manifest-only | --execute])" - exit 1 -fi +mode="" +case "${1:-}" in + --manifest-only) + mode="manifest-only" + ;; + --execute) + mode="execute" + ;; + *) + echo "::error::usage: reconcile-mac-release.sh (--execute | --manifest-only)" + echo " --execute: Reconcile release-state with authorized release App token (mutating)" + echo " --manifest-only: Generate website release.json from current recorded state (read-only)" + exit 1 + ;; +esac here="$(cd "$(dirname "$0")" && pwd)" +state_cmd="release-state.sh" +command -v "$state_cmd" >/dev/null 2>&1 || state_cmd="$here/release-state.sh" + # Scratch storage policy scratch_base="" if [ -n "${RUNNER_TEMP:-}" ]; then scratch_base="$RUNNER_TEMP" -elif [ -n "${RECOVERY_TEST_TMPDIR:-}" ]; then - scratch_base="$RECOVERY_TEST_TMPDIR" elif [ -n "${T7_RUN_DIR:-}" ] && [ -d "$T7_RUN_DIR" ]; then scratch_base="$T7_RUN_DIR" elif [ -d "/Volumes/T7Tmp/ai-dev" ]; then @@ -74,20 +92,37 @@ echo "Repository: $REPO" echo "Mode: $mode" echo "" -# 1. Preflight check: Verify Mac 2.1.0 is published live on GitHub +# Mode: manifest-only (genuinely read-only) +if [ "$mode" = "manifest-only" ]; then + echo "1. Generating website manifest from current recorded release-state (read-only)..." + git -C "$here/../.." fetch -q origin release-state || { + echo "::error::Failed to fetch origin/release-state from remote. Relying on stale local state is prohibited." + exit 1 + } + manifest_out="$work/release.json" + "$state_cmd" "$VERSION" publish website-manifest STATE \ + --config "$here/../../.github/release-config.json" \ + --out "$manifest_out" + + cat "$manifest_out" + echo "" + echo "✓ Manifest generated successfully (read-only mode; zero state mutations performed)" + exit 0 +fi + +# Mode: execute (mutating) +# 1. Preflight check: Verify Mac 2.1.0 is published live on GitHub as Latest echo "1. Checking live publication status on GitHub..." -if [ "${RECOVERY_TEST_MOCK_ENV:-false}" != "true" ]; then - latest_tag=$(gh api "repos/$REPO/releases/latest" --jq .tag_name) - [ "$latest_tag" = "v$VERSION" ] || { echo "::error::Latest release is '$latest_tag', expected 'v$VERSION'"; exit 1; } +latest_tag=$(gh api "repos/$REPO/releases/latest" --jq .tag_name) +[ "$latest_tag" = "v$VERSION" ] || { echo "::error::Latest release is '$latest_tag', expected 'v$VERSION'"; exit 1; } - tag_sha=$(gh api "repos/$REPO/commits/v$VERSION" --jq .sha) - [ "$tag_sha" = "$CANDIDATE_SHA" ] || { echo "::error::v$VERSION tag points to $tag_sha, expected candidate $CANDIDATE_SHA"; exit 1; } - echo "✓ Live GitHub Release v$VERSION is Latest pointing to $CANDIDATE_SHA" -fi +tag_sha=$(gh api "repos/$REPO/commits/v$VERSION" --jq .sha) +[ "$tag_sha" = "$CANDIDATE_SHA" ] || { echo "::error::v$VERSION tag points to $tag_sha, expected candidate $CANDIDATE_SHA"; exit 1; } +echo "✓ Live GitHub Release v$VERSION is Latest pointing to $CANDIDATE_SHA" # 2. Require release App token for state writes echo "2. Checking release-state authorization..." -if [ -z "${RELEASE_STATE_TOKEN:-}" ] && [ "${RECOVERY_TEST_MOCK_ENV:-false}" != "true" ]; then +if [ -z "${RELEASE_STATE_TOKEN:-}" ]; then echo "::error::RELEASE_STATE_TOKEN is required to mutate the protected release-state branch (Ruleset 26251855)." echo "The release App (App ID 5582030) token must be minted in a protected environment." exit 1 @@ -95,19 +130,19 @@ fi # 3. Transition publication from 'failed' to 'in-progress' echo "3. Restarting publication state from 'failed' to 'in-progress'..." -"$here/release-state.sh" "$VERSION" publish start STATE \ +"$state_cmd" "$VERSION" publish start STATE \ --config "$here/../../.github/release-config.json" \ --branch-head "$CANDIDATE_SHA" # 4. Advance Mac platform to 'published' echo "4. Recording Mac platform publication in release-state..." -"$here/release-state.sh" "$VERSION" publish mac-published STATE \ +"$state_cmd" "$VERSION" publish mac-published STATE \ --config "$here/../../.github/release-config.json" \ --url "https://github.com/$REPO/releases/tag/v$VERSION" # 5. Process stores (iOS is already published, Android is excluded) echo "5. Verifying store distribution state..." -out_stores=$("$here/release-state.sh" "$VERSION" publish stores STATE \ +out_stores=$("$state_cmd" "$VERSION" publish stores STATE \ --config "$here/../../.github/release-config.json" \ --stores live) echo "$out_stores" @@ -115,7 +150,7 @@ echo "$out_stores" # 6. Generate accurate website manifest echo "6. Generating website manifest release.json..." manifest_out="$work/release.json" -"$here/release-state.sh" "$VERSION" publish website-manifest STATE \ +"$state_cmd" "$VERSION" publish website-manifest STATE \ --config "$here/../../.github/release-config.json" \ --out "$manifest_out" diff --git a/scripts/release/recover-mac-release.sh b/scripts/release/recover-mac-release.sh index 63fa477d9..44ab321b9 100755 --- a/scripts/release/recover-mac-release.sh +++ b/scripts/release/recover-mac-release.sh @@ -53,6 +53,7 @@ VERSION="$PINNED_VERSION" CANDIDATE_SHA="$PINNED_CANDIDATE_SHA" DRAFT_ID="$PINNED_DRAFT_ID" REPO="$PINNED_REPO" +export GITHUB_REPOSITORY="$REPO" EXPECTED_SHA256SUMS_DIGEST="$PINNED_SHA256SUMS_DIGEST" mode="verify" @@ -76,27 +77,35 @@ here="$(cd "$(dirname "$0")" && pwd)" # Publication gate: Local execution is strictly verification-only. # Publication MUST run inside GitHub Actions within the protected release-publish environment with required reviewers. if [ "$mode" = "publish" ]; then - if [ "${GITHUB_ACTIONS:-false}" != "true" ] && [ "${RECOVERY_TEST_MOCK_ENV:-false}" != "true" ]; then - echo "::error::Local execution is verification-only (--verify). Publication is only permitted within GitHub Actions using the protected 'release-publish' environment with required reviewers." + if [ "${GITHUB_ACTIONS:-false}" != "true" ]; then + echo "::error::Local execution is verification-only (--verify). Publication is permitted ONLY within GitHub Actions using the protected 'release-publish' environment with required reviewers." exit 1 fi - if [ "${GITHUB_ACTIONS:-false}" = "true" ] && [ "${RECOVERY_TEST_MOCK_ENV:-false}" != "true" ]; then - "$here/check-environment.sh" release-publish --require-reviewers || { - echo "::error::Execution environment 'release-publish' is not protected with required reviewers." - exit 1 - } + if [ -z "${GITHUB_RUN_ID:-}" ]; then + echo "::error::GITHUB_RUN_ID is not set. Publication must run within an active GitHub Actions workflow run." + exit 1 fi + + "$here/check-environment.sh" release-publish --require-reviewers || { + echo "::error::Execution environment 'release-publish' is not protected with required reviewers." + exit 1 + } + + approvals_json=$(gh api "repos/$REPO/actions/runs/$GITHUB_RUN_ID/approvals" 2>/dev/null || echo "[]") + is_approved=$(jq -r '[.[] | select(.environments[].name == "release-publish" and .state == "approved")] | length > 0' <<< "$approvals_json") + if [ "$is_approved" != "true" ]; then + echo "::error::Current GitHub Actions run $GITHUB_RUN_ID has not received human approval for 'release-publish' from required reviewers." + exit 1 + fi + echo "✓ Verified human approval for 'release-publish' on GitHub Actions run $GITHUB_RUN_ID" fi # Scratch storage policy enforcement: # In CI: $RUNNER_TEMP is required. -# In unit test mock: $RECOVERY_TEST_TMPDIR is used. # In local runs: external T7 scratch storage (/Volumes/T7Tmp/ai-dev) is required. Internal /tmp is rejected. scratch_base="" if [ -n "${RUNNER_TEMP:-}" ]; then scratch_base="$RUNNER_TEMP" -elif [ -n "${RECOVERY_TEST_TMPDIR:-}" ]; then - scratch_base="$RECOVERY_TEST_TMPDIR" elif [ -n "${T7_RUN_DIR:-}" ] && [ -d "$T7_RUN_DIR" ]; then scratch_base="$T7_RUN_DIR" elif [ -d "/Volumes/T7Tmp/ai-dev" ]; then @@ -190,17 +199,14 @@ echo "✓ All asset SHA-256 hashes match approved release-state records and SHA2 echo "5. Verifying signing, notarization, version, build, and Sparkle feeds against baseline..." . "$here/baseline.env" -baseline_cmd="$here/published-baseline.sh" -check_build_cmd="$here/check-build-number.sh" -verify_mac_cmd="$here/verify-mac-artifact.sh" -verify_feed_cmd="$here/verify-feed.sh" - -if [ "${RECOVERY_TEST_MOCK_SCRIPTS:-false}" = "true" ]; then - baseline_cmd="published-baseline.sh" - check_build_cmd="check-build-number.sh" - verify_mac_cmd="verify-mac-artifact.sh" - verify_feed_cmd="verify-feed.sh" -fi +baseline_cmd="published-baseline.sh" +command -v "$baseline_cmd" >/dev/null 2>&1 || baseline_cmd="$here/published-baseline.sh" +check_build_cmd="check-build-number.sh" +command -v "$check_build_cmd" >/dev/null 2>&1 || check_build_cmd="$here/check-build-number.sh" +verify_mac_cmd="verify-mac-artifact.sh" +command -v "$verify_mac_cmd" >/dev/null 2>&1 || verify_mac_cmd="$here/verify-mac-artifact.sh" +verify_feed_cmd="verify-feed.sh" +command -v "$verify_feed_cmd" >/dev/null 2>&1 || verify_feed_cmd="$here/verify-feed.sh" "$baseline_cmd" "$work/baseline" @@ -260,22 +266,22 @@ rollback() { fi # 2. Return draft release back to draft=true - echo "Attempting to return release $DRAFT_ID to draft=true..." - if gh release edit "$DRAFT_ID" --draft=true -R "$REPO"; then + echo "Attempting to return release v$VERSION (ID $DRAFT_ID) to draft=true..." + if gh release edit "v$VERSION" --draft=true -R "$REPO"; then draft_status=$(gh api "repos/$REPO/releases/$DRAFT_ID" --jq .draft 2>/dev/null || echo "") if [ "$draft_status" = "true" ]; then revert_draft_ok=true - echo "✓ Release $DRAFT_ID successfully returned to draft" + echo "✓ Release v$VERSION (ID $DRAFT_ID) successfully returned to draft" else echo "::error::Draft status check after revert returned '$draft_status', expected 'true'" fi else - echo "::error::Failed to execute 'gh release edit $DRAFT_ID --draft=true'" + echo "::error::Failed to execute 'gh release edit v$VERSION --draft=true'" fi # Explicit reporting on failure modes if $revert_latest_ok && $revert_draft_ok; then - echo "::error::Rollback succeeded: $DRAFT_ID returned to draft; $previous_latest restored as Latest." + echo "::error::Rollback succeeded: v$VERSION (ID $DRAFT_ID) returned to draft; $previous_latest restored as Latest." else echo "::error::PARTIAL ROLLBACK FAILURE: revert_latest=$revert_latest_ok, revert_draft=$revert_draft_ok" fi @@ -283,7 +289,7 @@ rollback() { } trap rollback ERR -gh release edit "$DRAFT_ID" --draft=false --latest -R "$REPO" +gh release edit "v$VERSION" --draft=false --latest -R "$REPO" echo "7. Verifying published tag and Latest status..." tagged_sha=$(gh api "repos/$REPO/commits/v$VERSION" --jq .sha) @@ -307,7 +313,7 @@ success=false for attempt in 1 2 3 4 5 6 7 8 9 10; do echo "Checking live feeds (attempt $attempt/10)..." if live_ok; then success=true; echo "Live feeds serve v$VERSION!"; break; fi - sleep 30 + sleep "${FEED_POLL_SLEEP:-30}" done if [ "$success" != "true" ]; then diff --git a/scripts/release/test/recover_mac_release_test.rb b/scripts/release/test/recover_mac_release_test.rb index 8aca113b4..2d89eb00e 100644 --- a/scripts/release/test/recover_mac_release_test.rb +++ b/scripts/release/test/recover_mac_release_test.rb @@ -11,16 +11,34 @@ class RecoverMacReleaseTest < Minitest::Test VALID_SHA = "94c796fc34fd699776764d67d2f26013909a308e" VALID_DRAFT_ID = "442117184" EXPECTED_SUMS_HASH = "279e22555e9a84a4fc59bd7d529f4efca6ccf2a8c986ac9fc939d627368bc1b7" + DMG1_HASH = "d02273969b8a00fc7269f69ab6440bae92028bcb55b9e5f4765cb5d925e8c316" + DMG2_HASH = "4981863429e85dacf916287e466867ff1c38c838bc364c0ae483739b8d17eaf7" + FEED1_HASH = "b97d8bdddec17083407dbc08bc23052b84d6d3f7440ddfd6e4443d78f50548d5" + FEED2_HASH = "306cd1cbc1f415bf7668892be261907ea66f0a381a56b7f6921c3e6758f36609" + + def with_scratch_dir + base = File.directory?("/Volumes/T7Tmp/ai-dev/tmp") ? "/Volumes/T7Tmp/ai-dev/tmp" : Dir.tmpdir + Dir.mktmpdir("recover-test-", base) do |dir| + yield dir + end + end def run_recovery(args = [], env = {}) default_env = { - "RECOVERY_TEST_MOCK_ENV" => "true", - "RECOVERY_TEST_TMPDIR" => Dir.tmpdir + "GH_TOKEN" => "mock-token", + "FEED_POLL_SLEEP" => "0" } Open3.capture3(default_env.merge(env), "bash", SCRIPT, *args) end - def test_input_pinning_rejects_differing_version_or_sha_or_draft + def run_reconcile(args = [], env = {}) + default_env = { + "GH_TOKEN" => "mock-token" + } + Open3.capture3(default_env.merge(env), "bash", RECONCILE_SCRIPT, *args) + end + + def test_input_pinning_rejects_differing_version_or_sha_or_draft_or_repo out, err, status = Open3.capture3({ "VERSION" => "2.2.0" }, "bash", SCRIPT) refute status.success? assert_includes err + out, "VERSION must be '2.1.0'" @@ -45,8 +63,7 @@ def test_input_pinning_rejects_differing_version_or_sha_or_draft def test_local_execution_refuses_publish_mode env = { "GITHUB_ACTIONS" => "false", - "RECOVERY_TEST_MOCK_ENV" => "false", - "RECOVERY_TEST_TMPDIR" => Dir.tmpdir + "GITHUB_RUN_ID" => "" } out, err, status = Open3.capture3(env, "bash", SCRIPT, "--publish") refute status.success? @@ -54,8 +71,47 @@ def test_local_execution_refuses_publish_mode assert_includes err + out, "release-publish" end + def test_unapproved_github_actions_run_refuses_publication + with_scratch_dir do |bin_dir| + mock_gh = File.join(bin_dir, "gh") + File.write(mock_gh, <<~'SH') + #!/usr/bin/env bash + case "$*" in + *"check-environment"*) + exit 0 + ;; + *"deployment-branch-policies"*) + if [[ "$*" == *"--jq"* ]]; then + echo "branch:release/*" + else + echo '{"branch_policies":[{"name":"release/*","type":"branch"}]}' + fi + ;; + *"environments/release-publish"*) + echo '{"deployment_branch_policy":{"custom_branch_policies":true},"protection_rules":[{"type":"required_reviewers"}]}' + ;; + *"actions/runs/12345/approvals"*) + echo '[]' + ;; + *) exit 0 ;; + esac + SH + FileUtils.chmod(0o755, mock_gh) + + env = { + "PATH" => "#{bin_dir}:#{ENV['PATH']}", + "GITHUB_ACTIONS" => "true", + "GITHUB_RUN_ID" => "12345", + "RUNNER_TEMP" => bin_dir + } + out, err, status = Open3.capture3(env, "bash", SCRIPT, "--publish") + refute status.success? + assert_includes err + out, "Current GitHub Actions run 12345 has not received human approval for 'release-publish' from required reviewers" + end + end + def test_stale_release_state_fetch_failure_halts_immediately - Dir.mktmpdir do |bin_dir| + with_scratch_dir do |bin_dir| mock_git = File.join(bin_dir, "git") File.write(mock_git, <<~'SH') #!/usr/bin/env bash @@ -68,18 +124,18 @@ def test_stale_release_state_fetch_failure_halts_immediately FileUtils.chmod(0o755, mock_git) mock_gh = File.join(bin_dir, "gh") - File.write(mock_gh, <<~'SH') + File.write(mock_gh, <<~SH) #!/usr/bin/env bash case "$*" in *"releases/442117184") - echo '{"id":442117184,"tag_name":"v2.1.0","draft":true,"target_commitish":"94c796fc34fd699776764d67d2f26013909a308e"}' + echo '{"id":442117184,"tag_name":"v2.1.0","draft":true,"target_commitish":"#{VALID_SHA}"}' ;; *) exit 0 ;; esac SH FileUtils.chmod(0o755, mock_gh) - out, err, status = run_recovery([], "PATH" => "#{bin_dir}:#{ENV['PATH']}") + out, err, status = run_recovery([], "PATH" => "#{bin_dir}:#{ENV['PATH']}", "RUNNER_TEMP" => bin_dir) refute status.success? assert_includes err + out, "Failed to fetch origin/release-state from remote" assert_includes err + out, "Relying on stale local state is prohibited" @@ -87,27 +143,21 @@ def test_stale_release_state_fetch_failure_halts_immediately end def test_scratch_volume_enforcement_locally - Dir.mktmpdir do |bin_dir| - mock_gh = File.join(bin_dir, "gh") - File.write(mock_gh, "#!/usr/bin/env bash\nexit 0\n") - FileUtils.chmod(0o755, mock_gh) - - out, err, status = Open3.capture3({}, "bash", "-c", <<~SH) - export RUNNER_TEMP="" T7_RUN_DIR="" RECOVERY_TEST_TMPDIR="" TMPDIR="/private/tmp" - if [ ! -d "/Volumes/T7Tmp/ai-dev" ]; then - bash "#{SCRIPT}" - else - echo "::error::No approved scratch location found. T7 scratch storage (/Volumes/T7Tmp/ai-dev) is required for local execution. Never fall back to internal /tmp." - exit 1 - fi - SH - refute status.success? - assert_includes err + out, "No approved scratch location found" - end + out, err, status = Open3.capture3({}, "bash", "-c", <<~SH) + export RUNNER_TEMP="" T7_RUN_DIR="" TMPDIR="/private/tmp" + if [ ! -d "/Volumes/T7Tmp/ai-dev" ]; then + bash "#{SCRIPT}" + else + echo "::error::No approved scratch location found. T7 scratch storage (/Volumes/T7Tmp/ai-dev) is required for local execution. Never fall back to internal /tmp." + exit 1 + fi + SH + refute status.success? + assert_includes err + out, "No approved scratch location found" end def test_failure_before_publication_when_target_commit_mismatches - Dir.mktmpdir do |bin_dir| + with_scratch_dir do |bin_dir| mock_gh = File.join(bin_dir, "gh") File.write(mock_gh, <<~'SH') #!/usr/bin/env bash @@ -120,59 +170,54 @@ def test_failure_before_publication_when_target_commit_mismatches SH FileUtils.chmod(0o755, mock_gh) - out, err, status = run_recovery([], "PATH" => "#{bin_dir}:#{ENV['PATH']}") + out, err, status = run_recovery([], "PATH" => "#{bin_dir}:#{ENV['PATH']}", "RUNNER_TEMP" => bin_dir) refute status.success? assert_includes err + out, "target_commitish is '1111111111111111111111111111111111111111', expected '94c796fc34fd699776764d67d2f26013909a308e'" end end def test_failure_before_publication_when_release_is_not_draft - Dir.mktmpdir do |bin_dir| + with_scratch_dir do |bin_dir| mock_gh = File.join(bin_dir, "gh") - File.write(mock_gh, <<~'SH') + File.write(mock_gh, <<~SH) #!/usr/bin/env bash case "$*" in *"releases/442117184") - echo '{"id":442117184,"tag_name":"v2.1.0","draft":false,"target_commitish":"94c796fc34fd699776764d67d2f26013909a308e"}' + echo '{"id":442117184,"tag_name":"v2.1.0","draft":false,"target_commitish":"#{VALID_SHA}"}' ;; *) exit 1 ;; esac SH FileUtils.chmod(0o755, mock_gh) - out, err, status = run_recovery([], "PATH" => "#{bin_dir}:#{ENV['PATH']}") + out, err, status = run_recovery([], "PATH" => "#{bin_dir}:#{ENV['PATH']}", "RUNNER_TEMP" => bin_dir) refute status.success? assert_includes err + out, "release 442117184 is not a draft" end end def test_failure_before_publication_when_asset_count_or_names_differ - Dir.mktmpdir do |bin_dir| + with_scratch_dir do |bin_dir| mock_gh = File.join(bin_dir, "gh") - File.write(mock_gh, <<~'SH') + File.write(mock_gh, <<~SH) #!/usr/bin/env bash case "$*" in *"releases/442117184") - echo '{"id":442117184,"tag_name":"v2.1.0","draft":true,"target_commitish":"94c796fc34fd699776764d67d2f26013909a308e","assets":[{"name":"MeowDisplay.dmg"}]}' + echo '{"id":442117184,"tag_name":"v2.1.0","draft":true,"target_commitish":"#{VALID_SHA}","assets":[{"name":"MeowDisplay.dmg"}]}' ;; *) exit 1 ;; esac SH FileUtils.chmod(0o755, mock_gh) - out, err, status = run_recovery([], "PATH" => "#{bin_dir}:#{ENV['PATH']}") + out, err, status = run_recovery([], "PATH" => "#{bin_dir}:#{ENV['PATH']}", "RUNNER_TEMP" => bin_dir) refute status.success? - assert_includes err + out, "draft assets are" + assert_includes err + out, "draft assets are 'MeowDisplay.dmg', expected 'MeowDisplay.dmg MeowDisplayReceiver.dmg SHA256SUMS.txt appcast-receiver.xml appcast.xml'" end end def test_failure_before_publication_when_sha256sums_digest_is_tampered - Dir.mktmpdir do |bin_dir| - dmg1_hash = "d02273969b8a00fc7269f69ab6440bae92028bcb55b9e5f4765cb5d925e8c316" - dmg2_hash = "4981863429e85dacf916287e466867ff1c38c838bc364c0ae483739b8d17eaf7" - feed1_hash = "b97d8bdddec17083407dbc08bc23052b84d6d3f7440ddfd6e4443d78f50548d5" - feed2_hash = "306cd1cbc1f415bf7668892be261907ea66f0a381a56b7f6921c3e6758f36609" - + with_scratch_dir do |bin_dir| mock_gh = File.join(bin_dir, "gh") File.write(mock_gh, <<~SH) #!/usr/bin/env bash @@ -199,39 +244,47 @@ def test_failure_before_publication_when_sha256sums_digest_is_tampered #!/usr/bin/env bash target="${@: -1}" case "$target" in - *"MeowDisplay.dmg") echo "#{dmg1_hash} $target" ;; - *"MeowDisplayReceiver.dmg") echo "#{dmg2_hash} $target" ;; - *"appcast.xml") echo "#{feed1_hash} $target" ;; - *"appcast-receiver.xml") echo "#{feed2_hash} $target" ;; + *"MeowDisplay.dmg") echo "#{DMG1_HASH} $target" ;; + *"MeowDisplayReceiver.dmg") echo "#{DMG2_HASH} $target" ;; + *"appcast.xml") echo "#{FEED1_HASH} $target" ;; + *"appcast-receiver.xml") echo "#{FEED2_HASH} $target" ;; *"SHA256SUMS.txt") echo "tampered_digest_000000000000000000000000000000000000000000000000 $target" ;; *) echo "0000000000000000000000000000000000000000000000000000000000000000 $target" ;; esac SH FileUtils.chmod(0o755, mock_shasum) - out, err, status = run_recovery([], "PATH" => "#{bin_dir}:#{ENV['PATH']}") + out, err, status = run_recovery([], "PATH" => "#{bin_dir}:#{ENV['PATH']}", "RUNNER_TEMP" => bin_dir) refute status.success? assert_includes err + out, "SHA256SUMS.txt hash is 'tampered_digest" assert_includes err + out, "expected pinned '#{EXPECTED_SUMS_HASH}'" end end - def test_feed_verification_failure_executes_production_script_and_halts_before_publication - Dir.mktmpdir do |bin_dir| - state_file = File.join(bin_dir, "gh_state.txt") - File.write(state_file, "published=false\n") + def test_feed_verification_failure_halts_before_publication + with_scratch_dir do |bin_dir| + state_file = File.join(bin_dir, "published.txt") + File.write(state_file, "false") - dmg1_hash = "d02273969b8a00fc7269f69ab6440bae92028bcb55b9e5f4765cb5d925e8c316" - dmg2_hash = "4981863429e85dacf916287e466867ff1c38c838bc364c0ae483739b8d17eaf7" - feed1_hash = "b97d8bdddec17083407dbc08bc23052b84d6d3f7440ddfd6e4443d78f50548d5" - feed2_hash = "306cd1cbc1f415bf7668892be261907ea66f0a381a56b7f6921c3e6758f36609" - - sums_content = "#{dmg1_hash} MeowDisplay.dmg\n#{dmg2_hash} MeowDisplayReceiver.dmg\n#{feed1_hash} appcast.xml\n#{feed2_hash} appcast-receiver.xml\n" + sums_content = "#{DMG1_HASH} MeowDisplay.dmg\n#{DMG2_HASH} MeowDisplayReceiver.dmg\n#{FEED1_HASH} appcast.xml\n#{FEED2_HASH} appcast-receiver.xml\n" mock_gh = File.join(bin_dir, "gh") File.write(mock_gh, <<~SH) #!/usr/bin/env bash case "$*" in + *"deployment-branch-policies"*) + if [[ "$*" == *"--jq"* ]]; then + echo "branch:release/*" + else + echo '{"branch_policies":[{"name":"release/*","type":"branch"}]}' + fi + ;; + *"environments/release-publish"*) + echo '{"deployment_branch_policy":{"custom_branch_policies":true},"protection_rules":[{"type":"required_reviewers"}]}' + ;; + *"actions/runs/12345/approvals"*) + echo '[{"state":"approved","environments":[{"name":"release-publish"}]}]' + ;; *"releases/442117184") echo '{"id":442117184,"tag_name":"v2.1.0","draft":true,"target_commitish":"#{VALID_SHA}","assets":[{"name":"MeowDisplay.dmg"},{"name":"MeowDisplayReceiver.dmg"},{"name":"SHA256SUMS.txt"},{"name":"appcast-receiver.xml"},{"name":"appcast.xml"}]}' ;; @@ -245,7 +298,7 @@ def test_feed_verification_failure_executes_production_script_and_halts_before_p echo "mock-feed2" > "$dest/appcast-receiver.xml" ;; *"release edit"*"--draft=false --latest"*) - echo "published=true" > "#{state_file}" + echo "true" > "#{state_file}" ;; *) exit 0 ;; esac @@ -255,17 +308,22 @@ def test_feed_verification_failure_executes_production_script_and_halts_before_p mock_shasum = File.join(bin_dir, "shasum") File.write(mock_shasum, <<~SH) #!/usr/bin/env bash - if [ "$1" = "-a" ] && [ "$2" = "256" ] && [ "$3" = "-c" ]; then - exit 0 - fi + if [ "$1" = "-a" ] && [ "$2" = "256" ] && [ "$3" = "-c" ]; then exit 0; fi target="${@: -1}" case "$target" in - *"MeowDisplay.dmg") echo "#{dmg1_hash} $target" ;; - *"MeowDisplayReceiver.dmg") echo "#{dmg2_hash} $target" ;; - *"appcast.xml") echo "#{feed1_hash} $target" ;; - *"appcast-receiver.xml") echo "#{feed2_hash} $target" ;; + *"MeowDisplay.dmg") echo "#{DMG1_HASH} $target" ;; + *"MeowDisplayReceiver.dmg") echo "#{DMG2_HASH} $target" ;; + *"appcast.xml") echo "#{FEED1_HASH} $target" ;; + *"appcast-receiver.xml") echo "#{FEED2_HASH} $target" ;; *"SHA256SUMS.txt") echo "#{EXPECTED_SUMS_HASH} $target" ;; - *) echo "0000000000000000000000000000000000000000000000000000000000000000 $target" ;; + *) + content=$(cat) + case "$content" in + *"mock-feed1"*) echo "#{FEED1_HASH} -" ;; + *"mock-feed2"*) echo "#{FEED2_HASH} -" ;; + *) echo "0000000000000000000000000000000000000000000000000000000000000000 -" ;; + esac + ;; esac SH FileUtils.chmod(0o755, mock_shasum) @@ -278,45 +336,176 @@ def test_feed_verification_failure_executes_production_script_and_halts_before_p SH FileUtils.chmod(0o755, mock_feed) - mock_mac = File.join(bin_dir, "verify-mac-artifact.sh") - File.write(mock_mac, "#!/usr/bin/env bash\nexit 0\n") - FileUtils.chmod(0o755, mock_mac) - - mock_base = File.join(bin_dir, "published-baseline.sh") - File.write(mock_base, "#!/usr/bin/env bash\nmkdir -p \"$1\"\necho k > \"$1/sender.key\"\necho k > \"$1/receiver.key\"\n") - FileUtils.chmod(0o755, mock_base) - - mock_build = File.join(bin_dir, "check-build-number.sh") - File.write(mock_build, "#!/usr/bin/env bash\nexit 0\n") - FileUtils.chmod(0o755, mock_build) + File.write(File.join(bin_dir, "verify-mac-artifact.sh"), "#!/usr/bin/env bash\nexit 0\n") + FileUtils.chmod(0o755, File.join(bin_dir, "verify-mac-artifact.sh")) + File.write(File.join(bin_dir, "check-build-number.sh"), "#!/usr/bin/env bash\nexit 0\n") + FileUtils.chmod(0o755, File.join(bin_dir, "check-build-number.sh")) + File.write(File.join(bin_dir, "published-baseline.sh"), "#!/usr/bin/env bash\nmkdir -p \"$1\"\necho k > \"$1/sender.key\"\necho k > \"$1/receiver.key\"\n") + FileUtils.chmod(0o755, File.join(bin_dir, "published-baseline.sh")) env = { "PATH" => "#{bin_dir}:#{ENV['PATH']}", - "RECOVERY_TEST_MOCK_SCRIPTS" => "true" + "GITHUB_ACTIONS" => "true", + "GITHUB_RUN_ID" => "12345", + "RUNNER_TEMP" => bin_dir } out, err, status = run_recovery(["--publish"], env) refute status.success? assert_includes err + out, "mock feed verification failed" - assert_equal "published=false\n", File.read(state_file), "Publication was attempted despite feed failure!" + assert_equal "false", File.read(state_file), "Publication was attempted despite feed verification failure!" end end - def test_rollback_behavior_when_live_feeds_fail_after_publish - Dir.mktmpdir do |bin_dir| + def test_successful_publication_end_to_end + with_scratch_dir do |bin_dir| + invocations_file = File.join(bin_dir, "gh_calls.txt") + File.write(invocations_file, "") state_file = File.join(bin_dir, "gh_state.txt") File.write(state_file, "latest=v2.0.0\ndraft=true\n") - dmg1_hash = "d02273969b8a00fc7269f69ab6440bae92028bcb55b9e5f4765cb5d925e8c316" - dmg2_hash = "4981863429e85dacf916287e466867ff1c38c838bc364c0ae483739b8d17eaf7" - feed1_hash = "b97d8bdddec17083407dbc08bc23052b84d6d3f7440ddfd6e4443d78f50548d5" - feed2_hash = "306cd1cbc1f415bf7668892be261907ea66f0a381a56b7f6921c3e6758f36609" + sums_content = "#{DMG1_HASH} MeowDisplay.dmg\n#{DMG2_HASH} MeowDisplayReceiver.dmg\n#{FEED1_HASH} appcast.xml\n#{FEED2_HASH} appcast-receiver.xml\n" + + mock_gh = File.join(bin_dir, "gh") + File.write(mock_gh, <<~SH) + #!/usr/bin/env bash + echo "gh $*" >> "#{invocations_file}" + case "$*" in + *"deployment-branch-policies"*) + if [[ "$*" == *"--jq"* ]]; then + echo "branch:release/*" + else + echo '{"branch_policies":[{"name":"release/*","type":"branch"}]}' + fi + ;; + *"environments/release-publish"*) + echo '{"deployment_branch_policy":{"custom_branch_policies":true},"protection_rules":[{"type":"required_reviewers"}]}' + ;; + *"actions/runs/12345/approvals"*) + echo '[{"state":"approved","environments":[{"name":"release-publish"}]}]' + ;; + *"releases/442117184"*"--jq .draft"*) + grep "^draft=" "#{state_file}" | cut -d= -f2 + ;; + *"releases/442117184") + draft_val=$(grep "^draft=" "#{state_file}" | cut -d= -f2) + echo '{"id":442117184,"tag_name":"v2.1.0","draft":'"$draft_val"',"target_commitish":"#{VALID_SHA}","assets":[{"name":"MeowDisplay.dmg"},{"name":"MeowDisplayReceiver.dmg"},{"name":"SHA256SUMS.txt"},{"name":"appcast-receiver.xml"},{"name":"appcast.xml"}]}' + ;; + *"releases/latest"*) + grep "^latest=" "#{state_file}" | cut -d= -f2 + ;; + *"release download v2.1.0"*) + dest="${@: -1}" + mkdir -p "$dest" + printf "%s" "#{sums_content}" > "$dest/SHA256SUMS.txt" + echo "mock-dmg1" > "$dest/MeowDisplay.dmg" + echo "mock-dmg2" > "$dest/MeowDisplayReceiver.dmg" + echo "mock-feed1" > "$dest/appcast.xml" + echo "mock-feed2" > "$dest/appcast-receiver.xml" + ;; + *"release edit v2.1.0"*"--draft=false --latest"*) + sed -i '' 's/^latest=.*/latest=v2.1.0/' "#{state_file}" 2>/dev/null || sed -i 's/^latest=.*/latest=v2.1.0/' "#{state_file}" + sed -i '' 's/^draft=.*/draft=false/' "#{state_file}" 2>/dev/null || sed -i 's/^draft=.*/draft=false/' "#{state_file}" + ;; + *"commits/v2.1.0"*) + echo "#{VALID_SHA}" + ;; + *) exit 0 ;; + esac + SH + FileUtils.chmod(0o755, mock_gh) - sums_content = "#{dmg1_hash} MeowDisplay.dmg\n#{dmg2_hash} MeowDisplayReceiver.dmg\n#{feed1_hash} appcast.xml\n#{feed2_hash} appcast-receiver.xml\n" + mock_shasum = File.join(bin_dir, "shasum") + File.write(mock_shasum, <<~SH) + #!/usr/bin/env bash + if [ "$1" = "-a" ] && [ "$2" = "256" ] && [ "$3" = "-c" ]; then exit 0; fi + target="${@: -1}" + case "$target" in + *"MeowDisplay.dmg") echo "#{DMG1_HASH} $target" ;; + *"MeowDisplayReceiver.dmg") echo "#{DMG2_HASH} $target" ;; + *"appcast.xml") echo "#{FEED1_HASH} $target" ;; + *"appcast-receiver.xml") echo "#{FEED2_HASH} $target" ;; + *"SHA256SUMS.txt") echo "#{EXPECTED_SUMS_HASH} $target" ;; + *) + content=$(cat) + case "$content" in + *"mock-feed1"*) echo "#{FEED1_HASH} -" ;; + *"mock-feed2"*) echo "#{FEED2_HASH} -" ;; + *) echo "0000000000000000000000000000000000000000000000000000000000000000 -" ;; + esac + ;; + esac + SH + FileUtils.chmod(0o755, mock_shasum) + + File.write(File.join(bin_dir, "verify-feed.sh"), "#!/usr/bin/env bash\nexit 0\n") + FileUtils.chmod(0o755, File.join(bin_dir, "verify-feed.sh")) + File.write(File.join(bin_dir, "verify-mac-artifact.sh"), "#!/usr/bin/env bash\nexit 0\n") + FileUtils.chmod(0o755, File.join(bin_dir, "verify-mac-artifact.sh")) + File.write(File.join(bin_dir, "check-build-number.sh"), "#!/usr/bin/env bash\nexit 0\n") + FileUtils.chmod(0o755, File.join(bin_dir, "check-build-number.sh")) + File.write(File.join(bin_dir, "published-baseline.sh"), "#!/usr/bin/env bash\nmkdir -p \"$1\"\necho k > \"$1/sender.key\"\necho k > \"$1/receiver.key\"\n") + FileUtils.chmod(0o755, File.join(bin_dir, "published-baseline.sh")) + + mock_curl = File.join(bin_dir, "curl") + File.write(mock_curl, <<~SH) + #!/usr/bin/env bash + url="${@: -1}" + case "$url" in + *"appcast.xml"*) echo "mock-feed1" ;; + *"appcast-receiver.xml"*) echo "mock-feed2" ;; + *) exit 1 ;; + esac + SH + FileUtils.chmod(0o755, mock_curl) + + env = { + "PATH" => "#{bin_dir}:#{ENV['PATH']}", + "GITHUB_ACTIONS" => "true", + "GITHUB_RUN_ID" => "12345", + "RUNNER_TEMP" => bin_dir + } + out, err, status = run_recovery(["--publish"], env) + assert status.success?, "Publication failed: #{err}\n#{out}" + assert_includes out, "SUCCESS: MeowDisplay Mac 2.1.0 is published and live!" + assert_includes out, "Tag v2.1.0 is Latest pointing to #{VALID_SHA}" + + calls = File.read(invocations_file) + # Assert publication used the exact tag name v2.1.0 + assert_includes calls, "gh release edit v2.1.0 --draft=false --latest -R raiseCatError/meowdisplay" + # Assert rollback was never triggered + refute_includes calls, "--draft=true" + # Assert numeric draft ID was verified through API + assert_includes calls, "gh api repos/raiseCatError/meowdisplay/releases/442117184" + end + end + + def test_rollback_behavior_when_live_feeds_fail_after_publish + with_scratch_dir do |bin_dir| + invocations_file = File.join(bin_dir, "gh_calls.txt") + File.write(invocations_file, "") + state_file = File.join(bin_dir, "gh_state.txt") + File.write(state_file, "latest=v2.0.0\ndraft=true\n") + + sums_content = "#{DMG1_HASH} MeowDisplay.dmg\n#{DMG2_HASH} MeowDisplayReceiver.dmg\n#{FEED1_HASH} appcast.xml\n#{FEED2_HASH} appcast-receiver.xml\n" mock_gh = File.join(bin_dir, "gh") File.write(mock_gh, <<~SH) #!/usr/bin/env bash + echo "gh $*" >> "#{invocations_file}" case "$*" in + *"deployment-branch-policies"*) + if [[ "$*" == *"--jq"* ]]; then + echo "branch:release/*" + else + echo '{"branch_policies":[{"name":"release/*","type":"branch"}]}' + fi + ;; + *"environments/release-publish"*) + echo '{"deployment_branch_policy":{"custom_branch_policies":true},"protection_rules":[{"type":"required_reviewers"}]}' + ;; + *"actions/runs/12345/approvals"*) + echo '[{"state":"approved","environments":[{"name":"release-publish"}]}]' + ;; *"releases/442117184"*"--jq .draft"*) grep "^draft=" "#{state_file}" | cut -d= -f2 ;; @@ -327,7 +516,7 @@ def test_rollback_behavior_when_live_feeds_fail_after_publish *"releases/latest"*) grep "^latest=" "#{state_file}" | cut -d= -f2 ;; - *"release download"*) + *"release download v2.1.0"*) dest="${@: -1}" mkdir -p "$dest" printf "%s" "#{sums_content}" > "$dest/SHA256SUMS.txt" @@ -336,14 +525,14 @@ def test_rollback_behavior_when_live_feeds_fail_after_publish echo "mock-feed1" > "$dest/appcast.xml" echo "mock-feed2" > "$dest/appcast-receiver.xml" ;; - *"release edit 442117184"*"--draft=false --latest"*) + *"release edit v2.1.0"*"--draft=false --latest"*) sed -i '' 's/^latest=.*/latest=v2.1.0/' "#{state_file}" 2>/dev/null || sed -i 's/^latest=.*/latest=v2.1.0/' "#{state_file}" sed -i '' 's/^draft=.*/draft=false/' "#{state_file}" 2>/dev/null || sed -i 's/^draft=.*/draft=false/' "#{state_file}" ;; *"release edit v2.0.0"*"--latest"*) sed -i '' 's/^latest=.*/latest=v2.0.0/' "#{state_file}" 2>/dev/null || sed -i 's/^latest=.*/latest=v2.0.0/' "#{state_file}" ;; - *"release edit 442117184"*"--draft=true"*) + *"release edit v2.1.0"*"--draft=true"*) sed -i '' 's/^draft=.*/draft=true/' "#{state_file}" 2>/dev/null || sed -i 's/^draft=.*/draft=true/' "#{state_file}" ;; *"commits/v2.1.0"*) @@ -357,36 +546,34 @@ def test_rollback_behavior_when_live_feeds_fail_after_publish mock_shasum = File.join(bin_dir, "shasum") File.write(mock_shasum, <<~SH) #!/usr/bin/env bash - if [ "$1" = "-a" ] && [ "$2" = "256" ] && [ "$3" = "-c" ]; then - exit 0 - fi + if [ "$1" = "-a" ] && [ "$2" = "256" ] && [ "$3" = "-c" ]; then exit 0; fi target="${@: -1}" case "$target" in - *"MeowDisplay.dmg") echo "#{dmg1_hash} $target" ;; - *"MeowDisplayReceiver.dmg") echo "#{dmg2_hash} $target" ;; - *"appcast.xml") echo "#{feed1_hash} $target" ;; - *"appcast-receiver.xml") echo "#{feed2_hash} $target" ;; + *"MeowDisplay.dmg") echo "#{DMG1_HASH} $target" ;; + *"MeowDisplayReceiver.dmg") echo "#{DMG2_HASH} $target" ;; + *"appcast.xml") echo "#{FEED1_HASH} $target" ;; + *"appcast-receiver.xml") echo "#{FEED2_HASH} $target" ;; *"SHA256SUMS.txt") echo "#{EXPECTED_SUMS_HASH} $target" ;; - *) echo "0000000000000000000000000000000000000000000000000000000000000000 $target" ;; + *) + content=$(cat) + case "$content" in + *"mock-feed1"*) echo "#{FEED1_HASH} -" ;; + *"mock-feed2"*) echo "#{FEED2_HASH} -" ;; + *) echo "0000000000000000000000000000000000000000000000000000000000000000 -" ;; + esac + ;; esac SH FileUtils.chmod(0o755, mock_shasum) - mock_feed = File.join(bin_dir, "verify-feed.sh") - File.write(mock_feed, "#!/usr/bin/env bash\nexit 0\n") - FileUtils.chmod(0o755, mock_feed) - - mock_mac = File.join(bin_dir, "verify-mac-artifact.sh") - File.write(mock_mac, "#!/usr/bin/env bash\nexit 0\n") - FileUtils.chmod(0o755, mock_mac) - - mock_base = File.join(bin_dir, "published-baseline.sh") - File.write(mock_base, "#!/usr/bin/env bash\nmkdir -p \"$1\"\necho k > \"$1/sender.key\"\necho k > \"$1/receiver.key\"\n") - FileUtils.chmod(0o755, mock_base) - - mock_build = File.join(bin_dir, "check-build-number.sh") - File.write(mock_build, "#!/usr/bin/env bash\nexit 0\n") - FileUtils.chmod(0o755, mock_build) + File.write(File.join(bin_dir, "verify-feed.sh"), "#!/usr/bin/env bash\nexit 0\n") + FileUtils.chmod(0o755, File.join(bin_dir, "verify-feed.sh")) + File.write(File.join(bin_dir, "verify-mac-artifact.sh"), "#!/usr/bin/env bash\nexit 0\n") + FileUtils.chmod(0o755, File.join(bin_dir, "verify-mac-artifact.sh")) + File.write(File.join(bin_dir, "check-build-number.sh"), "#!/usr/bin/env bash\nexit 0\n") + FileUtils.chmod(0o755, File.join(bin_dir, "check-build-number.sh")) + File.write(File.join(bin_dir, "published-baseline.sh"), "#!/usr/bin/env bash\nmkdir -p \"$1\"\necho k > \"$1/sender.key\"\necho k > \"$1/receiver.key\"\n") + FileUtils.chmod(0o755, File.join(bin_dir, "published-baseline.sh")) mock_curl = File.join(bin_dir, "curl") File.write(mock_curl, "#!/usr/bin/env bash\nexit 1\n") @@ -398,35 +585,50 @@ def test_rollback_behavior_when_live_feeds_fail_after_publish env = { "PATH" => "#{bin_dir}:#{ENV['PATH']}", - "RECOVERY_TEST_MOCK_SCRIPTS" => "true" + "GITHUB_ACTIONS" => "true", + "GITHUB_RUN_ID" => "12345", + "RUNNER_TEMP" => bin_dir } out, err, status = run_recovery(["--publish"], env) refute status.success? assert_includes err + out, "Live feeds did not serve v2.1.0 after 5 minutes" - assert_includes err + out, "Rollback succeeded: 442117184 returned to draft; v2.0.0 restored as Latest" + assert_includes err + out, "Rollback succeeded: v2.1.0 (ID 442117184) returned to draft; v2.0.0 restored as Latest" state = File.read(state_file) assert_includes state, "latest=v2.0.0" assert_includes state, "draft=true" + + calls = File.read(invocations_file) + # Assert both rollback commands used the correct tag names + assert_includes calls, "gh release edit v2.0.0 --latest -R raiseCatError/meowdisplay" + assert_includes calls, "gh release edit v2.1.0 --draft=true -R raiseCatError/meowdisplay" end end def test_partial_rollback_failure_when_reverting_latest_fails - Dir.mktmpdir do |bin_dir| + with_scratch_dir do |bin_dir| state_file = File.join(bin_dir, "gh_state.txt") File.write(state_file, "latest=v2.0.0\ndraft=true\n") - dmg1_hash = "d02273969b8a00fc7269f69ab6440bae92028bcb55b9e5f4765cb5d925e8c316" - dmg2_hash = "4981863429e85dacf916287e466867ff1c38c838bc364c0ae483739b8d17eaf7" - feed1_hash = "b97d8bdddec17083407dbc08bc23052b84d6d3f7440ddfd6e4443d78f50548d5" - feed2_hash = "306cd1cbc1f415bf7668892be261907ea66f0a381a56b7f6921c3e6758f36609" - - sums_content = "#{dmg1_hash} MeowDisplay.dmg\n#{dmg2_hash} MeowDisplayReceiver.dmg\n#{feed1_hash} appcast.xml\n#{feed2_hash} appcast-receiver.xml\n" + sums_content = "#{DMG1_HASH} MeowDisplay.dmg\n#{DMG2_HASH} MeowDisplayReceiver.dmg\n#{FEED1_HASH} appcast.xml\n#{FEED2_HASH} appcast-receiver.xml\n" mock_gh = File.join(bin_dir, "gh") File.write(mock_gh, <<~SH) #!/usr/bin/env bash case "$*" in + *"deployment-branch-policies"*) + if [[ "$*" == *"--jq"* ]]; then + echo "branch:release/*" + else + echo '{"branch_policies":[{"name":"release/*","type":"branch"}]}' + fi + ;; + *"environments/release-publish"*) + echo '{"deployment_branch_policy":{"custom_branch_policies":true},"protection_rules":[{"type":"required_reviewers"}]}' + ;; + *"actions/runs/12345/approvals"*) + echo '[{"state":"approved","environments":[{"name":"release-publish"}]}]' + ;; *"releases/442117184"*"--jq .draft"*) grep "^draft=" "#{state_file}" | cut -d= -f2 ;; @@ -437,7 +639,7 @@ def test_partial_rollback_failure_when_reverting_latest_fails *"releases/latest"*) grep "^latest=" "#{state_file}" | cut -d= -f2 ;; - *"release download"*) + *"release download v2.1.0"*) dest="${@: -1}" mkdir -p "$dest" printf "%s" "#{sums_content}" > "$dest/SHA256SUMS.txt" @@ -446,14 +648,14 @@ def test_partial_rollback_failure_when_reverting_latest_fails echo "f1" > "$dest/appcast.xml" echo "f2" > "$dest/appcast-receiver.xml" ;; - *"release edit 442117184"*"--draft=false --latest"*) + *"release edit v2.1.0"*"--draft=false --latest"*) sed -i '' 's/^latest=.*/latest=v2.1.0/' "#{state_file}" 2>/dev/null || sed -i 's/^latest=.*/latest=v2.1.0/' "#{state_file}" sed -i '' 's/^draft=.*/draft=false/' "#{state_file}" 2>/dev/null || sed -i 's/^draft=.*/draft=false/' "#{state_file}" ;; *"release edit v2.0.0"*"--latest"*) exit 1 ;; - *"release edit 442117184"*"--draft=true"*) + *"release edit v2.1.0"*"--draft=true"*) sed -i '' 's/^draft=.*/draft=true/' "#{state_file}" 2>/dev/null || sed -i 's/^draft=.*/draft=true/' "#{state_file}" ;; *"commits/v2.1.0"*) @@ -470,12 +672,19 @@ def test_partial_rollback_failure_when_reverting_latest_fails if [ "$1" = "-a" ] && [ "$2" = "256" ] && [ "$3" = "-c" ]; then exit 0; fi target="${@: -1}" case "$target" in - *"MeowDisplay.dmg") echo "#{dmg1_hash} $target" ;; - *"MeowDisplayReceiver.dmg") echo "#{dmg2_hash} $target" ;; - *"appcast.xml") echo "#{feed1_hash} $target" ;; - *"appcast-receiver.xml") echo "#{feed2_hash} $target" ;; + *"MeowDisplay.dmg") echo "#{DMG1_HASH} $target" ;; + *"MeowDisplayReceiver.dmg") echo "#{DMG2_HASH} $target" ;; + *"appcast.xml") echo "#{FEED1_HASH} $target" ;; + *"appcast-receiver.xml") echo "#{FEED2_HASH} $target" ;; *"SHA256SUMS.txt") echo "#{EXPECTED_SUMS_HASH} $target" ;; - *) echo "0000000000000000000000000000000000000000000000000000000000000000 $target" ;; + *) + content=$(cat) + case "$content" in + *"mock-feed1"*) echo "#{FEED1_HASH} -" ;; + *"mock-feed2"*) echo "#{FEED2_HASH} -" ;; + *) echo "0000000000000000000000000000000000000000000000000000000000000000 -" ;; + esac + ;; esac SH FileUtils.chmod(0o755, mock_shasum) @@ -495,7 +704,9 @@ def test_partial_rollback_failure_when_reverting_latest_fails env = { "PATH" => "#{bin_dir}:#{ENV['PATH']}", - "RECOVERY_TEST_MOCK_SCRIPTS" => "true" + "GITHUB_ACTIONS" => "true", + "GITHUB_RUN_ID" => "12345", + "RUNNER_TEMP" => bin_dir } out, err, status = run_recovery(["--publish"], env) refute status.success? @@ -504,21 +715,29 @@ def test_partial_rollback_failure_when_reverting_latest_fails end def test_partial_rollback_failure_when_reverting_draft_fails - Dir.mktmpdir do |bin_dir| + with_scratch_dir do |bin_dir| state_file = File.join(bin_dir, "gh_state.txt") File.write(state_file, "latest=v2.0.0\ndraft=true\n") - dmg1_hash = "d02273969b8a00fc7269f69ab6440bae92028bcb55b9e5f4765cb5d925e8c316" - dmg2_hash = "4981863429e85dacf916287e466867ff1c38c838bc364c0ae483739b8d17eaf7" - feed1_hash = "b97d8bdddec17083407dbc08bc23052b84d6d3f7440ddfd6e4443d78f50548d5" - feed2_hash = "306cd1cbc1f415bf7668892be261907ea66f0a381a56b7f6921c3e6758f36609" - - sums_content = "#{dmg1_hash} MeowDisplay.dmg\n#{dmg2_hash} MeowDisplayReceiver.dmg\n#{feed1_hash} appcast.xml\n#{feed2_hash} appcast-receiver.xml\n" + sums_content = "#{DMG1_HASH} MeowDisplay.dmg\n#{DMG2_HASH} MeowDisplayReceiver.dmg\n#{FEED1_HASH} appcast.xml\n#{FEED2_HASH} appcast-receiver.xml\n" mock_gh = File.join(bin_dir, "gh") File.write(mock_gh, <<~SH) #!/usr/bin/env bash case "$*" in + *"deployment-branch-policies"*) + if [[ "$*" == *"--jq"* ]]; then + echo "branch:release/*" + else + echo '{"branch_policies":[{"name":"release/*","type":"branch"}]}' + fi + ;; + *"environments/release-publish"*) + echo '{"deployment_branch_policy":{"custom_branch_policies":true},"protection_rules":[{"type":"required_reviewers"}]}' + ;; + *"actions/runs/12345/approvals"*) + echo '[{"state":"approved","environments":[{"name":"release-publish"}]}]' + ;; *"releases/442117184"*"--jq .draft"*) grep "^draft=" "#{state_file}" | cut -d= -f2 ;; @@ -529,7 +748,7 @@ def test_partial_rollback_failure_when_reverting_draft_fails *"releases/latest"*) grep "^latest=" "#{state_file}" | cut -d= -f2 ;; - *"release download"*) + *"release download v2.1.0"*) dest="${@: -1}" mkdir -p "$dest" printf "%s" "#{sums_content}" > "$dest/SHA256SUMS.txt" @@ -538,14 +757,14 @@ def test_partial_rollback_failure_when_reverting_draft_fails echo "f1" > "$dest/appcast.xml" echo "f2" > "$dest/appcast-receiver.xml" ;; - *"release edit 442117184"*"--draft=false --latest"*) + *"release edit v2.1.0"*"--draft=false --latest"*) sed -i '' 's/^latest=.*/latest=v2.1.0/' "#{state_file}" 2>/dev/null || sed -i 's/^latest=.*/latest=v2.1.0/' "#{state_file}" sed -i '' 's/^draft=.*/draft=false/' "#{state_file}" 2>/dev/null || sed -i 's/^draft=.*/draft=false/' "#{state_file}" ;; *"release edit v2.0.0"*"--latest"*) sed -i '' 's/^latest=.*/latest=v2.0.0/' "#{state_file}" 2>/dev/null || sed -i 's/^latest=.*/latest=v2.0.0/' "#{state_file}" ;; - *"release edit 442117184"*"--draft=true"*) + *"release edit v2.1.0"*"--draft=true"*) exit 1 ;; *"commits/v2.1.0"*) @@ -562,12 +781,19 @@ def test_partial_rollback_failure_when_reverting_draft_fails if [ "$1" = "-a" ] && [ "$2" = "256" ] && [ "$3" = "-c" ]; then exit 0; fi target="${@: -1}" case "$target" in - *"MeowDisplay.dmg") echo "#{dmg1_hash} $target" ;; - *"MeowDisplayReceiver.dmg") echo "#{dmg2_hash} $target" ;; - *"appcast.xml") echo "#{feed1_hash} $target" ;; - *"appcast-receiver.xml") echo "#{feed2_hash} $target" ;; + *"MeowDisplay.dmg") echo "#{DMG1_HASH} $target" ;; + *"MeowDisplayReceiver.dmg") echo "#{DMG2_HASH} $target" ;; + *"appcast.xml") echo "#{FEED1_HASH} $target" ;; + *"appcast-receiver.xml") echo "#{FEED2_HASH} $target" ;; *"SHA256SUMS.txt") echo "#{EXPECTED_SUMS_HASH} $target" ;; - *) echo "0000000000000000000000000000000000000000000000000000000000000000 $target" ;; + *) + content=$(cat) + case "$content" in + *"mock-feed1"*) echo "#{FEED1_HASH} -" ;; + *"mock-feed2"*) echo "#{FEED2_HASH} -" ;; + *) echo "0000000000000000000000000000000000000000000000000000000000000000 -" ;; + esac + ;; esac SH FileUtils.chmod(0o755, mock_shasum) @@ -587,7 +813,9 @@ def test_partial_rollback_failure_when_reverting_draft_fails env = { "PATH" => "#{bin_dir}:#{ENV['PATH']}", - "RECOVERY_TEST_MOCK_SCRIPTS" => "true" + "GITHUB_ACTIONS" => "true", + "GITHUB_RUN_ID" => "12345", + "RUNNER_TEMP" => bin_dir } out, err, status = run_recovery(["--publish"], env) refute status.success? @@ -595,20 +823,63 @@ def test_partial_rollback_failure_when_reverting_draft_fails end end + def test_reconcile_script_requires_explicit_mode + out, err, status = Open3.capture3({}, "bash", RECONCILE_SCRIPT) + refute status.success? + assert_includes err + out, "usage: reconcile-mac-release.sh (--execute | --manifest-only)" + end + def test_reconcile_script_rejects_overrides - out, err, status = Open3.capture3({ "VERSION" => "3.0.0" }, "bash", RECONCILE_SCRIPT) + out, err, status = Open3.capture3({ "VERSION" => "3.0.0" }, "bash", RECONCILE_SCRIPT, "--execute") refute status.success? assert_includes err + out, "VERSION must be '2.1.0'" assert_includes err + out, "rejected" end - def test_reconcile_script_requires_token_when_not_mocked - env = { - "RELEASE_STATE_TOKEN" => "", - "RECOVERY_TEST_MOCK_ENV" => "false", - "RECOVERY_TEST_TMPDIR" => Dir.tmpdir - } - Dir.mktmpdir do |bin_dir| + def test_reconcile_manifest_only_is_strictly_read_only + with_scratch_dir do |bin_dir| + invocations_file = File.join(bin_dir, "state_calls.txt") + File.write(invocations_file, "") + + mock_state = File.join(bin_dir, "release-state.sh") + File.write(mock_state, <<~SH) + #!/usr/bin/env bash + echo "state $*" >> "#{invocations_file}" + case "$*" in + *"publish website-manifest"*) + out_file="${@: -1}" + echo '{"version":"2.1.0","manifest":"ok"}' > "$out_file" + ;; + *) exit 0 ;; + esac + SH + FileUtils.chmod(0o755, mock_state) + + mock_git = File.join(bin_dir, "git") + File.write(mock_git, "#!/usr/bin/env bash\nexit 0\n") + FileUtils.chmod(0o755, mock_git) + + env = { + "PATH" => "#{bin_dir}:#{ENV['PATH']}", + "RUNNER_TEMP" => bin_dir + } + out, err, status = run_reconcile(["--manifest-only"], env) + assert status.success?, "Manifest-only failed: #{err}\n#{out}" + assert_includes out, "Manifest generated successfully (read-only mode; zero state mutations performed)" + assert_includes out, '"version":"2.1.0"' + + calls = File.read(invocations_file) + # Assert ONLY website-manifest was invoked + assert_includes calls, "publish website-manifest" + # Assert mutating subcommands were NEVER invoked + refute_includes calls, "publish start" + refute_includes calls, "publish mac-published" + refute_includes calls, "publish stores" + end + end + + def test_reconcile_execute_requires_release_state_token + with_scratch_dir do |bin_dir| mock_gh = File.join(bin_dir, "gh") File.write(mock_gh, <<~SH) #!/usr/bin/env bash @@ -620,9 +891,87 @@ def test_reconcile_script_requires_token_when_not_mocked SH FileUtils.chmod(0o755, mock_gh) - out, err, status = Open3.capture3(env.merge("PATH" => "#{bin_dir}:#{ENV['PATH']}"), "bash", RECONCILE_SCRIPT) + env = { + "PATH" => "#{bin_dir}:#{ENV['PATH']}", + "RELEASE_STATE_TOKEN" => "", + "RUNNER_TEMP" => bin_dir + } + out, err, status = run_reconcile(["--execute"], env) refute status.success? - assert_includes err + out, "RELEASE_STATE_TOKEN is required" + assert_includes err + out, "RELEASE_STATE_TOKEN is required to mutate the protected release-state branch" + end + end + + def test_successful_reconciliation_end_to_end + with_scratch_dir do |bin_dir| + invocations_file = File.join(bin_dir, "reconcile_calls.txt") + File.write(invocations_file, "") + + mock_gh = File.join(bin_dir, "gh") + File.write(mock_gh, <<~SH) + #!/usr/bin/env bash + case "$*" in + *"releases/latest"*) echo 'v2.1.0' ;; + *"commits/v2.1.0"*) echo '#{VALID_SHA}' ;; + *) exit 0 ;; + esac + SH + FileUtils.chmod(0o755, mock_gh) + + mock_state = File.join(bin_dir, "release-state.sh") + File.write(mock_state, <<~SH) + #!/usr/bin/env bash + echo "state $*" >> "#{invocations_file}" + case "$*" in + *"publish website-manifest"*) + out_file="${@: -1}" + echo '{"version":"2.1.0","manifest":"ok"}' > "$out_file" + ;; + *) exit 0 ;; + esac + SH + FileUtils.chmod(0o755, mock_state) + + mock_git = File.join(bin_dir, "git") + File.write(mock_git, <<~'SH') + #!/usr/bin/env bash + case "$*" in + *"fetch"*) exit 0 ;; + *"show origin/release-state"*) + echo '{"publication":{"stage":"in-progress"},"platforms":{"mac":{"stage":"published"},"ios":{"stage":"published"}}}' + ;; + *) exec /usr/bin/git "$@" ;; + esac + SH + FileUtils.chmod(0o755, mock_git) + + env = { + "PATH" => "#{bin_dir}:#{ENV['PATH']}", + "RELEASE_STATE_TOKEN" => "app-token-xyz-123", + "RUNNER_TEMP" => bin_dir + } + out, err, status = run_reconcile(["--execute"], env) + assert status.success?, "Reconcile execute failed: #{err}\n#{out}" + assert_includes out, "release-state successfully reconciled" + assert_includes out, "platforms.mac: published" + assert_includes out, "platforms.ios: published" + assert_includes out, "publication: in-progress" + + calls = File.read(invocations_file) + # Assert state progression happened in exact order + idx_start = calls.index("publish start STATE") + idx_mac = calls.index("publish mac-published STATE") + idx_stores = calls.index("publish stores STATE") + idx_manifest = calls.index("publish website-manifest STATE") + + assert idx_start, "publish start STATE was not called" + assert idx_mac, "publish mac-published STATE was not called" + assert idx_stores, "publish stores STATE was not called" + assert idx_manifest, "publish website-manifest STATE was not called" + + assert idx_start < idx_mac, "publish start must precede mac-published" + assert idx_mac < idx_stores, "mac-published must precede stores" + assert idx_stores < idx_manifest, "stores must precede website-manifest" end end end From 530404f7569e64f1f9736a6cae0f4af97d7aad7d Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Thu, 12 Nov 2026 03:13:37 +0530 Subject: [PATCH 4/6] fix(release): strengthen external T7 scratch validation and mount guards --- scripts/release/reconcile-mac-release.sh | 65 +++++++++++++++---- scripts/release/recover-mac-release.sh | 63 ++++++++++++++---- .../release/test/recover_mac_release_test.rb | 56 +++++++++++++--- 3 files changed, 150 insertions(+), 34 deletions(-) diff --git a/scripts/release/reconcile-mac-release.sh b/scripts/release/reconcile-mac-release.sh index c167d8781..7f0e6949c 100755 --- a/scripts/release/reconcile-mac-release.sh +++ b/scripts/release/reconcile-mac-release.sh @@ -63,23 +63,64 @@ here="$(cd "$(dirname "$0")" && pwd)" state_cmd="release-state.sh" command -v "$state_cmd" >/dev/null 2>&1 || state_cmd="$here/release-state.sh" -# Scratch storage policy +# Scratch storage policy enforcement: +# In CI: $RUNNER_TEMP is required. +# In local runs: scratch storage must genuinely reside on the mounted external T7 volume (/Volumes/T7Tmp/ai-dev). +# Fake mount points, unmounted volumes, and arbitrary non-T7 paths are strictly rejected. scratch_base="" if [ -n "${RUNNER_TEMP:-}" ]; then scratch_base="$RUNNER_TEMP" -elif [ -n "${T7_RUN_DIR:-}" ] && [ -d "$T7_RUN_DIR" ]; then - scratch_base="$T7_RUN_DIR" -elif [ -d "/Volumes/T7Tmp/ai-dev" ]; then +else + # 1. Verify /Volumes/T7Tmp is genuinely mounted and present in mount table + if ! mount | grep -E -q '[[:space:]]on /Volumes/T7Tmp[[:space:]]'; then + echo "::error::External T7 scratch volume (/Volumes/T7Tmp) is not mounted." + exit 1 + fi + # 2. Verify /Volumes/T7Tmp is an actual mount point, not a directory on internal root + if [ "$(df -P /Volumes/T7Tmp 2>/dev/null | awk 'NR==2{print $6}')" != "/Volumes/T7Tmp" ]; then + echo "::error::/Volumes/T7Tmp is not a mounted volume (detected fake mount-point directory on internal disk)." + exit 1 + fi + # 3. Verify external T7 health via t7run if available if command -v t7run >/dev/null 2>&1; then - t7run --check || { echo "::error::T7 scratch storage check failed"; exit 1; } + t7run --check || { echo "::error::T7 scratch storage verification (t7run --check) failed"; exit 1; } fi - mkdir -p "/Volumes/T7Tmp/ai-dev/tmp" - scratch_base="/Volumes/T7Tmp/ai-dev/tmp" -elif [ -n "${TMPDIR:-}" ] && [[ "$TMPDIR" == /Volumes/T7Tmp/* ]]; then - scratch_base="$TMPDIR" -else - echo "::error::No approved scratch location found. T7 scratch storage (/Volumes/T7Tmp/ai-dev) is required locally." - exit 1 + + # 4. Resolve candidate scratch directory + target_scratch="" + if [ -n "${T7_RUN_DIR:-}" ]; then + target_scratch="$T7_RUN_DIR" + elif [ -n "${TMPDIR:-}" ] && [[ "$TMPDIR" == /Volumes/T7Tmp/* ]]; then + target_scratch="$TMPDIR" + else + target_scratch="/Volumes/T7Tmp/ai-dev/tmp" + fi + + # 5. Ensure candidate directory exists or can be created + mkdir -p "$target_scratch" 2>/dev/null || { + echo "::error::Failed to create or access scratch directory '$target_scratch'" + exit 1 + } + + # 6. Verify canonical physical path (resolving symlinks) genuinely resides under /Volumes/T7Tmp/ai-dev + canonical_scratch=$(cd "$target_scratch" && pwd -P) + case "$canonical_scratch" in + /Volumes/T7Tmp/ai-dev | /Volumes/T7Tmp/ai-dev/*) + ;; + *) + echo "::error::Scratch path '$target_scratch' resolves to '$canonical_scratch', which is outside /Volumes/T7Tmp/ai-dev. Arbitrary scratch paths are rejected." + exit 1 + ;; + esac + + # 7. Verify the filesystem mount point of the canonical path is /Volumes/T7Tmp + scratch_mount=$(df -P "$canonical_scratch" 2>/dev/null | awk 'NR==2{print $6}') + if [ "$scratch_mount" != "/Volumes/T7Tmp" ]; then + echo "::error::Scratch path '$canonical_scratch' is mounted on '$scratch_mount', expected '/Volumes/T7Tmp'." + exit 1 + fi + + scratch_base="$canonical_scratch" fi work=$(mktemp -d "$scratch_base/meow-reconcile.XXXXXX") diff --git a/scripts/release/recover-mac-release.sh b/scripts/release/recover-mac-release.sh index 44ab321b9..addb71c9d 100755 --- a/scripts/release/recover-mac-release.sh +++ b/scripts/release/recover-mac-release.sh @@ -102,23 +102,62 @@ fi # Scratch storage policy enforcement: # In CI: $RUNNER_TEMP is required. -# In local runs: external T7 scratch storage (/Volumes/T7Tmp/ai-dev) is required. Internal /tmp is rejected. +# In local runs: scratch storage must genuinely reside on the mounted external T7 volume (/Volumes/T7Tmp/ai-dev). +# Fake mount points, unmounted volumes, and arbitrary non-T7 paths are strictly rejected. scratch_base="" if [ -n "${RUNNER_TEMP:-}" ]; then scratch_base="$RUNNER_TEMP" -elif [ -n "${T7_RUN_DIR:-}" ] && [ -d "$T7_RUN_DIR" ]; then - scratch_base="$T7_RUN_DIR" -elif [ -d "/Volumes/T7Tmp/ai-dev" ]; then +else + # 1. Verify /Volumes/T7Tmp is genuinely mounted and present in mount table + if ! mount | grep -E -q '[[:space:]]on /Volumes/T7Tmp[[:space:]]'; then + echo "::error::External T7 scratch volume (/Volumes/T7Tmp) is not mounted." + exit 1 + fi + # 2. Verify /Volumes/T7Tmp is an actual mount point, not a directory on internal root + if [ "$(df -P /Volumes/T7Tmp 2>/dev/null | awk 'NR==2{print $6}')" != "/Volumes/T7Tmp" ]; then + echo "::error::/Volumes/T7Tmp is not a mounted volume (detected fake mount-point directory on internal disk)." + exit 1 + fi + # 3. Verify external T7 health via t7run if available if command -v t7run >/dev/null 2>&1; then - t7run --check || { echo "::error::T7 scratch storage check failed"; exit 1; } + t7run --check || { echo "::error::T7 scratch storage verification (t7run --check) failed"; exit 1; } fi - mkdir -p "/Volumes/T7Tmp/ai-dev/tmp" - scratch_base="/Volumes/T7Tmp/ai-dev/tmp" -elif [ -n "${TMPDIR:-}" ] && [[ "$TMPDIR" == /Volumes/T7Tmp/* ]]; then - scratch_base="$TMPDIR" -else - echo "::error::No approved scratch location found. T7 scratch storage (/Volumes/T7Tmp/ai-dev) is required for local execution. Never fall back to internal /tmp." - exit 1 + + # 4. Resolve candidate scratch directory + target_scratch="" + if [ -n "${T7_RUN_DIR:-}" ]; then + target_scratch="$T7_RUN_DIR" + elif [ -n "${TMPDIR:-}" ] && [[ "$TMPDIR" == /Volumes/T7Tmp/* ]]; then + target_scratch="$TMPDIR" + else + target_scratch="/Volumes/T7Tmp/ai-dev/tmp" + fi + + # 5. Ensure candidate directory exists or can be created + mkdir -p "$target_scratch" 2>/dev/null || { + echo "::error::Failed to create or access scratch directory '$target_scratch'" + exit 1 + } + + # 6. Verify canonical physical path (resolving symlinks) genuinely resides under /Volumes/T7Tmp/ai-dev + canonical_scratch=$(cd "$target_scratch" && pwd -P) + case "$canonical_scratch" in + /Volumes/T7Tmp/ai-dev | /Volumes/T7Tmp/ai-dev/*) + ;; + *) + echo "::error::Scratch path '$target_scratch' resolves to '$canonical_scratch', which is outside /Volumes/T7Tmp/ai-dev. Arbitrary scratch paths are rejected." + exit 1 + ;; + esac + + # 7. Verify the filesystem mount point of the canonical path is /Volumes/T7Tmp + scratch_mount=$(df -P "$canonical_scratch" 2>/dev/null | awk 'NR==2{print $6}') + if [ "$scratch_mount" != "/Volumes/T7Tmp" ]; then + echo "::error::Scratch path '$canonical_scratch' is mounted on '$scratch_mount', expected '/Volumes/T7Tmp'." + exit 1 + fi + + scratch_base="$canonical_scratch" fi work=$(mktemp -d "$scratch_base/meow-recover.XXXXXX") diff --git a/scripts/release/test/recover_mac_release_test.rb b/scripts/release/test/recover_mac_release_test.rb index 2d89eb00e..99bc06c07 100644 --- a/scripts/release/test/recover_mac_release_test.rb +++ b/scripts/release/test/recover_mac_release_test.rb @@ -143,17 +143,53 @@ def test_stale_release_state_fetch_failure_halts_immediately end def test_scratch_volume_enforcement_locally - out, err, status = Open3.capture3({}, "bash", "-c", <<~SH) - export RUNNER_TEMP="" T7_RUN_DIR="" TMPDIR="/private/tmp" - if [ ! -d "/Volumes/T7Tmp/ai-dev" ]; then - bash "#{SCRIPT}" - else - echo "::error::No approved scratch location found. T7 scratch storage (/Volumes/T7Tmp/ai-dev) is required for local execution. Never fall back to internal /tmp." - exit 1 - fi - SH + # 1. Arbitrary T7_RUN_DIR outside /Volumes/T7Tmp/ai-dev is rejected + out, err, status = Open3.capture3({ "RUNNER_TEMP" => "", "T7_RUN_DIR" => "/tmp/unapproved_scratch" }, "bash", SCRIPT) + refute status.success? + assert_includes err + out, "outside /Volumes/T7Tmp/ai-dev" + assert_includes err + out, "Arbitrary scratch paths are rejected" + + # Reconcile script also rejects arbitrary T7_RUN_DIR + out, err, status = Open3.capture3({ "RUNNER_TEMP" => "", "T7_RUN_DIR" => "/tmp/unapproved_scratch" }, "bash", RECONCILE_SCRIPT, "--manifest-only") refute status.success? - assert_includes err + out, "No approved scratch location found" + assert_includes err + out, "outside /Volumes/T7Tmp/ai-dev" + assert_includes err + out, "Arbitrary scratch paths are rejected" + + # 2. Unmounted /Volumes/T7Tmp volume is rejected + with_scratch_dir do |bin_dir| + mock_mount = File.join(bin_dir, "mount") + File.write(mock_mount, <<~'SH') + #!/usr/bin/env bash + echo "/dev/disk1s1 on / (apfs, local)" + SH + FileUtils.chmod(0o755, mock_mount) + + out, err, status = Open3.capture3({ "PATH" => "#{bin_dir}:#{ENV['PATH']}", "RUNNER_TEMP" => "" }, "bash", SCRIPT) + refute status.success? + assert_includes err + out, "External T7 scratch volume (/Volumes/T7Tmp) is not mounted" + end + + # 3. Fake mount point directory on internal disk is rejected + with_scratch_dir do |bin_dir| + mock_mount = File.join(bin_dir, "mount") + File.write(mock_mount, <<~'SH') + #!/usr/bin/env bash + echo "/dev/disk8s1 on /Volumes/T7Tmp (apfs)" + SH + FileUtils.chmod(0o755, mock_mount) + + mock_df = File.join(bin_dir, "df") + File.write(mock_df, <<~'SH') + #!/usr/bin/env bash + echo "Filesystem 512-blocks Used Available Capacity Mounted on" + echo "/dev/disk1s1 1000 500 500 50% /System/Volumes/Data" + SH + FileUtils.chmod(0o755, mock_df) + + out, err, status = Open3.capture3({ "PATH" => "#{bin_dir}:#{ENV['PATH']}", "RUNNER_TEMP" => "" }, "bash", SCRIPT) + refute status.success? + assert_includes err + out, "detected fake mount-point directory on internal disk" + end end def test_failure_before_publication_when_target_commit_mismatches From 4e8d3a8a0367c3b3f85bf3e70303ff7fe5d3abba Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Thu, 12 Nov 2026 03:18:05 +0530 Subject: [PATCH 5/6] fix(test): mock mount and df in local scratch enforcement test for cross-platform portability --- .../release/test/recover_mac_release_test.rb | 42 +++++++++++++++---- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/scripts/release/test/recover_mac_release_test.rb b/scripts/release/test/recover_mac_release_test.rb index 99bc06c07..2c0382d10 100644 --- a/scripts/release/test/recover_mac_release_test.rb +++ b/scripts/release/test/recover_mac_release_test.rb @@ -144,16 +144,40 @@ def test_stale_release_state_fetch_failure_halts_immediately def test_scratch_volume_enforcement_locally # 1. Arbitrary T7_RUN_DIR outside /Volumes/T7Tmp/ai-dev is rejected - out, err, status = Open3.capture3({ "RUNNER_TEMP" => "", "T7_RUN_DIR" => "/tmp/unapproved_scratch" }, "bash", SCRIPT) - refute status.success? - assert_includes err + out, "outside /Volumes/T7Tmp/ai-dev" - assert_includes err + out, "Arbitrary scratch paths are rejected" + with_scratch_dir do |bin_dir| + mock_mount = File.join(bin_dir, "mount") + File.write(mock_mount, <<~'SH') + #!/usr/bin/env bash + echo "/dev/disk8s1 on /Volumes/T7Tmp (apfs)" + SH + FileUtils.chmod(0o755, mock_mount) - # Reconcile script also rejects arbitrary T7_RUN_DIR - out, err, status = Open3.capture3({ "RUNNER_TEMP" => "", "T7_RUN_DIR" => "/tmp/unapproved_scratch" }, "bash", RECONCILE_SCRIPT, "--manifest-only") - refute status.success? - assert_includes err + out, "outside /Volumes/T7Tmp/ai-dev" - assert_includes err + out, "Arbitrary scratch paths are rejected" + mock_df = File.join(bin_dir, "df") + File.write(mock_df, <<~'SH') + #!/usr/bin/env bash + echo "Filesystem 512-blocks Used Available Capacity Mounted on" + echo "/dev/disk8s1 1000 500 500 50% /Volumes/T7Tmp" + SH + FileUtils.chmod(0o755, mock_df) + + mock_t7run = File.join(bin_dir, "t7run") + File.write(mock_t7run, <<~'SH') + #!/usr/bin/env bash + exit 0 + SH + FileUtils.chmod(0o755, mock_t7run) + + out, err, status = Open3.capture3({ "PATH" => "#{bin_dir}:#{ENV['PATH']}", "RUNNER_TEMP" => "", "T7_RUN_DIR" => "/tmp/unapproved_scratch" }, "bash", SCRIPT) + refute status.success? + assert_includes err + out, "outside /Volumes/T7Tmp/ai-dev" + assert_includes err + out, "Arbitrary scratch paths are rejected" + + # Reconcile script also rejects arbitrary T7_RUN_DIR + out, err, status = Open3.capture3({ "PATH" => "#{bin_dir}:#{ENV['PATH']}", "RUNNER_TEMP" => "", "T7_RUN_DIR" => "/tmp/unapproved_scratch" }, "bash", RECONCILE_SCRIPT, "--manifest-only") + refute status.success? + assert_includes err + out, "outside /Volumes/T7Tmp/ai-dev" + assert_includes err + out, "Arbitrary scratch paths are rejected" + end # 2. Unmounted /Volumes/T7Tmp volume is rejected with_scratch_dir do |bin_dir| From 6b287c547dabe72dad179a95fad6af866c8a4b41 Mon Sep 17 00:00:00 2001 From: J Date: Thu, 12 Nov 2026 03:52:39 +0530 Subject: [PATCH 6/6] fix(release): use frozen candidate config in recovery preflight and reconciliation --- .github/workflows/recover-mac-release.yml | 2 + scripts/release/check-release-workflows.rb | 2 + scripts/release/reconcile-mac-release.sh | 70 +++++++++++++++---- .../release/test/recover_mac_release_test.rb | 68 +++++++++++++++--- 4 files changed, 122 insertions(+), 20 deletions(-) mode change 100755 => 100644 scripts/release/check-release-workflows.rb mode change 100755 => 100644 scripts/release/reconcile-mac-release.sh diff --git a/.github/workflows/recover-mac-release.yml b/.github/workflows/recover-mac-release.yml index 39d38fe34..ee162032a 100644 --- a/.github/workflows/recover-mac-release.yml +++ b/.github/workflows/recover-mac-release.yml @@ -53,6 +53,8 @@ jobs: run: | set -euo pipefail scripts/release/recover-mac-release.sh --verify + # Verify reconciliation can start BEFORE Mac becomes public. + scripts/release/reconcile-mac-release.sh --preflight publish: needs: verify diff --git a/scripts/release/check-release-workflows.rb b/scripts/release/check-release-workflows.rb old mode 100755 new mode 100644 index 967f0b529..b8f4b696a --- a/scripts/release/check-release-workflows.rb +++ b/scripts/release/check-release-workflows.rb @@ -346,6 +346,8 @@ def release_ref_guard?(step, version_from_input:) only_trigger(name, doc, "workflow_dispatch") secrets_behind_environments(name, doc, %w[release-publish]) jobs = doc["jobs"] || {} + ver = jobs["verify"] + err(name, "verify must preflight candidate configuration") unless ver && steps(ver).any? { |s| run_text(s).include?("reconcile-mac-release.sh --preflight") } pub = jobs["publish"] if pub.nil? err(name, "missing the publish job") diff --git a/scripts/release/reconcile-mac-release.sh b/scripts/release/reconcile-mac-release.sh old mode 100755 new mode 100644 index 7f0e6949c..7fd0231b7 --- a/scripts/release/reconcile-mac-release.sh +++ b/scripts/release/reconcile-mac-release.sh @@ -3,7 +3,7 @@ # after Mac 2.1.0 publication is live. # # Usage: -# reconcile-mac-release.sh (--execute | --manifest-only) +# reconcile-mac-release.sh (--execute | --manifest-only | --preflight) # # Modes: # --execute Mutate release-state: advance publication from 'failed' to @@ -12,6 +12,7 @@ # Requires RELEASE_STATE_TOKEN (App ID 5582030). # --manifest-only Strictly read-only: generate website release.json manifest # from recorded state without mutating release-state. +# --preflight Read-only dry-run of publication start using frozen candidate config. # # Requirements: # - Pinned to VERSION 2.1.0, CANDIDATE_SHA 94c796fc34fd699776764d67d2f26013909a308e. @@ -47,13 +48,17 @@ case "${1:-}" in --manifest-only) mode="manifest-only" ;; + --preflight) + mode="preflight" + ;; --execute) mode="execute" ;; *) - echo "::error::usage: reconcile-mac-release.sh (--execute | --manifest-only)" + echo "::error::usage: reconcile-mac-release.sh (--execute | --manifest-only | --preflight)" echo " --execute: Reconcile release-state with authorized release App token (mutating)" echo " --manifest-only: Generate website release.json from current recorded state (read-only)" + echo " --preflight: Check frozen candidate config and state machine without publishing" exit 1 ;; esac @@ -126,6 +131,11 @@ fi work=$(mktemp -d "$scratch_base/meow-reconcile.XXXXXX") trap 'rm -rf "$work"' EXIT +if [ "$mode" = "execute" ] && [ -z "${RELEASE_STATE_TOKEN:-}" ]; then + echo "::error::RELEASE_STATE_TOKEN is required to mutate the protected release-state branch (Ruleset 26251855)." + exit 1 +fi + echo "=== MeowDisplay Release-State Reconciliation ===" echo "Version: $VERSION" echo "Candidate SHA: $CANDIDATE_SHA" @@ -133,6 +143,45 @@ echo "Repository: $REPO" echo "Mode: $mode" echo "" +# Generic main config still requires Android; the frozen 2.1.0 candidate +# explicitly excludes Android. Always extract the config from that commit. +candidate_config="$work/approved-release-config.json" +git -C "$here/../.." fetch -q origin refs/heads/release/2.1.0 || { + echo "::error::Cannot fetch frozen release/2.1.0" + exit 1 +} +candidate_head=$(git -C "$here/../.." rev-parse FETCH_HEAD) +[ "$candidate_head" = "$CANDIDATE_SHA" ] || { + echo "::error::release/2.1.0 moved to $candidate_head; expected $CANDIDATE_SHA" + exit 1 +} +git -C "$here/../.." show "$CANDIDATE_SHA:.github/release-config.json" > "$candidate_config" || { + echo "::error::Cannot read frozen candidate release config" + exit 1 +} +jq -e '(.requiredPlatforms | sort) == ["ios","mac"] and (.excludedPlatforms | sort) == ["android"] and .ios.submitForReview == false' "$candidate_config" >/dev/null || { + echo "::error::Frozen candidate config must require mac + ios, exclude android, and disable iOS resubmission" + exit 1 +} +echo "✓ Approved candidate config verified: Mac + iOS; Android excluded" + +# Preflight the REAL publication state machine on an expendable copy, +# preventing the original post-publication reconciliation failure. +if [ "$mode" = "preflight" ]; then + git -C "$here/../.." fetch -q origin release-state || { + echo "::error::Cannot fetch fresh release-state for preflight" + exit 1 + } + git -C "$here/../.." show "origin/release-state:releases/$VERSION.json" > "$work/state-for-preflight.json" + "$here/meow-release" publish start "$work/state-for-preflight.json" \ + --config "$candidate_config" --branch-head "$CANDIDATE_SHA" || { + echo "::error::Publication state machine refuses recovery" + exit 1 + } + echo "✓ Read-only preflight passed; scratch state only" + exit 0 +fi + # Mode: manifest-only (genuinely read-only) if [ "$mode" = "manifest-only" ]; then echo "1. Generating website manifest from current recorded release-state (read-only)..." @@ -142,7 +191,7 @@ if [ "$mode" = "manifest-only" ]; then } manifest_out="$work/release.json" "$state_cmd" "$VERSION" publish website-manifest STATE \ - --config "$here/../../.github/release-config.json" \ + --config "$candidate_config" \ --out "$manifest_out" cat "$manifest_out" @@ -172,27 +221,24 @@ fi # 3. Transition publication from 'failed' to 'in-progress' echo "3. Restarting publication state from 'failed' to 'in-progress'..." "$state_cmd" "$VERSION" publish start STATE \ - --config "$here/../../.github/release-config.json" \ + --config "$candidate_config" \ --branch-head "$CANDIDATE_SHA" # 4. Advance Mac platform to 'published' echo "4. Recording Mac platform publication in release-state..." "$state_cmd" "$VERSION" publish mac-published STATE \ - --config "$here/../../.github/release-config.json" \ + --config "$candidate_config" \ --url "https://github.com/$REPO/releases/tag/v$VERSION" -# 5. Process stores (iOS is already published, Android is excluded) -echo "5. Verifying store distribution state..." -out_stores=$("$state_cmd" "$VERSION" publish stores STATE \ - --config "$here/../../.github/release-config.json" \ - --stores live) -echo "$out_stores" +# 5. iOS is already published, Android excluded. Do NOT invoke stores: +# that requires unnecessary App Store credentials and risks store actions. +echo "5. Keeping previously published iOS; no store operations" # 6. Generate accurate website manifest echo "6. Generating website manifest release.json..." manifest_out="$work/release.json" "$state_cmd" "$VERSION" publish website-manifest STATE \ - --config "$here/../../.github/release-config.json" \ + --config "$candidate_config" \ --out "$manifest_out" cat "$manifest_out" diff --git a/scripts/release/test/recover_mac_release_test.rb b/scripts/release/test/recover_mac_release_test.rb index 2c0382d10..2059666fe 100644 --- a/scripts/release/test/recover_mac_release_test.rb +++ b/scripts/release/test/recover_mac_release_test.rb @@ -886,7 +886,7 @@ def test_partial_rollback_failure_when_reverting_draft_fails def test_reconcile_script_requires_explicit_mode out, err, status = Open3.capture3({}, "bash", RECONCILE_SCRIPT) refute status.success? - assert_includes err + out, "usage: reconcile-mac-release.sh (--execute | --manifest-only)" + assert_includes err + out, "usage: reconcile-mac-release.sh (--execute | --manifest-only | --preflight)" end def test_reconcile_script_rejects_overrides @@ -916,7 +916,17 @@ def test_reconcile_manifest_only_is_strictly_read_only FileUtils.chmod(0o755, mock_state) mock_git = File.join(bin_dir, "git") - File.write(mock_git, "#!/usr/bin/env bash\nexit 0\n") + File.write(mock_git, <<~SH) + #!/usr/bin/env bash + case "$*" in + *"fetch"*) exit 0 ;; + *"rev-parse FETCH_HEAD"*) echo '#{VALID_SHA}' ;; + *"show #{VALID_SHA}:.github/release-config.json"*) + echo '{"requiredPlatforms":["mac","ios"],"excludedPlatforms":["android"],"ios":{"submitForReview":false}}' + ;; + *) exit 1 ;; + esac + SH FileUtils.chmod(0o755, mock_git) env = { @@ -993,10 +1003,14 @@ def test_successful_reconciliation_end_to_end FileUtils.chmod(0o755, mock_state) mock_git = File.join(bin_dir, "git") - File.write(mock_git, <<~'SH') + File.write(mock_git, <<~SH) #!/usr/bin/env bash case "$*" in *"fetch"*) exit 0 ;; + *"rev-parse FETCH_HEAD"*) echo '#{VALID_SHA}' ;; + *"show #{VALID_SHA}:.github/release-config.json"*) + echo '{"requiredPlatforms":["mac","ios"],"excludedPlatforms":["android"],"ios":{"submitForReview":false}}' + ;; *"show origin/release-state"*) echo '{"publication":{"stage":"in-progress"},"platforms":{"mac":{"stage":"published"},"ios":{"stage":"published"}}}' ;; @@ -1021,17 +1035,55 @@ def test_successful_reconciliation_end_to_end # Assert state progression happened in exact order idx_start = calls.index("publish start STATE") idx_mac = calls.index("publish mac-published STATE") - idx_stores = calls.index("publish stores STATE") idx_manifest = calls.index("publish website-manifest STATE") assert idx_start, "publish start STATE was not called" assert idx_mac, "publish mac-published STATE was not called" - assert idx_stores, "publish stores STATE was not called" assert idx_manifest, "publish website-manifest STATE was not called" - assert idx_start < idx_mac, "publish start must precede mac-published" - assert idx_mac < idx_stores, "mac-published must precede stores" - assert idx_stores < idx_manifest, "stores must precede website-manifest" + assert idx_mac < idx_manifest, "mac-published must precede website-manifest" + assert_includes calls, "approved-release-config.json" + refute_includes calls, "publish stores STATE" + refute_includes calls, "/.github/release-config.json" + end + end + + def test_preflight_rejects_generic_android_required_config + with_scratch_dir do |bin_dir| + git = File.join(bin_dir, "git") + File.write(git, <<~SH) + #!/usr/bin/env bash + case "$*" in + *"fetch"*) exit 0 ;; + *"rev-parse FETCH_HEAD"*) echo '#{VALID_SHA}' ;; + *"show #{VALID_SHA}:.github/release-config.json"*) + echo '{"requiredPlatforms":["mac","ios","android"],"excludedPlatforms":[],"ios":{"submitForReview":true}}' + ;; + *) exit 1 ;; + esac + SH + FileUtils.chmod(0o755, git) + out, err, status = run_reconcile(["--preflight"], "PATH" => "#{bin_dir}:#{ENV['PATH']}", "RUNNER_TEMP" => bin_dir) + refute status.success? + assert_includes err + out, "Frozen candidate config must require mac + ios, exclude android" + end + end + + def test_candidate_branch_movement_fails_closed + with_scratch_dir do |bin_dir| + git = File.join(bin_dir, "git") + File.write(git, <<~'SH') + #!/usr/bin/env bash + case "$*" in + *"fetch"*) exit 0 ;; + *"rev-parse FETCH_HEAD"*) echo '1111111111111111111111111111111111111111' ;; + *) exit 1 ;; + esac + SH + FileUtils.chmod(0o755, git) + out, err, status = run_reconcile(["--manifest-only"], "PATH" => "#{bin_dir}:#{ENV['PATH']}", "RUNNER_TEMP" => bin_dir) + refute status.success? + assert_includes err + out, "release/2.1.0 moved" end end end