diff --git a/.github/workflows/recover-mac-release.yml b/.github/workflows/recover-mac-release.yml new file mode 100644 index 000000000..ee162032a --- /dev/null +++ b/.github/workflows/recover-mac-release.yml @@ -0,0 +1,112 @@ +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 + # Verify reconciliation can start BEFORE Mac becomes public. + scripts/release/reconcile-mac-release.sh --preflight + + 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 old mode 100755 new mode 100644 index 9b5a3310c..b8f4b696a --- 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,26 @@ 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"] || {} + 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") + 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 new file mode 100644 index 000000000..7fd0231b7 --- /dev/null +++ b/scripts/release/reconcile-mac-release.sh @@ -0,0 +1,267 @@ +#!/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 (--execute | --manifest-only | --preflight) +# +# 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. +# --preflight Read-only dry-run of publication start using frozen candidate config. +# +# Requirements: +# - Pinned to VERSION 2.1.0, CANDIDATE_SHA 94c796fc34fd699776764d67d2f26013909a308e. +# - 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 + +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}" +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)" + 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="" +case "${1:-}" in + --manifest-only) + mode="manifest-only" + ;; + --preflight) + mode="preflight" + ;; + --execute) + mode="execute" + ;; + *) + 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 + +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 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" +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 verification (t7run --check) failed"; exit 1; } + fi + + # 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") +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" +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)..." + 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 "$candidate_config" \ + --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..." +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" + +# 2. Require release App token for state writes +echo "2. Checking release-state authorization..." +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 +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 "$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 "$candidate_config" \ + --url "https://github.com/$REPO/releases/tag/v$VERSION" + +# 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 "$candidate_config" \ + --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 new file mode 100755 index 000000000..addb71c9d --- /dev/null +++ b/scripts/release/recover-mac-release.sh @@ -0,0 +1,370 @@ +#!/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. +# Local execution is strictly verification-only. +# --publish Perform all verifications, publish the draft as Latest, verify live feeds, +# 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 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. +# - 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 + +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" +export GITHUB_REPOSITORY="$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 ===" +echo "Version: $VERSION" +echo "Candidate SHA: $CANDIDATE_SHA" +echo "Draft ID: $DRAFT_ID" +echo "Mode: $mode" +echo "Repository: $REPO" +echo "" + +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" ]; 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 [ -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 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" +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 verification (t7run --check) failed"; exit 1; } + fi + + # 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") +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 (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 || { + 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; } +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 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 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 is '$actual_sums_hash', expected pinned '$EXPECTED_SHA256SUMS_DIGEST'" + exit 1 +} +echo " ✓ SHA256SUMS.txt matches independently pinned digest $EXPECTED_SHA256SUMS_DIGEST" + +# 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" + +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" + +for role in sender receiver; do + select_app "$role" + echo "--- Verifying $role ($DMG) ---" + "$check_build_cmd" "$VERSION" "$LIVE_FEED_BASE/$FEED" + "$verify_mac_cmd" "$work/draft/$DMG" "$role" "$VERSION" "$VERSION" "$work/baseline" + echo "--- Verifying Feed ($FEED) ---" + "$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" + +if [ "$mode" = "verify" ]; then + echo "" + echo "=================================================================" + echo "VERIFICATION PASSED: Draft v$VERSION is safe and ready to 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 (running inside protected GitHub Actions environment) +echo "" +echo "6. Publishing draft release as Latest..." +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" +[ "$(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::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 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 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 v$VERSION --draft=true'" + fi + + # Explicit reporting on failure modes + if $revert_latest_ok && $revert_draft_ok; then + 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 + exit 1 +} +trap rollback ERR + +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) +[ "$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 "${FEED_POLL_SLEEP:-30}" +done + +if [ "$success" != "true" ]; then + echo "::error::Live feeds did not serve v$VERSION after 5 minutes" + rollback +fi + +trap - ERR + +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..2059666fe --- /dev/null +++ b/scripts/release/test/recover_mac_release_test.rb @@ -0,0 +1,1089 @@ +# 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__) + RECONCILE_SCRIPT = File.expand_path("../reconcile-mac-release.sh", __dir__) + 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 = { + "GH_TOKEN" => "mock-token", + "FEED_POLL_SLEEP" => "0" + } + Open3.capture3(default_env.merge(env), "bash", SCRIPT, *args) + end + + 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'" + assert_includes err + out, "rejected" + + out, err, status = Open3.capture3({ "CANDIDATE_SHA" => "1111111111111111111111111111111111111111" }, "bash", SCRIPT) + refute status.success? + assert_includes err + out, "CANDIDATE_SHA must be '94c796fc34fd699776764d67d2f26013909a308e'" + assert_includes err + out, "rejected" + + out, err, status = Open3.capture3({ "DRAFT_ID" => "99999999" }, "bash", SCRIPT) + refute status.success? + 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", + "GITHUB_RUN_ID" => "" + } + 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_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 + with_scratch_dir 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":"#{VALID_SHA}"}' + ;; + *) exit 0 ;; + esac + SH + FileUtils.chmod(0o755, mock_gh) + + 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" + end + end + + def test_scratch_volume_enforcement_locally + # 1. Arbitrary T7_RUN_DIR outside /Volumes/T7Tmp/ai-dev 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/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| + 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 + with_scratch_dir 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']}", "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 + with_scratch_dir 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":"#{VALID_SHA}"}' + ;; + *) exit 1 ;; + esac + SH + FileUtils.chmod(0o755, mock_gh) + + 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 + with_scratch_dir 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":"#{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']}", "RUNNER_TEMP" => bin_dir) + refute status.success? + 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 + with_scratch_dir 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":"#{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']}", "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_halts_before_publication + with_scratch_dir do |bin_dir| + state_file = File.join(bin_dir, "published.txt") + File.write(state_file, "false") + + 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"}]}' + ;; + *"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 "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 + 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) + + 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_feed) + + 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']}", + "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 "false", File.read(state_file), "Publication was attempted despite feed verification failure!" + end + end + + 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") + + 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) + + 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 + ;; + *"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}" + ;; + *"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 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"*) + 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" ;; + *) + 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, "#!/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']}", + "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: 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 + 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") + + 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 + ;; + *"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 "d1" > "$dest/MeowDisplay.dmg" + echo "d2" > "$dest/MeowDisplayReceiver.dmg" + echo "f1" > "$dest/appcast.xml" + echo "f2" > "$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}" + ;; + *"release edit v2.0.0"*"--latest"*) + exit 1 + ;; + *"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"*) + 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" ;; + *) + 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")) + 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']}", + "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, "PARTIAL ROLLBACK FAILURE: revert_latest=false, revert_draft=true" + end + end + + def test_partial_rollback_failure_when_reverting_draft_fails + 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") + + 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 + ;; + *"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 "d1" > "$dest/MeowDisplay.dmg" + echo "d2" > "$dest/MeowDisplayReceiver.dmg" + echo "f1" > "$dest/appcast.xml" + echo "f2" > "$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}" + ;; + *"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 v2.1.0"*"--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" ;; + *) + 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")) + 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']}", + "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, "PARTIAL ROLLBACK FAILURE: revert_latest=true, revert_draft=false" + 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 | --preflight)" + end + + def test_reconcile_script_rejects_overrides + 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_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, <<~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 = { + "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 + case "$*" in + *"releases/latest"*) echo 'v2.1.0' ;; + *"commits/v2.1.0"*) echo '#{VALID_SHA}' ;; + *) exit 0 ;; + esac + SH + FileUtils.chmod(0o755, mock_gh) + + 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 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 ;; + *"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"}}}' + ;; + *) 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_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_manifest, "publish website-manifest STATE was not called" + assert idx_start < idx_mac, "publish start must precede mac-published" + 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