diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f8c8e0dc..60ff5790 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,7 @@ on: required: true type: string failed_recovery_run_id: - description: Previous failed workflow_dispatch recovery Release run ID (31427093503) chained to the same immutable source + description: Direct predecessor workflow_dispatch Release run ID (31441693191) whose publish job failed for the same immutable source required: true type: string @@ -71,7 +71,7 @@ jobs: read -r remote_main remote_ref < <(git ls-remote --exit-code origin refs/heads/main) remote_main="$(printf '%s' "$remote_main" | tr '[:upper:]' '[:lower:]')" test "$remote_ref" = "refs/heads/main" - predecessor_tooling_sha="6162466834bbabb8a16a2c08808e03a53c2b22b6" + predecessor_tooling_sha="924bc1102a9343e14c3beea2a3622b5d92ebff13" if [[ "$GITHUB_EVENT_NAME" = "push" ]]; then test -z "$FAILED_RUN_ID" test -z "$FAILED_RECOVERY_RUN_ID" @@ -84,7 +84,7 @@ jobs: [[ "$FAILED_RUN_ID" =~ ^[1-9][0-9]*$ ]] [[ "$FAILED_RECOVERY_RUN_ID" =~ ^[1-9][0-9]*$ ]] test "$FAILED_RUN_ID" = "31412976593" - test "$FAILED_RECOVERY_RUN_ID" = "31427093503" + test "$FAILED_RECOVERY_RUN_ID" = "31441693191" test "$tooling_sha" = "$remote_main" recovery_root="$RUNNER_TEMP/opentake-release-recovery-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" mkdir -p "$recovery_root/tooling" @@ -1009,6 +1009,8 @@ jobs: RELEASE_TOOLING_SHA: ${{ needs.validate.outputs.tooling_sha }} RELEASE_VERSION: ${{ needs.validate.outputs.version }} NOTES_PATH: ${{ needs.validate.outputs.notes_path }} + ROOT_FAILED_RUN_ID: ${{ github.event_name == 'workflow_dispatch' && inputs.failed_run_id || '' }} + PREDECESSOR_RUN_ID: ${{ github.event_name == 'workflow_dispatch' && inputs.failed_recovery_run_id || '' }} PYTHONDONTWRITEBYTECODE: '1' steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 @@ -1037,6 +1039,17 @@ jobs: "$RUNNER_TEMP"/opentake-release-*) ;; *) exit 1 ;; esac + mkdir -p "$publish_root/tooling" + if ! git cat-file -e "${RELEASE_TOOLING_SHA}^{commit}" 2>/dev/null; then + git fetch --no-tags --depth=1 origin "$RELEASE_TOOLING_SHA" + fi + test "$(git rev-parse "${RELEASE_TOOLING_SHA}^{commit}")" = "$RELEASE_TOOLING_SHA" + git cat-file blob "$RELEASE_TOOLING_SHA:scripts/check_release_workflow.py" \ + > "$publish_root/tooling/check_release_workflow.py" + git cat-file blob "$RELEASE_TOOLING_SHA:scripts/workflow_yaml.py" \ + > "$publish_root/tooling/workflow_yaml.py" + test -s "$publish_root/tooling/check_release_workflow.py" + test -s "$publish_root/tooling/workflow_yaml.py" printf 'PUBLISH_ROOT=%s\n' "$publish_root" >> "$GITHUB_ENV" - name: Install Minisign verifier @@ -1532,6 +1545,15 @@ jobs: notes_sha="$RELEASE_TOOLING_SHA" fi test -s "$PUBLISH_ROOT/release-body.md" + recovery_chain="normal tag push" + if [[ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]]; then + test "$ROOT_FAILED_RUN_ID" = "31412976593" + test "$PREDECESSOR_RUN_ID" = "31441693191" + recovery_chain="root run $ROOT_FAILED_RUN_ID; direct predecessor run $PREDECESSOR_RUN_ID" + else + test -z "$ROOT_FAILED_RUN_ID" + test -z "$PREDECESSOR_RUN_ID" + fi cat >> "$PUBLISH_ROOT/release-body.md" < "$PUBLISH_ROOT/remote-tag-before-draft.txt" - python3 scripts/check_release_workflow.py resolve-remote-tag \ + python3 -B "$PUBLISH_ROOT/tooling/check_release_workflow.py" resolve-remote-tag \ --input "$PUBLISH_ROOT/remote-tag-before-draft.txt" \ --tag "$RELEASE_TAG" --sha "$RELEASE_SHA" @@ -1580,7 +1603,7 @@ jobs: gh api graphql \ -f query="$query" -f owner="$owner" -f name="$repository" -f tag="$RELEASE_TAG" \ > "$PUBLISH_ROOT/existing-release-graphql.json" - python3 scripts/check_release_workflow.py resolve-release-state \ + python3 -B "$PUBLISH_ROOT/tooling/check_release_workflow.py" resolve-release-state \ --input "$PUBLISH_ROOT/existing-release-graphql.json" \ --tag "$RELEASE_TAG" \ --sha "$RELEASE_SHA" \ @@ -1588,9 +1611,11 @@ jobs: action="$(jq -r '.action' "$PUBLISH_ROOT/release-state.json")" case "$action" in create) + # The existing, independently verified tag binds the source commit. + # Passing target_commitish would require a Workflows-write token when + # release tooling changed workflow files; GITHUB_TOKEN cannot have it. gh release create "$RELEASE_TAG" \ --verify-tag \ - --target "$RELEASE_SHA" \ --title "OpenTake $RELEASE_VERSION" \ --notes-file "$PUBLISH_ROOT/release-body.md" \ --draft --prerelease --latest=false @@ -1605,13 +1630,12 @@ jobs: fi test "$(jq -r '.prerelease' "$PUBLISH_ROOT/existing-draft-rest.json")" = "true" test "$(jq -r '.tag_name' "$PUBLISH_ROOT/existing-draft-rest.json")" = "$RELEASE_TAG" - test "$(jq -r '.target_commitish' "$PUBLISH_ROOT/existing-draft-rest.json" | tr '[:upper:]' '[:lower:]')" = "$RELEASE_SHA" + jq -e '.target_commitish | (type == "string" and length > 0)' "$PUBLISH_ROOT/existing-draft-rest.json" >/dev/null jq -e 'all(.assets[]; (.id | type) == "number")' "$PUBLISH_ROOT/existing-draft-rest.json" >/dev/null while read -r asset_id; do gh api --method DELETE "repos/$GITHUB_REPOSITORY/releases/assets/$asset_id" done < <(jq -r '.assets[].id' "$PUBLISH_ROOT/existing-draft-rest.json") gh release edit "$RELEASE_TAG" \ - --target "$RELEASE_SHA" \ --title "OpenTake $RELEASE_VERSION" \ --notes-file "$PUBLISH_ROOT/release-body.md" \ --draft --prerelease --latest=false @@ -1633,7 +1657,7 @@ jobs: gh api graphql \ -f query="$query" -f owner="$owner" -f name="$repository" -f tag="$RELEASE_TAG" \ > "$PUBLISH_ROOT/draft-release-graphql.json" - python3 scripts/check_release_workflow.py resolve-release-state \ + python3 -B "$PUBLISH_ROOT/tooling/check_release_workflow.py" resolve-release-state \ --input "$PUBLISH_ROOT/draft-release-graphql.json" \ --tag "$RELEASE_TAG" \ --sha "$RELEASE_SHA" \ @@ -1681,7 +1705,7 @@ jobs: git ls-remote --exit-code origin \ "refs/tags/$RELEASE_TAG" "refs/tags/$RELEASE_TAG^{}" \ > "$PUBLISH_ROOT/remote-tag-before-publication.txt" - python3 scripts/check_release_workflow.py resolve-remote-tag \ + python3 -B "$PUBLISH_ROOT/tooling/check_release_workflow.py" resolve-remote-tag \ --input "$PUBLISH_ROOT/remote-tag-before-publication.txt" \ --tag "$RELEASE_TAG" --sha "$RELEASE_SHA" @@ -1706,6 +1730,28 @@ jobs: - name: Verify public release through API and checksums run: | set -euo pipefail + git ls-remote --exit-code origin \ + "refs/tags/$RELEASE_TAG" "refs/tags/$RELEASE_TAG^{}" \ + > "$PUBLISH_ROOT/remote-tag-after-publication.txt" + python3 -B "$PUBLISH_ROOT/tooling/check_release_workflow.py" resolve-remote-tag \ + --input "$PUBLISH_ROOT/remote-tag-after-publication.txt" \ + --tag "$RELEASE_TAG" --sha "$RELEASE_SHA" + + owner="${GITHUB_REPOSITORY%%/*}" + repository="${GITHUB_REPOSITORY#*/}" + query='query($owner:String!,$name:String!,$tag:String!){repository(owner:$owner,name:$name){release(tagName:$tag){databaseId tagName tagCommit{oid} isDraft isPrerelease releaseAssets(first:100){nodes{id name size} pageInfo{hasNextPage}}}}}' + gh api graphql \ + -f query="$query" -f owner="$owner" -f name="$repository" -f tag="$RELEASE_TAG" \ + > "$PUBLISH_ROOT/public-release-graphql.json" + python3 -B "$PUBLISH_ROOT/tooling/check_release_workflow.py" resolve-public-release-state \ + --input "$PUBLISH_ROOT/public-release-graphql.json" \ + --tag "$RELEASE_TAG" \ + --sha "$RELEASE_SHA" \ + --output "$PUBLISH_ROOT/public-release-state.json" + jq -r '.asset_names[]' "$PUBLISH_ROOT/public-release-state.json" | LC_ALL=C sort > "$PUBLISH_ROOT/public-graphql-assets.txt" + cmp "$PUBLISH_ROOT/expected-assets.txt" "$PUBLISH_ROOT/public-graphql-assets.txt" + jq -e '.asset_sizes | length == 17 and all(. > 0)' "$PUBLISH_ROOT/public-release-state.json" >/dev/null + gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG" > "$PUBLISH_ROOT/public-release.json" python3 - <<'PY' import json @@ -1714,15 +1760,15 @@ jobs: root = Path(os.environ["PUBLISH_ROOT"]) release = json.loads((root / "public-release.json").read_text(encoding="utf-8")) - expected_sha = os.environ["RELEASE_SHA"] expected_names = set((root / "expected-assets.txt").read_text(encoding="utf-8").splitlines()) actual_names = {asset["name"] for asset in release.get("assets", [])} if release.get("draft") is not False or release.get("prerelease") is not True: raise SystemExit("release was not published as a prerelease") if release.get("tag_name") != os.environ["RELEASE_TAG"]: raise SystemExit("public release tag mismatch") - if release.get("target_commitish") != expected_sha: - raise SystemExit("public release target SHA mismatch") + target_commitish = release.get("target_commitish") + if not isinstance(target_commitish, str) or not target_commitish: + raise SystemExit("public release target_commitish schema is malformed") if expected_names != actual_names: raise SystemExit(f"public release assets mismatch: {sorted(actual_names)}") PY diff --git a/docs/releases/1.0.0-beta.4.md b/docs/releases/1.0.0-beta.4.md index a0263b10..922f21cd 100644 --- a/docs/releases/1.0.0-beta.4.md +++ b/docs/releases/1.0.0-beta.4.md @@ -36,21 +36,23 @@ 此时 product source SHA 与 release tooling SHA 必须相同,并等于当前远端 `main` HEAD。若这个 tag push 已通过 source、质量与 macOS 门禁,却仅在 Windows 的 checksum-pinned FFmpeg sidecar provision 步骤失败,则该 `workflow_dispatch` 恢复链以 root `failed_run_id`(固定为 -`failed_run_id=31412976593`)绑定原 tag-push run。首次恢复 -run `31427093503` 又仅在 Windows 原生构建的 PowerShell → `.cmd` → Tauri config argv 边界失败; -后续一次性恢复必须同时显式提供 `failed_run_id=31412976593` 与 -`failed_recovery_run_id=31427093503`,缺少或替换任一 ID 都 fail closed。product source 仍是原不可变 -tag SHA `2c4efdff9d2587c90cbcac0919f9d1d333d67d6a`;上一恢复的 release tooling 固定为 -`6162466834bbabb8a16a2c08808e03a53c2b22b6`,当前 release tooling 则固定为当前远端 `main` 的 +`failed_run_id=31412976593`)绑定原 tag-push run。历史恢复 run `31427093503` 仅在 Windows +PowerShell → `.cmd` → Tauri config argv 边界失败;其后的直接前驱 run `31441693191` +已通过 validate、质量、macOS 与 Windows 门禁,只在 publish 的 `Create or refresh draft prerelease` +步骤失败。下一次恢复必须同时显式提供 `failed_run_id=31412976593` 与 +`failed_recovery_run_id=31441693191`,缺少或替换任一 ID 都 fail closed。product source 仍是原不可变 +tag SHA `2c4efdff9d2587c90cbcac0919f9d1d333d67d6a`;直接前驱的 release tooling 固定为 +`924bc1102a9343e14c3beea2a3622b5d92ebff13`,当前 release tooling 则固定为当前远端 `main` 的 `github.workflow_sha`,并从该 Git commit 的 blob 精确提取,绝不从 raw HTTP URL 下载执行。 两条路径都不创建、移动或删除 tag。Cargo、Tauri 与 Web 版本均为 `1.0.0-beta.4`,Windows WiX 安装器版本为 `1.0.0.4`,并由独立发布合约 fail closed 校验。 1. validate job 解析 immutable source SHA;恢复路径先逐项核对 root run 的五个 job 与 Windows - sidecar 失败 step,再独立核对上一恢复 run 的五个 job、Windows config argv 失败 step 以及唯一 - macOS artifact 的 ID、大小、digest、未过期状态与 run/repository/source 绑定。两个 GitHub compare - 响应必须分别证明 source → `61624668…` 与 `61624668…` → 当前远端 `main`,不接受互换、跳段或 - 任意失败 run。随后验证版本、WiX、本文档、prerelease 语义和洁净 checkout;质量门禁执行依赖 + sidecar 失败 step,再独立核对直接前驱 run 的五个 exact job(包括成功的 validate job)、 + publish 第 15 步权限失败以及 macOS/Windows 两个 artifact 的 ID、大小、digest、未过期状态与 + run/repository/source 绑定。两个 GitHub compare 响应必须分别证明 source → `924bc110…` 与 + `924bc110…` → 当前远端 `main`,不接受互换、跳段或任意失败 run。直接前驱的 validate 成功 + 也是它已核验上一恢复链的证据。随后验证版本、WiX、本文档、prerelease 语义和洁净 checkout;质量门禁执行依赖 锁定安装、audit、格式、clippy、workspace/Web 测试及 workflow/tooling commit 合同。 2. macOS ARM64 和 Windows x64 都要求非空 `TAURI_SIGNING_PRIVATE_KEY` 与 `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`。构建产生平台安装包、Tauri updater package、每个 @@ -61,8 +63,12 @@ tag SHA `2c4efdff9d2587c90cbcac0919f9d1d333d67d6a`;上一恢复的 release too 3. publish 从 exact SHA 的配置读取内置 updater 公钥,用独立 Minisign 工具验证 package 与 attestation;manifest 将版本、tag、source SHA、平台、资产名称、大小和 SHA-256 绑定。只有 draft 的十七项精确资产上传、下载回读、名称/大小、签名和 `SHA256SUMS` 全部相符后才公开。 - 恢复发布的公开 release notes 从精确 release tooling commit 读取,并同时记录 source、tooling、 - notes commit 与 Actions run,避免把旧不可变 tag 误写成当前 `main` HEAD。 + 前驱的 403 不是 `contents: write` 缺失:当 `target_commitish` 指向与默认分支之间修改过 + workflow 文件的旧提交时,GitHub 要求 Actions `GITHUB_TOKEN` 无法获得的 Workflows write 权限。 + 因 tag 已存在且经独立验证,create/refresh 都不再传 `--target`;REST `target_commitish` 只做非空 + schema 校验,不作为 source oracle。发布前后的 remote tag peel 与 GraphQL `tagCommit.oid` 才必须始终 + 精确等于 source SHA。恢复发布的公开 release notes 从精确 release tooling commit 读取,并同时记录 + root/direct-predecessor run IDs、source、tooling、notes commit 与当前 Actions run。 Updater 私钥不写入 checkout、receipt、manifest、日志或发布 artifact;缺少任一 secret 时流程 失败,绝不退化为未签名 updater。 diff --git a/scripts/check_release_workflow.py b/scripts/check_release_workflow.py index 392a5a16..a0b841ae 100644 --- a/scripts/check_release_workflow.py +++ b/scripts/check_release_workflow.py @@ -169,7 +169,7 @@ } APPROVED_COMPLEX_RUN_SHA256 = { - ("validate", "Validate tag, source SHA, versions, and notes"): "57f984fba1ddd7171444f54b3f086b86b563e55ac7b298b6e9271446bbfdb54f", + ("validate", "Validate tag, source SHA, versions, and notes"): "d451f703d50cd202d583cd42cc631927bfb97ea64aa6988bca5210dc40509379", ("validate", "Reassert exact source after validation"): "953657d26d2eda8490c18e7030c66ddb19aba64a5c8b19808da9a853fd1bfdd2", ("quality", "Assert exact checked-out SHA"): "ff0b148eecdf8603712586a6c4a05e752df0b36b5c97a366760f6cba10e58ddd", ("quality", "Free disk space"): "5848415c4d0e696f46965d62a2e17c8b7a0dd45ae600d28102af0b04108d9bf6", @@ -197,28 +197,28 @@ ("windows_x64", "Create and sign Windows updater attestations"): "40c7f2ea8696db15adf49a688a446637df5b1625fe0b39de32502e66af71f2c6", ("windows_x64", "Create Windows exact-SHA receipt"): "0072244797bdcbe7a26f57a5a83e5c2ba880ffa96499f18f4c5affd30b0e54f4", ("publish", "Assert exact checked-out SHA"): "b1cd768e31e2924c14421c62357ae200ddee50b2a6c2ddc24618717a3c876267", - ("publish", "Initialize isolated publish root"): "ab0b76ab253b0497067e6d8650a0fd6b37fb3a0ac72b2ff11d1738a88b46c2dd", + ("publish", "Initialize isolated publish root"): "79b191e8b5c59e59d847c59edb73d17069736c54bc1cb56f2588051ab3e3f433", ("publish", "Install Minisign verifier"): "444027a1ab2942d223b3e16e286d2de585fdc1175e94e093b012b13aefea2416", ("publish", "Stage and verify the exact release payload"): "4cdd6d245891e535e6f11a29371b4b0c7e343702f36d37f56262387f3523b534", ("publish", "Verify updater signatures against embedded public key"): "d0da4c84101149e7853b764db8f770d25f61b4fa654f9f927fd813bd22604fe5", ("publish", "Write and verify tag-specific updater manifest"): "faef05e038dd082d81e1ec12a8c6f933575e8766e73df1b4ecb28cb794156579", ("publish", "Create and verify SHA256SUMS"): "67975ac408cc96209261e8c397402acaac47e1a689d7572d5634d3b8dabd66d8", - ("publish", "Prepare release notes with provenance"): "3f8dc1f9ab2680cb4d0c7989830cac37e536844bdbbee05f8955dfb40673a271", + ("publish", "Prepare release notes with provenance"): "25aa357cc5ce5620686b2b66155dfaf4d967c3977eb6cb5dcad628449a161f71", ("publish", "Reassert exact source before draft mutation"): "953657d26d2eda8490c18e7030c66ddb19aba64a5c8b19808da9a853fd1bfdd2", - ("publish", "Revalidate remote tag before draft mutation"): "2eac9a1203d96969b545c9447b9637c8ad16689d2704b32c7e710e9ae4bff47c", - ("publish", "Create or refresh draft prerelease"): "e3cee76806b604359715ec91cf1a7c6d7c8919e05dd5b5c99f77b98686debc6b", - ("publish", "Verify draft target and exact assets"): "16b9ac363a7f10417e01332035e2419341f762a46afb586fd2f214641142d0fe", - ("publish", "Revalidate remote tag before publication"): "89cd8010bf65a4a3d7b85b2666e13eeddc4554e24e53eb700592bd6dc77cea6f", + ("publish", "Revalidate remote tag before draft mutation"): "cd6a3662a7a43dbd837170c734a41f3832f258b14468b3bdbee36b1e42d7a75c", + ("publish", "Create or refresh draft prerelease"): "f636c39fe330986ae5fe717a5005554fd11a6a9df3e1b8303c4e42c5263b39a7", + ("publish", "Verify draft target and exact assets"): "99620971b48abd650be72d91bcdd6f2af47ba56446d124c105580ee79bc5d40e", + ("publish", "Revalidate remote tag before publication"): "502a7ccd2c1d6b81642aa7824c168521caaefd5d6118f4e9cdafa359ce57ce3c", ("publish", "Reassert exact source before publication"): "953657d26d2eda8490c18e7030c66ddb19aba64a5c8b19808da9a853fd1bfdd2", - ("publish", "Verify public release through API and checksums"): "bdd6671280f89cfe71fad7c0eb9fecb24a6f66931910ee5075c5c2e9e96f52fe", + ("publish", "Verify public release through API and checksums"): "136ae6eb7805f2f0e59f11bc1624860f51defa3d0449aab4b519a2506a554522", } APPROVED_JOB_SHA256 = { - "validate": "e0749abbec85f8a9dade905f4e98e903350168a79b2c972dcc5b839070f4e9c5", + "validate": "eb09b22d3681439d20d6a2e793d81b18aa2901e0d5f646dbcc9b809226954b46", "quality": "a2947370289ebd299042159fbe8fd046f7fedf72b47037d58b4398ed8e85baee", "macos_arm64": "1785d765c96278190c25e312c9e610070619e17b7b2b0d922f0bd234501df525", "windows_x64": "63bd70d85e40a3f1177e9059d4674d7f93d4502181fc378f7706e839af953378", - "publish": "c7d84471185df270f1d30129b936dccc8cb6c1069020e34924037be2976425ec", + "publish": "ea3fe6d18a94c0850d3ac7f21c4e23fb8fcf572189f77f659e5b34eab776bd85", } EXPECTED_RECOVERY_JOB_CONCLUSIONS = { @@ -303,64 +303,54 @@ ("Complete job", "completed", "success"), ) EXPECTED_RECOVERY_WINDOWS_STEP_NUMBERS = (*range(1, 25), 46, 47, 48, 49) -EXPECTED_FAILED_RECOVERY_RUN_ID = 31427093503 +EXPECTED_FAILED_RECOVERY_RUN_ID = 31441693191 EXPECTED_FAILED_RECOVERY_TAG = "v1.0.0-beta.4" EXPECTED_FAILED_RECOVERY_SOURCE_SHA = ( "2c4efdff9d2587c90cbcac0919f9d1d333d67d6a" ) EXPECTED_FAILED_RECOVERY_TOOLING_SHA = ( - "6162466834bbabb8a16a2c08808e03a53c2b22b6" + "924bc1102a9343e14c3beea2a3622b5d92ebff13" ) -EXPECTED_FAILED_RECOVERY_ARTIFACT_ID = 9077851536 -EXPECTED_FAILED_RECOVERY_ARTIFACT_SIZE = 142001290 -EXPECTED_FAILED_RECOVERY_ARTIFACT_DIGEST = ( - "sha256:b62a8270268087d91bc4f8d2c8aac5d2ae2fe2cf32ec21d95bd2fd46787df612" -) -EXPECTED_FAILED_RECOVERY_WINDOWS_STEPS = ( - *EXPECTED_RECOVERY_WINDOWS_STEPS[:8], - ( - "Provision checksum-pinned Windows FFmpeg sidecars", - "completed", - "success", - ), - ("Verify pinned sidecar supply", "completed", "success"), - ("Cache Cargo dependencies", "completed", "success"), - ("Install locked Web dependencies", "completed", "success"), - ("Rust workspace clippy", "completed", "success"), - ("Rust workspace tests", "completed", "success"), - ("Web editor behavior suite", "completed", "success"), - ("Minimal-feature Tauri clippy", "completed", "success"), - ("Web production build", "completed", "success"), - ("Reassert exact source before Windows build", "completed", "success"), - ( - "Build native MSI, NSIS, and signed updater artifacts", - "completed", - "failure", - ), - ( - "Install NSIS and smoke installed app, sidecars, and updater artifacts", - "completed", - "skipped", - ), - ("Reassert exact source after Windows packaging", "completed", "skipped"), - ("Create and sign Windows updater attestations", "completed", "skipped"), - ("Create Windows exact-SHA receipt", "completed", "skipped"), - ( - "Upload exact-SHA Windows packages and updater signatures", - "completed", - "skipped", - ), - ("Post Cache Cargo dependencies", "completed", "skipped"), - ( - f"Post Run actions/setup-node@{PINNED_ACTIONS['actions/setup-node']}", - "completed", - "skipped", - ), +EXPECTED_FAILED_RECOVERY_JOB_CONCLUSIONS = { + "Validate immutable release source": "success", + "Release quality gates": "success", + "macOS ARM64 app and DMG": "success", + "Windows x64 MSI and NSIS": "success", + "Publish verified GitHub prerelease": "failure", +} +EXPECTED_FAILED_RECOVERY_JOB_IDS = { + "Validate immutable release source": 93627558806, + "Release quality gates": 93627597989, + "macOS ARM64 app and DMG": 93627597999, + "Windows x64 MSI and NSIS": 93627598006, + "Publish verified GitHub prerelease": 93637315893, +} +EXPECTED_FAILED_RECOVERY_PUBLISH_STEPS = ( + ("Set up job", "completed", "success"), ( - f"Post Run pnpm/action-setup@{PINNED_ACTIONS['pnpm/action-setup']}", + f"Run actions/checkout@{PINNED_ACTIONS['actions/checkout']}", "completed", "success", ), + ("Assert exact checked-out SHA", "completed", "success"), + ("Initialize isolated publish root", "completed", "success"), + ("Install Minisign verifier", "completed", "success"), + ("Download macOS artifact", "completed", "success"), + ("Download Windows artifact", "completed", "success"), + ("Stage and verify the exact release payload", "completed", "success"), + ("Verify updater signatures against embedded public key", "completed", "success"), + ("Write and verify tag-specific updater manifest", "completed", "success"), + ("Create and verify SHA256SUMS", "completed", "success"), + ("Prepare release notes with provenance", "completed", "success"), + ("Reassert exact source before draft mutation", "completed", "success"), + ("Revalidate remote tag before draft mutation", "completed", "success"), + ("Create or refresh draft prerelease", "completed", "failure"), + ("Upload the exact payload to the draft", "completed", "skipped"), + ("Verify draft target and exact assets", "completed", "skipped"), + ("Revalidate remote tag before publication", "completed", "skipped"), + ("Reassert exact source before publication", "completed", "skipped"), + ("Publish verified prerelease", "completed", "skipped"), + ("Verify public release through API and checksums", "completed", "skipped"), ( f"Post Run actions/checkout@{PINNED_ACTIONS['actions/checkout']}", "completed", @@ -368,14 +358,19 @@ ), ("Complete job", "completed", "success"), ) -EXPECTED_FAILED_RECOVERY_WINDOWS_STEP_NUMBERS = ( - *range(1, 25), - 45, - 46, - 47, - 48, - 49, -) +EXPECTED_FAILED_RECOVERY_PUBLISH_STEP_NUMBERS = (*range(1, 22), 42, 43) +EXPECTED_FAILED_RECOVERY_ARTIFACTS = { + f"opentake-windows-x64-{EXPECTED_FAILED_RECOVERY_SOURCE_SHA}": { + "id": 9084161184, + "size_in_bytes": 563738459, + "digest": "sha256:fab634decf8ee1d74432bd003be49474c5790b97e45ad2c81d23aa3bc58613db", + }, + f"opentake-macos-arm64-{EXPECTED_FAILED_RECOVERY_SOURCE_SHA}": { + "id": 9083298193, + "size_in_bytes": 142002569, + "digest": "sha256:2c5a1ab7f80e59f812b47a37e316c61bc7feedc7b85dd0e20f94a328086ca0b7", + }, +} class ReleaseStateError(ValueError): @@ -570,7 +565,7 @@ def validate_failed_recovery_run( total_count = jobs.get("total_count") entries = jobs.get("jobs") - expected_job_count = len(EXPECTED_RECOVERY_JOB_CONCLUSIONS) + expected_job_count = len(EXPECTED_FAILED_RECOVERY_JOB_CONCLUSIONS) if ( total_count != expected_job_count or not isinstance(entries, list) @@ -582,13 +577,14 @@ def validate_failed_recovery_run( if ( not all(isinstance(name, str) for name in names) or len(set(names)) != expected_job_count - or set(names) != set(EXPECTED_RECOVERY_JOB_CONCLUSIONS) + or set(names) != set(EXPECTED_FAILED_RECOVERY_JOB_CONCLUSIONS) ): raise RecoveryRunError("failed recovery job set is not exact") by_name = {str(entry["name"]): entry for entry in entries} - for name, conclusion in EXPECTED_RECOVERY_JOB_CONCLUSIONS.items(): + for name, conclusion in EXPECTED_FAILED_RECOVERY_JOB_CONCLUSIONS.items(): entry = by_name[name] expected_fields = { + "id": EXPECTED_FAILED_RECOVERY_JOB_IDS[name], "run_id": EXPECTED_FAILED_RECOVERY_RUN_ID, "run_attempt": 1, "head_sha": EXPECTED_FAILED_RECOVERY_TOOLING_SHA, @@ -602,28 +598,28 @@ def validate_failed_recovery_run( f"failed recovery job outcome is not whitelisted: {name}" ) - windows_steps = by_name["Windows x64 MSI and NSIS"].get("steps") - if not isinstance(windows_steps, list) or not all( - isinstance(step, dict) for step in windows_steps + publish_steps = by_name["Publish verified GitHub prerelease"].get("steps") + if not isinstance(publish_steps, list) or not all( + isinstance(step, dict) for step in publish_steps ): raise RecoveryRunError( - "failed recovery Windows steps are missing or malformed" + "failed recovery publish steps are missing or malformed" ) - step_numbers = tuple(step.get("number") for step in windows_steps) + step_numbers = tuple(step.get("number") for step in publish_steps) step_outcomes = tuple( (step.get("name"), step.get("status"), step.get("conclusion")) - for step in windows_steps + for step in publish_steps ) if ( any( not isinstance(number, int) or isinstance(number, bool) for number in step_numbers ) - or step_numbers != EXPECTED_FAILED_RECOVERY_WINDOWS_STEP_NUMBERS - or step_outcomes != EXPECTED_FAILED_RECOVERY_WINDOWS_STEPS + or step_numbers != EXPECTED_FAILED_RECOVERY_PUBLISH_STEP_NUMBERS + or step_outcomes != EXPECTED_FAILED_RECOVERY_PUBLISH_STEPS ): raise RecoveryRunError( - "failed recovery is not the exact Windows config argv failure" + "failed recovery is not the exact draft-creation permission failure" ) artifact_total_count = artifacts.get("total_count") @@ -631,24 +627,20 @@ def validate_failed_recovery_run( if ( not isinstance(artifact_total_count, int) or isinstance(artifact_total_count, bool) - or artifact_total_count != 1 + or artifact_total_count != len(EXPECTED_FAILED_RECOVERY_ARTIFACTS) or not isinstance(artifact_entries, list) - or len(artifact_entries) != 1 - or not isinstance(artifact_entries[0], dict) + or len(artifact_entries) != len(EXPECTED_FAILED_RECOVERY_ARTIFACTS) + or not all(isinstance(entry, dict) for entry in artifact_entries) ): raise RecoveryRunError("failed recovery artifact list is not exact") - artifact = artifact_entries[0] - artifact_fields = { - "id": EXPECTED_FAILED_RECOVERY_ARTIFACT_ID, - "name": f"opentake-macos-arm64-{EXPECTED_FAILED_RECOVERY_SOURCE_SHA}", - "size_in_bytes": EXPECTED_FAILED_RECOVERY_ARTIFACT_SIZE, - "digest": EXPECTED_FAILED_RECOVERY_ARTIFACT_DIGEST, - } - if artifact.get("expired") is not False or any( - artifact.get(field) != value for field, value in artifact_fields.items() + artifact_names = [entry.get("name") for entry in artifact_entries] + if ( + not all(isinstance(name, str) for name in artifact_names) + or len(set(artifact_names)) != len(EXPECTED_FAILED_RECOVERY_ARTIFACTS) + or set(artifact_names) != set(EXPECTED_FAILED_RECOVERY_ARTIFACTS) ): - raise RecoveryRunError("failed recovery macOS artifact is not exact") - artifact_run = artifact.get("workflow_run") + raise RecoveryRunError("failed recovery artifact set is not exact") + artifacts_by_name = {str(entry["name"]): entry for entry in artifact_entries} expected_artifact_run = { "id": EXPECTED_FAILED_RECOVERY_RUN_ID, "repository_id": 1275692189, @@ -656,11 +648,24 @@ def validate_failed_recovery_run( "head_branch": "main", "head_sha": EXPECTED_FAILED_RECOVERY_TOOLING_SHA, } - if not isinstance(artifact_run, dict) or any( - artifact_run.get(field) != value - for field, value in expected_artifact_run.items() - ): - raise RecoveryRunError("failed recovery artifact provenance is not exact") + for name, expected_fields in EXPECTED_FAILED_RECOVERY_ARTIFACTS.items(): + artifact = artifacts_by_name[name] + artifact_run = artifact.get("workflow_run") + if ( + artifact.get("expired") is not False + or any( + artifact.get(field) != value + for field, value in expected_fields.items() + ) + or not isinstance(artifact_run, dict) + or any( + artifact_run.get(field) != value + for field, value in expected_artifact_run.items() + ) + ): + raise RecoveryRunError( + f"failed recovery artifact provenance is not exact: {name}" + ) base = tooling_comparison.get("base_commit") merge_base = tooling_comparison.get("merge_base_commit") @@ -795,6 +800,74 @@ def resolve_release_state( } +def resolve_public_release_state( + payload: dict[str, object], expected_tag: str, expected_sha: str +) -> dict[str, object]: + """Validate one published GraphQL release against its immutable tag commit.""" + if re.fullmatch(r"[0-9a-f]{40}", expected_sha) is None: + raise ReleaseStateError("expected source SHA must be lowercase 40-hex") + if payload.get("errors"): + raise ReleaseStateError("GraphQL public release lookup returned errors") + data = payload.get("data") + repository = data.get("repository") if isinstance(data, dict) else None + release = repository.get("release") if isinstance(repository, dict) else None + if not isinstance(release, dict): + raise ReleaseStateError("GraphQL public release lookup is malformed") + if release.get("tagName") != expected_tag: + raise ReleaseStateError("GraphQL public release tag does not match") + tag_commit = release.get("tagCommit") + target = tag_commit.get("oid") if isinstance(tag_commit, dict) else None + if not isinstance(target, str) or target.lower() != expected_sha: + raise ReleaseStateError( + "GraphQL public release tag commit does not match source SHA" + ) + if release.get("isDraft") is not False or release.get("isPrerelease") is not True: + raise ReleaseStateError("GraphQL release is not a published prerelease") + release_id = release.get("databaseId") + if ( + not isinstance(release_id, int) + or isinstance(release_id, bool) + or release_id <= 0 + ): + raise ReleaseStateError("GraphQL public release has no numeric database ID") + connection = release.get("releaseAssets") + if not isinstance(connection, dict): + raise ReleaseStateError("GraphQL public release has no asset connection") + page_info = connection.get("pageInfo") + if not isinstance(page_info, dict) or page_info.get("hasNextPage") is not False: + raise ReleaseStateError("GraphQL public release asset list is incomplete") + nodes = connection.get("nodes") + if not isinstance(nodes, list) or len(nodes) != 17: + raise ReleaseStateError("GraphQL public release must contain 17 assets") + + asset_node_ids: list[str] = [] + asset_names: list[str] = [] + asset_sizes: list[int] = [] + for asset in nodes: + if not isinstance(asset, dict): + raise ReleaseStateError("GraphQL public release asset is malformed") + asset_id = asset.get("id") + name = asset.get("name") + size = asset.get("size") + if not isinstance(asset_id, str) or not asset_id: + raise ReleaseStateError("GraphQL public release asset has no node ID") + if not isinstance(name, str) or not name: + raise ReleaseStateError("GraphQL public release asset has no name") + if not isinstance(size, int) or isinstance(size, bool) or size <= 0: + raise ReleaseStateError("GraphQL public release asset has invalid size") + asset_node_ids.append(asset_id) + asset_names.append(name) + asset_sizes.append(size) + if len(set(asset_node_ids)) != 17 or len(set(asset_names)) != 17: + raise ReleaseStateError("GraphQL public release assets are not unique") + return { + "release_id": release_id, + "asset_node_ids": asset_node_ids, + "asset_names": asset_names, + "asset_sizes": asset_sizes, + } + + def _as_mapping(value: object) -> dict[str, object] | None: return value if isinstance(value, dict) else None @@ -1056,7 +1129,7 @@ def validate_workflow(workflow: str) -> list[str]: or failed_recovery_run_input is None or set(failed_recovery_run_input) != {"description", "required", "type"} or failed_recovery_run_input.get("description") - != "Previous failed workflow_dispatch recovery Release run ID (31427093503) chained to the same immutable source" + != "Direct predecessor workflow_dispatch Release run ID (31441693191) whose publish job failed for the same immutable source" or failed_recovery_run_input.get("required") is not True or failed_recovery_run_input.get("type") != "string" ): @@ -1344,9 +1417,9 @@ def validate_workflow(workflow: str) -> list[str]: '[[ "$FAILED_RUN_ID" =~ ^[1-9][0-9]*$ ]]', '[[ "$FAILED_RECOVERY_RUN_ID" =~ ^[1-9][0-9]*$ ]]', 'test "$FAILED_RUN_ID" = "31412976593"', - 'test "$FAILED_RECOVERY_RUN_ID" = "31427093503"', + 'test "$FAILED_RECOVERY_RUN_ID" = "31441693191"', 'test "$tooling_sha" = "$remote_main"', - 'predecessor_tooling_sha="6162466834bbabb8a16a2c08808e03a53c2b22b6"', + 'predecessor_tooling_sha="924bc1102a9343e14c3beea2a3622b5d92ebff13"', 'if ! git cat-file -e "${tooling_sha}^{commit}" 2>/dev/null; then', 'git fetch --no-tags --depth=1 origin "$tooling_sha"', 'test "$(git rev-parse "${tooling_sha}^{commit}")" = "$tooling_sha"', @@ -1966,7 +2039,14 @@ def validate_workflow(workflow: str) -> list[str]: if publish.get("permissions") != {"contents": "write"}: errors.append("publish-only contents write permission") publish_env = _as_mapping(publish.get("env")) - if publish_env is None or publish_env.get("PYTHONDONTWRITEBYTECODE") != "1": + if ( + publish_env is None + or publish_env.get("PYTHONDONTWRITEBYTECODE") != "1" + or publish_env.get("ROOT_FAILED_RUN_ID") + != "${{ github.event_name == 'workflow_dispatch' && inputs.failed_run_id || '' }}" + or publish_env.get("PREDECESSOR_RUN_ID") + != "${{ github.event_name == 'workflow_dispatch' && inputs.failed_recovery_run_id || '' }}" + ): errors.append("publish Python helpers cannot write bytecode into the checkout") initialize_publish = _structured_step(publish, "Initialize isolated publish root") download_macos = _structured_step(publish, "Download macOS artifact") @@ -1978,15 +2058,24 @@ def validate_workflow(workflow: str) -> list[str]: initialize_publish, ( 'publish_root="$RUNNER_TEMP/opentake-release-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT"', + 'if ! git cat-file -e "${RELEASE_TOOLING_SHA}^{commit}" 2>/dev/null; then', + 'git fetch --no-tags --depth=1 origin "$RELEASE_TOOLING_SHA"', + 'test "$(git rev-parse "${RELEASE_TOOLING_SHA}^{commit}")" = "$RELEASE_TOOLING_SHA"', + 'git cat-file blob "$RELEASE_TOOLING_SHA:scripts/check_release_workflow.py" \\', + 'git cat-file blob "$RELEASE_TOOLING_SHA:scripts/workflow_yaml.py" \\', + 'test -s "$publish_root/tooling/check_release_workflow.py"', + 'test -s "$publish_root/tooling/workflow_yaml.py"', 'printf \'PUBLISH_ROOT=%s\\n\' "$publish_root" >> "$GITHUB_ENV"', ), ) + and "raw.githubusercontent.com" not in _run_script(initialize_publish) and download_macos_with.get("path") == "${{ runner.temp }}/opentake-release-${{ github.run_id }}-${{ github.run_attempt }}/input/macos" and download_windows_with.get("path") == "${{ runner.temp }}/opentake-release-${{ github.run_id }}-${{ github.run_attempt }}/input/windows" ): errors.append("publish outputs stay outside the worktree") + errors.append("publish helpers use exact tooling commit outside the worktree") stage = _structured_step(publish, "Stage and verify the exact release payload") stage_commands = ( @@ -2257,9 +2346,9 @@ def validate_workflow(workflow: str) -> list[str]: make_public = _structured_step(publish, ordered_names[5]) final = _structured_step(publish, ordered_names[6]) draft_commands = _commands(_run_script(draft)) - create_command = ("gh", "release", "create", "$RELEASE_TAG", "--verify-tag", "--target", "$RELEASE_SHA", "--title", "OpenTake $RELEASE_VERSION", "--notes-file", "$PUBLISH_ROOT/release-body.md", "--draft", "--prerelease", "--latest=false") - refresh_command = ("gh", "release", "edit", "$RELEASE_TAG", "--target", "$RELEASE_SHA", "--title", "OpenTake $RELEASE_VERSION", "--notes-file", "$PUBLISH_ROOT/release-body.md", "--draft", "--prerelease", "--latest=false") - resolver_command = ("python3", "scripts/check_release_workflow.py", "resolve-release-state", "--input", "$PUBLISH_ROOT/existing-release-graphql.json", "--tag", "$RELEASE_TAG", "--sha", "$RELEASE_SHA", "--output", "$PUBLISH_ROOT/release-state.json") + create_command = ("gh", "release", "create", "$RELEASE_TAG", "--verify-tag", "--title", "OpenTake $RELEASE_VERSION", "--notes-file", "$PUBLISH_ROOT/release-body.md", "--draft", "--prerelease", "--latest=false") + refresh_command = ("gh", "release", "edit", "$RELEASE_TAG", "--title", "OpenTake $RELEASE_VERSION", "--notes-file", "$PUBLISH_ROOT/release-body.md", "--draft", "--prerelease", "--latest=false") + resolver_command = ("python3", "-B", "$PUBLISH_ROOT/tooling/check_release_workflow.py", "resolve-release-state", "--input", "$PUBLISH_ROOT/existing-release-graphql.json", "--tag", "$RELEASE_TAG", "--sha", "$RELEASE_SHA", "--output", "$PUBLISH_ROOT/release-state.json") delete_command = ("gh", "api", "--method", "DELETE", "repos/$GITHUB_REPOSITORY/releases/assets/$asset_id") published_guard = ("echo", "release is already published; refusing to mutate it", ">&2") draft_ok = ( @@ -2272,19 +2361,35 @@ def validate_workflow(workflow: str) -> list[str]: and ("gh", "api", "repos/$GITHUB_REPOSITORY/releases/$release_id", ">", "$PUBLISH_ROOT/existing-draft-rest.json") in draft_commands and delete_command in draft_commands and published_guard in draft_commands + and _has_code_lines( + draft, + ( + 'jq -e \'.target_commitish | (type == "string" and length > 0)\' "$PUBLISH_ROOT/existing-draft-rest.json" >/dev/null', + ), + ) and draft_commands.index(resolver_command) < draft_commands.index(delete_command) and draft_commands.index(published_guard) < draft_commands.index(delete_command) ) if not draft_ok: errors.append("draft exists before release upload") errors.append("published same-tag release is immutable") + if ( + create_command not in draft_commands + or refresh_command not in draft_commands + or any( + token == "--target" or token.startswith("--target=") + for command in draft_commands + for token in command + ) + ): + errors.append("release mutations omit workflow-protected target_commitish") if not _has_command( upload, ("gh", "release", "upload", "$RELEASE_TAG", "$PUBLISH_ROOT/assets/*", "--clobber"), ): errors.append("draft payload upload supports failed-run retry") if not ( - _has_command(inspect_draft, ("python3", "scripts/check_release_workflow.py", "resolve-release-state", "--input", "$PUBLISH_ROOT/draft-release-graphql.json", "--tag", "$RELEASE_TAG", "--sha", "$RELEASE_SHA", "--output", "$PUBLISH_ROOT/draft-state.json")) + _has_command(inspect_draft, ("python3", "-B", "$PUBLISH_ROOT/tooling/check_release_workflow.py", "resolve-release-state", "--input", "$PUBLISH_ROOT/draft-release-graphql.json", "--tag", "$RELEASE_TAG", "--sha", "$RELEASE_SHA", "--output", "$PUBLISH_ROOT/draft-state.json")) and _has_code_lines(inspect_draft, ('test "$(jq -r \'.action\' "$PUBLISH_ROOT/draft-state.json")" = "refresh"', 'cmp "$PUBLISH_ROOT/expected-assets.txt" "$PUBLISH_ROOT/draft-assets.txt"', "jq -e '.asset_sizes | length == 17 and all(. > 0)' \"$PUBLISH_ROOT/draft-state.json\" >/dev/null")) ): errors.append("draft API verification") @@ -2319,7 +2424,7 @@ def remote_rebind_ok(step: dict[str, object] | None, output: str) -> bool: ("git", "ls-remote", "--exit-code", "origin", "refs/tags/$RELEASE_TAG", "refs/tags/$RELEASE_TAG^{}", ">", output), ) and _has_command( step, - ("python3", "scripts/check_release_workflow.py", "resolve-remote-tag", "--input", output, "--tag", "$RELEASE_TAG", "--sha", "$RELEASE_SHA"), + ("python3", "-B", "$PUBLISH_ROOT/tooling/check_release_workflow.py", "resolve-remote-tag", "--input", output, "--tag", "$RELEASE_TAG", "--sha", "$RELEASE_SHA"), ) if not remote_rebind_ok(rebind_draft, "$PUBLISH_ROOT/remote-tag-before-draft.txt"): @@ -2331,16 +2436,40 @@ def remote_rebind_ok(step: dict[str, object] | None, output: str) -> bool: ("gh", "release", "edit", "$RELEASE_TAG", "--draft=false", "--prerelease", "--latest=false"), ): errors.append("verified prerelease publication") + if not remote_rebind_ok( + final, "$PUBLISH_ROOT/remote-tag-after-publication.txt" + ): + errors.append("final remote tag rebound after publication") + final_graphql_ok = ( + _has_command( + final, + ("gh", "api", "graphql", "-f", "query=$query", "-f", "owner=$owner", "-f", "name=$repository", "-f", "tag=$RELEASE_TAG", ">", "$PUBLISH_ROOT/public-release-graphql.json"), + ) + and _has_command( + final, + ("python3", "-B", "$PUBLISH_ROOT/tooling/check_release_workflow.py", "resolve-public-release-state", "--input", "$PUBLISH_ROOT/public-release-graphql.json", "--tag", "$RELEASE_TAG", "--sha", "$RELEASE_SHA", "--output", "$PUBLISH_ROOT/public-release-state.json"), + ) + and _has_code_lines( + final, + ( + 'cmp "$PUBLISH_ROOT/expected-assets.txt" "$PUBLISH_ROOT/public-graphql-assets.txt"', + "jq -e '.asset_sizes | length == 17 and all(. > 0)' \"$PUBLISH_ROOT/public-release-state.json\" >/dev/null", + ), + ) + ) + if not final_graphql_ok: + errors.append("final GraphQL verification binds immutable tag commit and assets") if not _has_code_lines( final, ( - 'if release.get("target_commitish") != expected_sha:', 'if release.get("draft") is not False or release.get("prerelease") is not True:', + 'target_commitish = release.get("target_commitish")', + "if not isinstance(target_commitish, str) or not target_commitish:", "if expected_names != actual_names:", "sha256sum --check SHA256SUMS", ), ) or not _has_command(final, ("gh", "release", "download", "$RELEASE_TAG", "--dir", "$PUBLISH_ROOT/verified-release")): - errors.append("final API verification binds target SHA") + errors.append("final REST schema, exact assets, and checksums") notes = _structured_step(publish, "Prepare release notes with provenance") if not _has_code_lines( @@ -2356,7 +2485,12 @@ def remote_rebind_ok(step: dict[str, object] | None, output: str) -> bool: 'git cat-file blob "$RELEASE_TOOLING_SHA:$NOTES_PATH" \\', 'notes_sha="$RELEASE_TOOLING_SHA"', 'test -s "$PUBLISH_ROOT/release-body.md"', + 'recovery_chain="normal tag push"', + 'test "$ROOT_FAILED_RUN_ID" = "31412976593"', + 'test "$PREDECESSOR_RUN_ID" = "31441693191"', + 'recovery_chain="root run $ROOT_FAILED_RUN_ID; direct predecessor run $PREDECESSOR_RUN_ID"', "- Release notes commit: \\`$notes_sha\\`", + "- Recovery chain: $recovery_chain", ), ): errors.append("recovery release notes use exact tooling commit") @@ -2389,14 +2523,16 @@ def validate_release_notes_contract(notes_path: Path) -> list[str]: "当前远端 `main` HEAD", "`failed_run_id`", "`failed_run_id=31412976593`", - "`failed_recovery_run_id=31427093503`", + "`failed_recovery_run_id=31441693191`", "`2c4efdff9d2587c90cbcac0919f9d1d333d67d6a`", - "`6162466834bbabb8a16a2c08808e03a53c2b22b6`", + "`924bc1102a9343e14c3beea2a3622b5d92ebff13`", "`workflow_dispatch` 恢复", "原不可变 tag SHA", "`github.workflow_sha`", - "source → `61624668…`", - "`61624668…` → 当前远端 `main`", + "source → `924bc110…`", + "`924bc110…` → 当前远端 `main`", + "REST `target_commitish` 只做非空 schema 校验", + "GraphQL `tagCommit.oid`", "不创建、移动或删除 tag", "公开 release notes", "notes commit", @@ -2465,6 +2601,27 @@ def _resolve_release_state_command(arguments: list[str]) -> None: ) +def _resolve_public_release_state_command(arguments: list[str]) -> None: + parser = argparse.ArgumentParser( + prog="check_release_workflow.py resolve-public-release-state" + ) + parser.add_argument("--input", required=True, type=Path) + parser.add_argument("--tag", required=True) + parser.add_argument("--sha", required=True) + parser.add_argument("--output", required=True, type=Path) + options = parser.parse_args(arguments) + try: + payload = json.loads(options.input.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise ReleaseStateError("GraphQL public release must be a JSON object") + state = resolve_public_release_state(payload, options.tag, options.sha) + except (OSError, json.JSONDecodeError, ReleaseStateError) as error: + raise SystemExit(f"unsafe public release state: {error}") from error + options.output.write_text( + json.dumps(state, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + + def _resolve_remote_tag_command(arguments: list[str]) -> None: parser = argparse.ArgumentParser(prog="check_release_workflow.py resolve-remote-tag") parser.add_argument("--input", required=True, type=Path) @@ -2567,6 +2724,8 @@ def main(arguments: list[str] | None = None) -> None: if arguments: if arguments[0] == "resolve-release-state": _resolve_release_state_command(arguments[1:]) + elif arguments[0] == "resolve-public-release-state": + _resolve_public_release_state_command(arguments[1:]) elif arguments[0] == "resolve-remote-tag": _resolve_remote_tag_command(arguments[1:]) elif arguments[0] == "validate-recovery-run": diff --git a/scripts/test_check_release_workflow.py b/scripts/test_check_release_workflow.py index 0d6780d6..ea71479e 100644 --- a/scripts/test_check_release_workflow.py +++ b/scripts/test_check_release_workflow.py @@ -30,9 +30,9 @@ CHECKOUT_SHA = "11d5960a326750d5838078e36cf38b85af677262" BETA4_FAILED_RUN_ID = 31412976593 BETA4_SOURCE_SHA = "2c4efdff9d2587c90cbcac0919f9d1d333d67d6a" -BETA4_RECOVERY2_RUN_ID = 31427093503 -BETA4_RECOVERY1_TOOLING_SHA = "6162466834bbabb8a16a2c08808e03a53c2b22b6" -BETA4_NEXT_TOOLING_SHA = "d90c87df0c0b4194635cd20de5e5816b6797d0c0" +BETA4_PREDECESSOR_RUN_ID = 31441693191 +BETA4_PREDECESSOR_TOOLING_SHA = "924bc1102a9343e14c3beea2a3622b5d92ebff13" +BETA4_CURRENT_TOOLING_SHA = "d90c87df0c0b4194635cd20de5e5816b6797d0c0" RECOVERY_WINDOWS_STEP_OUTCOMES = ( ("Set up job", "success"), (f"Run actions/checkout@{CHECKOUT_SHA}", "success"), @@ -82,37 +82,32 @@ ("Complete job", "success"), ) RECOVERY_WINDOWS_STEP_NUMBERS = (*range(1, 25), 46, 47, 48, 49) -RECOVERY2_WINDOWS_STEP_OUTCOMES = ( - *RECOVERY_WINDOWS_STEP_OUTCOMES[:8], - ("Provision checksum-pinned Windows FFmpeg sidecars", "success"), - ("Verify pinned sidecar supply", "success"), - ("Cache Cargo dependencies", "success"), - ("Install locked Web dependencies", "success"), - ("Rust workspace clippy", "success"), - ("Rust workspace tests", "success"), - ("Web editor behavior suite", "success"), - ("Minimal-feature Tauri clippy", "success"), - ("Web production build", "success"), - ("Reassert exact source before Windows build", "success"), - ("Build native MSI, NSIS, and signed updater artifacts", "failure"), - ("Install NSIS and smoke installed app, sidecars, and updater artifacts", "skipped"), - ("Reassert exact source after Windows packaging", "skipped"), - ("Create and sign Windows updater attestations", "skipped"), - ("Create Windows exact-SHA receipt", "skipped"), - ("Upload exact-SHA Windows packages and updater signatures", "skipped"), - ("Post Cache Cargo dependencies", "skipped"), - ( - "Post Run actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020", - "skipped", - ), - ( - "Post Run pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1", - "success", - ), +PREDECESSOR_PUBLISH_STEP_OUTCOMES = ( + ("Set up job", "success"), + (f"Run actions/checkout@{CHECKOUT_SHA}", "success"), + ("Assert exact checked-out SHA", "success"), + ("Initialize isolated publish root", "success"), + ("Install Minisign verifier", "success"), + ("Download macOS artifact", "success"), + ("Download Windows artifact", "success"), + ("Stage and verify the exact release payload", "success"), + ("Verify updater signatures against embedded public key", "success"), + ("Write and verify tag-specific updater manifest", "success"), + ("Create and verify SHA256SUMS", "success"), + ("Prepare release notes with provenance", "success"), + ("Reassert exact source before draft mutation", "success"), + ("Revalidate remote tag before draft mutation", "success"), + ("Create or refresh draft prerelease", "failure"), + ("Upload the exact payload to the draft", "skipped"), + ("Verify draft target and exact assets", "skipped"), + ("Revalidate remote tag before publication", "skipped"), + ("Reassert exact source before publication", "skipped"), + ("Publish verified prerelease", "skipped"), + ("Verify public release through API and checksums", "skipped"), (f"Post Run actions/checkout@{CHECKOUT_SHA}", "success"), ("Complete job", "success"), ) -RECOVERY2_WINDOWS_STEP_NUMBERS = (*range(1, 25), 45, 46, 47, 48, 49) +PREDECESSOR_PUBLISH_STEP_NUMBERS = (*range(1, 22), 42, 43) def recovery_windows_steps() -> list[dict[str, object]]: @@ -131,7 +126,7 @@ def recovery_windows_steps() -> list[dict[str, object]]: ] -def recovery2_windows_steps() -> list[dict[str, object]]: +def predecessor_publish_steps() -> list[dict[str, object]]: return [ { "number": number, @@ -140,25 +135,39 @@ def recovery2_windows_steps() -> list[dict[str, object]]: "conclusion": conclusion, } for number, (name, conclusion) in zip( - RECOVERY2_WINDOWS_STEP_NUMBERS, - RECOVERY2_WINDOWS_STEP_OUTCOMES, + PREDECESSOR_PUBLISH_STEP_NUMBERS, + PREDECESSOR_PUBLISH_STEP_OUTCOMES, strict=True, ) ] -def recovery_artifacts( +def predecessor_artifacts( run_id: int, head_branch: str, head_sha: str ) -> dict[str, object]: return { - "total_count": 1, + "total_count": 2, "artifacts": [ { - "id": 9077851536, + "id": 9084161184, + "name": f"opentake-windows-x64-{BETA4_SOURCE_SHA}", + "size_in_bytes": 563738459, + "expired": False, + "digest": "sha256:fab634decf8ee1d74432bd003be49474c5790b97e45ad2c81d23aa3bc58613db", + "workflow_run": { + "id": run_id, + "repository_id": 1275692189, + "head_repository_id": 1275692189, + "head_branch": head_branch, + "head_sha": head_sha, + }, + }, + { + "id": 9083298193, "name": f"opentake-macos-arm64-{BETA4_SOURCE_SHA}", - "size_in_bytes": 142001290, + "size_in_bytes": 142002569, "expired": False, - "digest": "sha256:b62a8270268087d91bc4f8d2c8aac5d2ae2fe2cf32ec21d95bd2fd46787df612", + "digest": "sha256:2c5a1ab7f80e59f812b47a37e316c61bc7feedc7b85dd0e20f94a328086ca0b7", "workflow_run": { "id": run_id, "repository_id": 1275692189, @@ -173,7 +182,7 @@ def recovery_artifacts( def recovery_comparison( base_sha: str, - head_sha: str = BETA4_RECOVERY1_TOOLING_SHA, + head_sha: str = BETA4_PREDECESSOR_TOOLING_SHA, ) -> dict[str, object]: return { "status": "ahead", @@ -275,7 +284,7 @@ def test_failed_run_recovery_binds_the_original_tag_push_and_source(self) -> Non "total_commits": 1, "base_commit": {"sha": source_sha}, "merge_base_commit": {"sha": source_sha}, - "commits": [{"sha": BETA4_RECOVERY1_TOOLING_SHA}], + "commits": [{"sha": BETA4_PREDECESSOR_TOOLING_SHA}], } contract.validate_recovery_run( @@ -285,7 +294,7 @@ def test_failed_run_recovery_binds_the_original_tag_push_and_source(self) -> Non expected_run_id=run_id, expected_tag="v1.0.0-beta.4", expected_sha=source_sha, - expected_comparison_head_sha=BETA4_RECOVERY1_TOOLING_SHA, + expected_comparison_head_sha=BETA4_PREDECESSOR_TOOLING_SHA, ) mutations = ( @@ -346,7 +355,7 @@ def test_failed_run_recovery_binds_the_original_tag_push_and_source(self) -> Non expected_run_id=run_id, expected_tag="v1.0.0-beta.4", expected_sha=source_sha, - expected_comparison_head_sha=BETA4_RECOVERY1_TOOLING_SHA, + expected_comparison_head_sha=BETA4_PREDECESSOR_TOOLING_SHA, ) def test_failed_run_recovery_requires_the_exact_windows_failure_job_set( @@ -394,7 +403,7 @@ def entry(name: str, conclusion: str) -> dict[str, object]: "total_commits": 1, "base_commit": {"sha": source_sha}, "merge_base_commit": {"sha": source_sha}, - "commits": [{"sha": BETA4_RECOVERY1_TOOLING_SHA}], + "commits": [{"sha": BETA4_PREDECESSOR_TOOLING_SHA}], } invalid_job_sets = { "missing": exact[:-1], @@ -440,7 +449,7 @@ def entry(name: str, conclusion: str) -> dict[str, object]: expected_run_id=run_id, expected_tag="v1.0.0-beta.4", expected_sha=source_sha, - expected_comparison_head_sha=BETA4_RECOVERY1_TOOLING_SHA, + expected_comparison_head_sha=BETA4_PREDECESSOR_TOOLING_SHA, ) def test_failed_run_recovery_requires_the_exact_sidecar_failure_step( @@ -490,7 +499,7 @@ def jobs_with_steps(steps: list[dict[str, object]]) -> dict[str, object]: "total_commits": 1, "base_commit": {"sha": source_sha}, "merge_base_commit": {"sha": source_sha}, - "commits": [{"sha": BETA4_RECOVERY1_TOOLING_SHA}], + "commits": [{"sha": BETA4_PREDECESSOR_TOOLING_SHA}], } exact_steps = recovery_windows_steps() mutations: dict[str, list[dict[str, object]]] = { @@ -561,12 +570,12 @@ def jobs_with_steps(steps: list[dict[str, object]]) -> dict[str, object]: expected_run_id=run_id, expected_tag="v1.0.0-beta.4", expected_sha=source_sha, - expected_comparison_head_sha=BETA4_RECOVERY1_TOOLING_SHA, + expected_comparison_head_sha=BETA4_PREDECESSOR_TOOLING_SHA, ) - def test_second_recovery_run_is_an_exact_fail_closed_chain_link(self) -> None: + def test_direct_predecessor_is_the_exact_publish_failure_chain_link(self) -> None: run = { - "id": BETA4_RECOVERY2_RUN_ID, + "id": BETA4_PREDECESSOR_RUN_ID, "workflow_id": 330325373, "name": "Release", "path": ".github/workflows/release.yml", @@ -574,7 +583,7 @@ def test_second_recovery_run_is_an_exact_fail_closed_chain_link(self) -> None: "status": "completed", "conclusion": "failure", "head_branch": "main", - "head_sha": BETA4_RECOVERY1_TOOLING_SHA, + "head_sha": BETA4_PREDECESSOR_TOOLING_SHA, "run_attempt": 1, "repository": { "id": 1275692189, @@ -588,36 +597,39 @@ def test_second_recovery_run_is_an_exact_fail_closed_chain_link(self) -> None: }, } outcomes = ( - ("Validate immutable release source", "success"), - ("Release quality gates", "success"), - ("macOS ARM64 app and DMG", "success"), - ("Windows x64 MSI and NSIS", "failure"), - ("Publish verified GitHub prerelease", "skipped"), + ("Validate immutable release source", 93627558806, "success"), + ("Release quality gates", 93627597989, "success"), + ("macOS ARM64 app and DMG", 93627597999, "success"), + ("Windows x64 MSI and NSIS", 93627598006, "success"), + ("Publish verified GitHub prerelease", 93637315893, "failure"), ) - def jobs_with_steps(steps: list[dict[str, object]]) -> dict[str, object]: + def jobs_with_publish_steps( + steps: list[dict[str, object]], + ) -> dict[str, object]: entries = [] - for name, conclusion in outcomes: + for name, job_id, conclusion in outcomes: entry: dict[str, object] = { + "id": job_id, "name": name, - "run_id": BETA4_RECOVERY2_RUN_ID, + "run_id": BETA4_PREDECESSOR_RUN_ID, "run_attempt": 1, - "head_sha": BETA4_RECOVERY1_TOOLING_SHA, + "head_sha": BETA4_PREDECESSOR_TOOLING_SHA, "status": "completed", "conclusion": conclusion, } - if name == "Windows x64 MSI and NSIS": + if name == "Publish verified GitHub prerelease": entry["steps"] = steps entries.append(entry) return {"total_count": len(entries), "jobs": entries} - exact_jobs = jobs_with_steps(recovery2_windows_steps()) - exact_artifacts = recovery_artifacts( - BETA4_RECOVERY2_RUN_ID, "main", BETA4_RECOVERY1_TOOLING_SHA + exact_jobs = jobs_with_publish_steps(predecessor_publish_steps()) + exact_artifacts = predecessor_artifacts( + BETA4_PREDECESSOR_RUN_ID, "main", BETA4_PREDECESSOR_TOOLING_SHA ) run_comparison = recovery_comparison( - BETA4_RECOVERY1_TOOLING_SHA, - BETA4_NEXT_TOOLING_SHA, + BETA4_PREDECESSOR_TOOLING_SHA, + BETA4_CURRENT_TOOLING_SHA, ) contract.validate_failed_recovery_run( @@ -625,11 +637,11 @@ def jobs_with_steps(steps: list[dict[str, object]]) -> dict[str, object]: exact_jobs, exact_artifacts, run_comparison, - expected_run_id=BETA4_RECOVERY2_RUN_ID, + expected_run_id=BETA4_PREDECESSOR_RUN_ID, expected_tag="v1.0.0-beta.4", expected_source_sha=BETA4_SOURCE_SHA, - expected_tooling_sha=BETA4_RECOVERY1_TOOLING_SHA, - expected_current_tooling_sha=BETA4_NEXT_TOOLING_SHA, + expected_tooling_sha=BETA4_PREDECESSOR_TOOLING_SHA, + expected_current_tooling_sha=BETA4_CURRENT_TOOLING_SHA, ) moved_failure_steps = [ @@ -638,18 +650,22 @@ def jobs_with_steps(steps: list[dict[str, object]]) -> dict[str, object]: "conclusion": ( "success" if step["name"] - == "Build native MSI, NSIS, and signed updater artifacts" + == "Create or refresh draft prerelease" else "failure" - if step["name"] == "Rust workspace tests" + if step["name"] == "Upload the exact payload to the draft" else step["conclusion"] ), } - for step in recovery2_windows_steps() + for step in predecessor_publish_steps() ] - artifact = exact_artifacts["artifacts"][0] - assert isinstance(artifact, dict) - artifact_run = artifact["workflow_run"] - assert isinstance(artifact_run, dict) + artifact_entries = exact_artifacts["artifacts"] + assert isinstance(artifact_entries, list) + windows_artifact = artifact_entries[0] + macos_artifact = artifact_entries[1] + assert isinstance(windows_artifact, dict) + assert isinstance(macos_artifact, dict) + windows_artifact_run = windows_artifact["workflow_run"] + assert isinstance(windows_artifact_run, dict) job_entries = exact_jobs["jobs"] assert isinstance(job_entries, list) run_repository = run["repository"] @@ -658,7 +674,7 @@ def jobs_with_steps(steps: list[dict[str, object]]) -> dict[str, object]: assert isinstance(run_head_repository, dict) mutations = ( ( - "wrong dispatcher head", + "wrong predecessor head", {**run, "head_sha": "3" * 40}, exact_jobs, exact_artifacts, @@ -721,10 +737,11 @@ def jobs_with_steps(steps: list[dict[str, object]]) -> dict[str, object]: "jobs": [ *job_entries, { + "id": 1, "name": "unexpected job", - "run_id": BETA4_RECOVERY2_RUN_ID, + "run_id": BETA4_PREDECESSOR_RUN_ID, "run_attempt": 1, - "head_sha": BETA4_RECOVERY1_TOOLING_SHA, + "head_sha": BETA4_PREDECESSOR_TOOLING_SHA, "status": "completed", "conclusion": "failure", }, @@ -747,61 +764,110 @@ def jobs_with_steps(steps: list[dict[str, object]]) -> dict[str, object]: run_comparison, ), ( - "failure moved to workspace tests", + "wrong exact job ID", + run, + { + **exact_jobs, + "jobs": [{**job_entries[0], "id": 1}, *job_entries[1:]], + }, + exact_artifacts, + run_comparison, + ), + ( + "Windows job did not succeed", + run, + { + **exact_jobs, + "jobs": [ + *job_entries[:3], + {**job_entries[3], "conclusion": "failure"}, + job_entries[4], + ], + }, + exact_artifacts, + run_comparison, + ), + ( + "failure moved past draft creation", + run, + jobs_with_publish_steps(moved_failure_steps), + exact_artifacts, + run_comparison, + ), + ( + "missing publish failure step", run, - jobs_with_steps(moved_failure_steps), + jobs_with_publish_steps( + [ + step + for step in predecessor_publish_steps() + if step["name"] != "Create or refresh draft prerelease" + ] + ), exact_artifacts, run_comparison, ), ( - "boolean Windows step number", + "boolean publish step number", run, - jobs_with_steps( + jobs_with_publish_steps( [ - {**recovery2_windows_steps()[0], "number": True}, - *recovery2_windows_steps()[1:], + {**predecessor_publish_steps()[0], "number": True}, + *predecessor_publish_steps()[1:], ] ), exact_artifacts, run_comparison, ), ( - "artifact ID", + "Windows artifact ID", run, exact_jobs, { **exact_artifacts, - "artifacts": [{**artifact, "id": 9077851537}], + "artifacts": [ + {**windows_artifact, "id": 1}, + macos_artifact, + ], }, run_comparison, ), ( - "artifact name", + "macOS artifact name", run, exact_jobs, { **exact_artifacts, - "artifacts": [{**artifact, "name": "unbound-artifact"}], + "artifacts": [ + windows_artifact, + {**macos_artifact, "name": "unbound-artifact"}, + ], }, run_comparison, ), ( - "artifact size drift", + "Windows artifact size drift", run, exact_jobs, { **exact_artifacts, - "artifacts": [{**artifact, "size_in_bytes": 142001291}], + "artifacts": [ + {**windows_artifact, "size_in_bytes": 563738460}, + macos_artifact, + ], }, run_comparison, ), ( - "artifact digest", + "macOS artifact digest", run, exact_jobs, { **exact_artifacts, - "artifacts": [{**artifact, "digest": "sha256:" + "0" * 64}], + "artifacts": [ + windows_artifact, + {**macos_artifact, "digest": "sha256:" + "0" * 64}, + ], }, run_comparison, ), @@ -813,105 +879,110 @@ def jobs_with_steps(steps: list[dict[str, object]]) -> dict[str, object]: **exact_artifacts, "artifacts": [ { - **artifact, + **windows_artifact, "workflow_run": { - **artifact_run, + **windows_artifact_run, "repository_id": 1, }, - } + }, + macos_artifact, ], }, run_comparison, ), ( - "artifact head", + "artifact run head", run, exact_jobs, { **exact_artifacts, "artifacts": [ { - **artifact, + **windows_artifact, "workflow_run": { - **artifact_run, + **windows_artifact_run, "head_sha": BETA4_SOURCE_SHA, }, - } + }, + macos_artifact, ], }, run_comparison, ), ( - "missing artifact payload", - run, - exact_jobs, - {"total_count": 0, "artifacts": []}, - run_comparison, - ), - ( - "boolean artifact count", + "artifact run ID", run, exact_jobs, - {**exact_artifacts, "total_count": True}, + { + **exact_artifacts, + "artifacts": [ + { + **windows_artifact, + "workflow_run": { + **windows_artifact_run, + "id": BETA4_FAILED_RUN_ID, + }, + }, + macos_artifact, + ], + }, run_comparison, ), ( - "malformed artifact payload", + "missing artifact payload", run, exact_jobs, - {"total_count": 1, "artifacts": ["not-an-object"]}, + {"total_count": 1, "artifacts": [windows_artifact]}, run_comparison, ), ( - "expired artifact", + "boolean artifact count", run, exact_jobs, - { - **exact_artifacts, - "artifacts": [{**artifact, "expired": True}], - }, + {**exact_artifacts, "total_count": True}, run_comparison, ), ( - "non-boolean artifact expiry", + "malformed artifact payload", run, exact_jobs, { - **exact_artifacts, - "artifacts": [{**artifact, "expired": 0}], + "total_count": 2, + "artifacts": [windows_artifact, "not-an-object"], }, run_comparison, ), ( - "empty artifact", + "expired artifact", run, exact_jobs, { **exact_artifacts, - "artifacts": [{**artifact, "size_in_bytes": 0}], + "artifacts": [ + {**windows_artifact, "expired": True}, + macos_artifact, + ], }, run_comparison, ), ( - "artifact run", + "non-boolean artifact expiry", run, exact_jobs, { **exact_artifacts, "artifacts": [ { - **artifact, - "workflow_run": { - **artifact_run, - "id": BETA4_FAILED_RUN_ID, - }, - } + **windows_artifact, + "expired": 0, + }, + macos_artifact, ], }, run_comparison, ), ( - "prior tooling ancestry", + "predecessor tooling ancestry", run, exact_jobs, exact_artifacts, @@ -961,11 +1032,11 @@ def jobs_with_steps(steps: list[dict[str, object]]) -> dict[str, object]: mutated_jobs, mutated_artifacts, mutated_compare, - expected_run_id=BETA4_RECOVERY2_RUN_ID, + expected_run_id=BETA4_PREDECESSOR_RUN_ID, expected_tag="v1.0.0-beta.4", expected_source_sha=BETA4_SOURCE_SHA, - expected_tooling_sha=BETA4_RECOVERY1_TOOLING_SHA, - expected_current_tooling_sha=BETA4_NEXT_TOOLING_SHA, + expected_tooling_sha=BETA4_PREDECESSOR_TOOLING_SHA, + expected_current_tooling_sha=BETA4_CURRENT_TOOLING_SHA, ) def test_recovery_rejects_an_unlisted_failed_run(self) -> None: @@ -979,20 +1050,20 @@ def test_recovery_rejects_an_unlisted_failed_run(self) -> None: "status": "completed", "conclusion": "failure", "head_branch": "main", - "head_sha": BETA4_RECOVERY1_TOOLING_SHA, + "head_sha": BETA4_PREDECESSOR_TOOLING_SHA, "run_attempt": 1, }, {"total_count": 0, "jobs": []}, {"total_count": 0, "artifacts": []}, recovery_comparison( - BETA4_RECOVERY1_TOOLING_SHA, - BETA4_NEXT_TOOLING_SHA, + BETA4_PREDECESSOR_TOOLING_SHA, + BETA4_CURRENT_TOOLING_SHA, ), expected_run_id=99999999999, expected_tag="v1.0.0-beta.4", expected_source_sha=BETA4_SOURCE_SHA, - expected_tooling_sha=BETA4_RECOVERY1_TOOLING_SHA, - expected_current_tooling_sha=BETA4_NEXT_TOOLING_SHA, + expected_tooling_sha=BETA4_PREDECESSOR_TOOLING_SHA, + expected_current_tooling_sha=BETA4_CURRENT_TOOLING_SHA, ) def test_dispatch_contract_requires_root_and_predecessor_run_ids(self) -> None: @@ -1009,7 +1080,7 @@ def test_dispatch_contract_requires_root_and_predecessor_run_ids(self) -> None: ) self.assertEqual( { - "description": "Previous failed workflow_dispatch recovery Release run ID (31427093503) chained to the same immutable source", + "description": "Direct predecessor workflow_dispatch Release run ID (31441693191) whose publish job failed for the same immutable source", "required": True, "type": "string", }, @@ -1018,7 +1089,7 @@ def test_dispatch_contract_requires_root_and_predecessor_run_ids(self) -> None: mutated = self.mutate( " failed_recovery_run_id:\n" - " description: Previous failed workflow_dispatch recovery Release run ID (31427093503) chained to the same immutable source\n" + " description: Direct predecessor workflow_dispatch Release run ID (31441693191) whose publish job failed for the same immutable source\n" " required: true\n" " type: string\n", " failed_recovery_run_id:\n" @@ -1071,7 +1142,7 @@ def test_dispatch_recovery_cannot_bypass_failed_run_provenance(self) -> None: ' test -n "$FAILED_RUN_ID"\n', ), ( - ' test "$FAILED_RECOVERY_RUN_ID" = "31427093503"\n', + ' test "$FAILED_RECOVERY_RUN_ID" = "31441693191"\n', ' test -n "$FAILED_RECOVERY_RUN_ID"\n', ), ( @@ -1092,7 +1163,7 @@ def test_dispatch_recovery_cannot_bypass_failed_run_provenance(self) -> None: ), ( ' FAILED_RECOVERY_RUN_ID: ${{ github.event_name == \'workflow_dispatch\' && inputs.failed_recovery_run_id || \'\' }}\n', - ' FAILED_RECOVERY_RUN_ID: 31427093503\n', + ' FAILED_RECOVERY_RUN_ID: 31441693191\n', ), ) for old, new in mutations: @@ -1560,12 +1631,144 @@ def test_published_release_cannot_be_mutated_by_rerun(self) -> None: ) self.assert_rejected(mutated, "published same-tag release is immutable") - def test_final_api_verification_must_bind_target_sha(self) -> None: - mutated = self.mutate_last( - 'release.get("target_commitish") != expected_sha', - 'release.get("target_commitish") == ""', + def test_release_mutations_omit_workflow_protected_target_commitish(self) -> None: + draft_start = WORKFLOW.index(" - name: Create or refresh draft prerelease\n") + upload_start = WORKFLOW.index(" - name: Upload the exact payload to the draft\n") + draft_step = WORKFLOW[draft_start:upload_start] + self.assertIn("--verify-tag", draft_step) + self.assertNotIn('--target "$RELEASE_SHA"', draft_step) + self.assertIn( + "jq -e '.target_commitish | (type == \"string\" and length > 0)'", + draft_step, + ) + mutations = ( + ( + " --verify-tag \\\n", + " --verify-tag \\\n" + ' --target "$RELEASE_SHA" \\\n', + ), + ( + ' gh release edit "$RELEASE_TAG" \\\n' + ' --title "OpenTake $RELEASE_VERSION" \\\n', + ' gh release edit "$RELEASE_TAG" \\\n' + ' --target="$RELEASE_SHA" \\\n' + ' --title "OpenTake $RELEASE_VERSION" \\\n', + ), + ) + for old, new in mutations: + with self.subTest(target_mutation=new.strip()): + self.assert_rejected( + self.mutate(old, new), + "release mutations omit workflow-protected target_commitish", + ) + + def test_final_verification_rebinds_remote_tag_and_graphql_tag_commit(self) -> None: + final_start = WORKFLOW.index( + " - name: Verify public release through API and checksums\n" + ) + final_step = WORKFLOW[final_start:] + self.assertIn("remote-tag-after-publication.txt", final_step) + self.assertIn("resolve-remote-tag", final_step) + self.assertIn("public-release-graphql.json", final_step) + self.assertIn("resolve-public-release-state", final_step) + self.assertNotIn('release.get("target_commitish") != expected_sha', final_step) + remote_mutation = self.mutate_last( + 'python3 -B "$PUBLISH_ROOT/tooling/check_release_workflow.py" resolve-remote-tag \\\n', + 'python3 -B "$PUBLISH_ROOT/tooling/check_release_workflow.py" trust-remote-tag \\\n', + ) + self.assert_rejected( + remote_mutation, "final remote tag rebound after publication" + ) + graphql_mutation = self.mutate( + ' python3 -B "$PUBLISH_ROOT/tooling/check_release_workflow.py" resolve-public-release-state \\\n', + ' python3 -B "$PUBLISH_ROOT/tooling/check_release_workflow.py" trust-public-release-state \\\n', + ) + self.assert_rejected( + graphql_mutation, + "final GraphQL verification binds immutable tag commit and assets", + ) + + def test_public_graphql_state_binds_tag_commit_and_exact_assets(self) -> None: + assets = [ + {"id": f"asset-{index}", "name": f"asset-{index}", "size": index + 1} + for index in range(17) + ] + payload = { + "data": { + "repository": { + "release": { + "databaseId": 123, + "tagName": "v1.0.0-beta.4", + "tagCommit": {"oid": BETA4_SOURCE_SHA}, + "isDraft": False, + "isPrerelease": True, + "releaseAssets": { + "nodes": assets, + "pageInfo": {"hasNextPage": False}, + }, + } + } + } + } + expected_names = [asset["name"] for asset in assets] + state = contract.resolve_public_release_state( + payload, + "v1.0.0-beta.4", + BETA4_SOURCE_SHA, + ) + self.assertEqual(expected_names, state["asset_names"]) + + release = payload["data"]["repository"]["release"] + mutations = ( + {**release, "tagCommit": {"oid": "1" * 40}}, + {**release, "isDraft": True}, + {**release, "isPrerelease": False}, + { + **release, + "releaseAssets": { + "nodes": assets[:-1], + "pageInfo": {"hasNextPage": False}, + }, + }, + { + **release, + "releaseAssets": { + "nodes": assets, + "pageInfo": {"hasNextPage": True}, + }, + }, + { + **release, + "releaseAssets": { + "nodes": [ + assets[0], + {**assets[1], "name": assets[0]["name"]}, + *assets[2:], + ], + "pageInfo": {"hasNextPage": False}, + }, + }, + { + **release, + "releaseAssets": { + "nodes": [{**assets[0], "size": True}, *assets[1:]], + "pageInfo": {"hasNextPage": False}, + }, + }, ) - self.assert_rejected(mutated, "final API verification binds target SHA") + for mutation in mutations: + with self.assertRaises(contract.ReleaseStateError): + contract.resolve_public_release_state( + {"data": {"repository": {"release": mutation}}}, + "v1.0.0-beta.4", + BETA4_SOURCE_SHA, + ) + with self.assertRaises(contract.ReleaseStateError): + contract.resolve_public_release_state( + {**payload, "errors": [{"message": "forged partial response"}]}, + "v1.0.0-beta.4", + BETA4_SOURCE_SHA, + ) def test_draft_lookup_uses_graphql_not_the_rest_tag_endpoint(self) -> None: draft_start = WORKFLOW.index(" - name: Create or refresh draft prerelease\n") @@ -1855,6 +2058,45 @@ def test_windows_build_config_file_contract_is_fail_closed(self) -> None: "complete Windows x64 installer gate", ) + def test_publish_helpers_are_exact_tooling_blobs_outside_the_worktree(self) -> None: + publish_start = WORKFLOW.index(" publish:\n") + publish = WORKFLOW[publish_start:] + initialize_start = publish.index( + " - name: Initialize isolated publish root\n" + ) + minisign_start = publish.index(" - name: Install Minisign verifier\n") + initialize = publish[initialize_start:minisign_start] + self.assertIn( + 'git cat-file blob "$RELEASE_TOOLING_SHA:scripts/check_release_workflow.py"', + initialize, + ) + self.assertIn( + 'git cat-file blob "$RELEASE_TOOLING_SHA:scripts/workflow_yaml.py"', + initialize, + ) + self.assertNotIn("raw.githubusercontent.com", initialize) + self.assertNotIn("python3 scripts/check_release_workflow.py", publish) + self.assertIn( + 'python3 -B "$PUBLISH_ROOT/tooling/check_release_workflow.py"', + publish, + ) + mutations = ( + ( + ' git cat-file blob "$RELEASE_TOOLING_SHA:scripts/check_release_workflow.py" \\\n', + ' curl --fail "https://raw.githubusercontent.com/appergb/OpenTake/$RELEASE_TOOLING_SHA/scripts/check_release_workflow.py" \\\n', + ), + ( + ' git cat-file blob "$RELEASE_TOOLING_SHA:scripts/workflow_yaml.py" \\\n', + ' git cat-file blob "$RELEASE_SHA:scripts/workflow_yaml.py" \\\n', + ), + ) + for old, new in mutations: + with self.subTest(helper_mutation=new.strip()): + self.assert_rejected( + self.mutate_last(old, new), + "publish helpers use exact tooling commit outside the worktree", + ) + def test_publish_command_cannot_be_faked_by_echo(self) -> None: mutated = self.mutate( ' run: gh release edit "$RELEASE_TAG" --draft=false --prerelease --latest=false\n', @@ -1882,9 +2124,9 @@ def test_release_notes_document_normal_push_and_dual_sha_recovery(self) -> None: canonical = RELEASE_NOTES_PATH.read_text(encoding="utf-8") for marker in ( "`failed_run_id=31412976593`", - "`failed_recovery_run_id=31427093503`", + "`failed_recovery_run_id=31441693191`", "`2c4efdff9d2587c90cbcac0919f9d1d333d67d6a`", - "`6162466834bbabb8a16a2c08808e03a53c2b22b6`", + "`924bc1102a9343e14c3beea2a3622b5d92ebff13`", ): with self.subTest(marker=marker): with tempfile.TemporaryDirectory() as directory: