diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index de8651f5..cfec385d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -491,9 +491,56 @@ jobs: - name: Build native MSI and NSIS installers shell: pwsh run: | + $ErrorActionPreference = 'Stop' Remove-Item 'target/release/bundle/msi' -Recurse -Force -ErrorAction SilentlyContinue Remove-Item 'target/release/bundle/nsis' -Recurse -Force -ErrorAction SilentlyContinue - & .\web\node_modules\.bin\tauri.cmd build --ci --bundles msi,nsis + if ([string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)) { throw 'RUNNER_TEMP is required' } + $runnerTemp = [System.IO.Path]::GetFullPath($env:RUNNER_TEMP) + $configPath = Join-Path $env:RUNNER_TEMP "opentake-windows-ci-$env:GITHUB_RUN_ID-$env:GITHUB_RUN_ATTEMPT.json" + $configPath = [System.IO.Path]::GetFullPath($configPath) + if (-not [System.IO.Path]::IsPathFullyQualified($configPath)) { throw 'Tauri config path is not absolute' } + if (-not [string]::Equals([System.IO.Path]::GetDirectoryName($configPath), $runnerTemp, [System.StringComparison]::OrdinalIgnoreCase)) { + throw 'Tauri config path escaped RUNNER_TEMP' + } + if (Test-Path -LiteralPath $configPath) { throw 'Tauri config path already exists' } + $configJson = '{"bundle":{"createUpdaterArtifacts":false}}' + $utf8NoBom = [System.Text.UTF8Encoding]::new($false) + [System.IO.File]::WriteAllText($configPath, $configJson, $utf8NoBom) + $configItem = Get-Item -LiteralPath $configPath -Force + if ($configItem.PSIsContainer -or (($configItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0)) { + throw 'Tauri config must be a regular non-link file' + } + $configBytes = [System.IO.File]::ReadAllBytes($configPath) + if ($configBytes.Length -ne $utf8NoBom.GetByteCount($configJson)) { throw 'Tauri config is not exact UTF-8 without BOM' } + $configText = [System.IO.File]::ReadAllText($configPath, [System.Text.Encoding]::UTF8) + if ($configText -cne $configJson) { throw 'Tauri config content changed' } + $parsedConfig = [System.IO.File]::ReadAllText($configPath, [System.Text.Encoding]::UTF8) | ConvertFrom-Json -AsHashtable + if (@($parsedConfig.Keys).Count -ne 1 -or -not ($parsedConfig.Keys -ccontains 'bundle')) { throw 'Tauri config root is not exact' } + $bundleConfig = $parsedConfig['bundle'] + if (-not ($bundleConfig -is [System.Collections.IDictionary]) -or @($bundleConfig.Keys).Count -ne 1 -or -not ($bundleConfig.Keys -ccontains 'createUpdaterArtifacts')) { + throw 'Tauri bundle config is not exact' + } + if (-not ($bundleConfig['createUpdaterArtifacts'] -is [bool]) -or $bundleConfig['createUpdaterArtifacts'] -ne $false) { + throw 'Tauri updater artifact config must be false in product CI' + } + $tauriArguments = @( + 'build' + '--ci' + '--bundles' + 'msi,nsis' + '--config' + $configPath + ) + $tauriExitCode = $null + try { + & .\web\node_modules\.bin\tauri.cmd @tauriArguments + $tauriExitCode = $LASTEXITCODE + } finally { + if (Test-Path -LiteralPath $configPath) { + Remove-Item -LiteralPath $configPath -Force + } + } + if ($tauriExitCode -ne 0) { exit $tauriExitCode } - name: Install NSIS package and execute installed product without PATH shell: pwsh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4a572ffb..f8c8e0dc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,7 +10,11 @@ on: required: true type: string failed_run_id: - description: Failed tag-push Release run ID whose exact source is being recovered + description: Root failed tag-push Release run ID (31412976593) for the immutable source + required: true + type: string + failed_recovery_run_id: + description: Previous failed workflow_dispatch recovery Release run ID (31427093503) chained to the same immutable source required: true type: string @@ -25,9 +29,13 @@ jobs: validate: name: Validate immutable release source runs-on: ubuntu-latest + permissions: + actions: read + contents: read env: RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }} FAILED_RUN_ID: ${{ github.event_name == 'workflow_dispatch' && inputs.failed_run_id || '' }} + FAILED_RECOVERY_RUN_ID: ${{ github.event_name == 'workflow_dispatch' && inputs.failed_recovery_run_id || '' }} RELEASE_TOOLING_SHA: ${{ github.workflow_sha }} outputs: source_sha: ${{ steps.bind.outputs.source_sha }} @@ -63,8 +71,10 @@ 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" if [[ "$GITHUB_EVENT_NAME" = "push" ]]; then test -z "$FAILED_RUN_ID" + test -z "$FAILED_RECOVERY_RUN_ID" test "$tooling_sha" = "$source_sha" if [[ "$source_sha" != "$remote_main" ]]; then echo "tag commit does not equal current remote main HEAD" >&2 @@ -72,6 +82,9 @@ jobs: fi elif [[ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]]; then [[ "$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 "$tooling_sha" = "$remote_main" recovery_root="$RUNNER_TEMP/opentake-release-recovery-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" mkdir -p "$recovery_root/tooling" @@ -86,20 +99,41 @@ jobs: test -s "$recovery_root/tooling/check_release_workflow.py" test -s "$recovery_root/tooling/workflow_yaml.py" gh api "repos/$GITHUB_REPOSITORY/actions/runs/$FAILED_RUN_ID" \ - > "$recovery_root/run.json" + > "$recovery_root/root-run.json" gh api "repos/$GITHUB_REPOSITORY/actions/runs/$FAILED_RUN_ID/jobs?per_page=100" \ - > "$recovery_root/jobs.json" - gh api "repos/$GITHUB_REPOSITORY/compare/$source_sha...$remote_main" \ - > "$recovery_root/comparison.json" + > "$recovery_root/root-jobs.json" + gh api "repos/$GITHUB_REPOSITORY/compare/$source_sha...$predecessor_tooling_sha" \ + > "$recovery_root/source-to-predecessor.json" PYTHONDONTWRITEBYTECODE=1 PYTHONPATH="$recovery_root/tooling" \ python3 "$recovery_root/tooling/check_release_workflow.py" \ validate-recovery-run \ - --run "$recovery_root/run.json" \ - --jobs "$recovery_root/jobs.json" \ - --comparison "$recovery_root/comparison.json" \ + --run "$recovery_root/root-run.json" \ + --jobs "$recovery_root/root-jobs.json" \ + --comparison "$recovery_root/source-to-predecessor.json" \ --run-id "$FAILED_RUN_ID" \ --tag "$RELEASE_TAG" \ - --sha "$source_sha" + --sha "$source_sha" \ + --comparison-head-sha "$predecessor_tooling_sha" + gh api "repos/$GITHUB_REPOSITORY/actions/runs/$FAILED_RECOVERY_RUN_ID" \ + > "$recovery_root/predecessor-run.json" + gh api "repos/$GITHUB_REPOSITORY/actions/runs/$FAILED_RECOVERY_RUN_ID/jobs?per_page=100" \ + > "$recovery_root/predecessor-jobs.json" + gh api "repos/$GITHUB_REPOSITORY/actions/runs/$FAILED_RECOVERY_RUN_ID/artifacts?per_page=100" \ + > "$recovery_root/predecessor-artifacts.json" + gh api "repos/$GITHUB_REPOSITORY/compare/$predecessor_tooling_sha...$remote_main" \ + > "$recovery_root/predecessor-to-current.json" + PYTHONDONTWRITEBYTECODE=1 PYTHONPATH="$recovery_root/tooling" \ + python3 "$recovery_root/tooling/check_release_workflow.py" \ + validate-failed-recovery-run \ + --run "$recovery_root/predecessor-run.json" \ + --jobs "$recovery_root/predecessor-jobs.json" \ + --artifacts "$recovery_root/predecessor-artifacts.json" \ + --tooling-comparison "$recovery_root/predecessor-to-current.json" \ + --run-id "$FAILED_RECOVERY_RUN_ID" \ + --tag "$RELEASE_TAG" \ + --source-sha "$source_sha" \ + --tooling-sha "$predecessor_tooling_sha" \ + --current-tooling-sha "$tooling_sha" else echo "unsupported release event: $GITHUB_EVENT_NAME" >&2 exit 1 @@ -750,9 +784,56 @@ jobs: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} run: | + $ErrorActionPreference = 'Stop' Remove-Item 'target/release/bundle/msi' -Recurse -Force -ErrorAction SilentlyContinue Remove-Item 'target/release/bundle/nsis' -Recurse -Force -ErrorAction SilentlyContinue - & .\web\node_modules\.bin\tauri.cmd build --ci --bundles msi,nsis --config '{"bundle":{"createUpdaterArtifacts":true}}' + if ([string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)) { throw 'RUNNER_TEMP is required' } + $runnerTemp = [System.IO.Path]::GetFullPath($env:RUNNER_TEMP) + $configPath = Join-Path $env:RUNNER_TEMP "opentake-windows-updater-$env:GITHUB_RUN_ID-$env:GITHUB_RUN_ATTEMPT.json" + $configPath = [System.IO.Path]::GetFullPath($configPath) + if (-not [System.IO.Path]::IsPathFullyQualified($configPath)) { throw 'Tauri config path is not absolute' } + if (-not [string]::Equals([System.IO.Path]::GetDirectoryName($configPath), $runnerTemp, [System.StringComparison]::OrdinalIgnoreCase)) { + throw 'Tauri config path escaped RUNNER_TEMP' + } + if (Test-Path -LiteralPath $configPath) { throw 'Tauri config path already exists' } + $configJson = '{"bundle":{"createUpdaterArtifacts":true}}' + $utf8NoBom = [System.Text.UTF8Encoding]::new($false) + [System.IO.File]::WriteAllText($configPath, $configJson, $utf8NoBom) + $configItem = Get-Item -LiteralPath $configPath -Force + if ($configItem.PSIsContainer -or (($configItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0)) { + throw 'Tauri config must be a regular non-link file' + } + $configBytes = [System.IO.File]::ReadAllBytes($configPath) + if ($configBytes.Length -ne $utf8NoBom.GetByteCount($configJson)) { throw 'Tauri config is not exact UTF-8 without BOM' } + $configText = [System.IO.File]::ReadAllText($configPath, [System.Text.Encoding]::UTF8) + if ($configText -cne $configJson) { throw 'Tauri config content changed' } + $parsedConfig = [System.IO.File]::ReadAllText($configPath, [System.Text.Encoding]::UTF8) | ConvertFrom-Json -AsHashtable + if (@($parsedConfig.Keys).Count -ne 1 -or -not ($parsedConfig.Keys -ccontains 'bundle')) { throw 'Tauri config root is not exact' } + $bundleConfig = $parsedConfig['bundle'] + if (-not ($bundleConfig -is [System.Collections.IDictionary]) -or @($bundleConfig.Keys).Count -ne 1 -or -not ($bundleConfig.Keys -ccontains 'createUpdaterArtifacts')) { + throw 'Tauri bundle config is not exact' + } + if (-not ($bundleConfig['createUpdaterArtifacts'] -is [bool]) -or $bundleConfig['createUpdaterArtifacts'] -ne $true) { + throw 'Tauri updater artifact config must be true' + } + $tauriArguments = @( + 'build' + '--ci' + '--bundles' + 'msi,nsis' + '--config' + $configPath + ) + $tauriExitCode = $null + try { + & .\web\node_modules\.bin\tauri.cmd @tauriArguments + $tauriExitCode = $LASTEXITCODE + } finally { + if (Test-Path -LiteralPath $configPath) { + Remove-Item -LiteralPath $configPath -Force + } + } + if ($tauriExitCode -ne 0) { exit $tauriExitCode } - name: Install NSIS and smoke installed app, sidecars, and updater artifacts shell: pwsh diff --git a/docs/releases/1.0.0-beta.4.md b/docs/releases/1.0.0-beta.4.md index 5e83bdaf..a0263b10 100644 --- a/docs/releases/1.0.0-beta.4.md +++ b/docs/releases/1.0.0-beta.4.md @@ -35,20 +35,29 @@ `.github/workflows/release.yml` 的正常 tag push 只接受 `v1.0.0-beta.4` 这类 `v` tag; 此时 product source SHA 与 release tooling SHA 必须相同,并等于当前远端 `main` HEAD。若这个 tag push 已通过 source、质量与 macOS 门禁,却仅在 Windows 的 checksum-pinned FFmpeg sidecar -provision 步骤失败,则允许用显式 `tag` 与 `failed_run_id` 发起一次 `workflow_dispatch` 恢复: -product source 仍是原不可变 tag SHA,release tooling 则固定为当前 `main` 的 +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` 的 `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;恢复路径还会逐项核对原 run 的五个 job 与 Windows - 失败 step,并证明 source 是当前 `main` 的祖先。随后验证版本、WiX、本文档、prerelease 语义 - 和洁净 checkout;质量门禁执行依赖锁定安装、audit、格式、clippy、workspace/Web 测试及 - workflow/tooling commit 合同。 +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;质量门禁执行依赖 + 锁定安装、audit、格式、clippy、workspace/Web 测试及 workflow/tooling commit 合同。 2. macOS ARM64 和 Windows x64 都要求非空 `TAURI_SIGNING_PRIVATE_KEY` 与 `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`。构建产生平台安装包、Tauri updater package、每个 companion signature 与签名 attestation;macOS app 仍是 ad-hoc 签名,Windows 安装器仍不是 - Authenticode 签名。 + Authenticode 签名。Windows release 与 full-product CI 都在 `RUNNER_TEMP` 写入 UTF-8 无 BOM、 + 非链接 regular-file Tauri config,并把绝对路径作为单独 `--config` argv;release 精确开启 updater + artifacts,CI 精确关闭它以避免依赖发布签名 secrets。 3. publish 从 exact SHA 的配置读取内置 updater 公钥,用独立 Minisign 工具验证 package 与 attestation;manifest 将版本、tag、source SHA、平台、资产名称、大小和 SHA-256 绑定。只有 draft 的十七项精确资产上传、下载回读、名称/大小、签名和 `SHA256SUMS` 全部相符后才公开。 diff --git a/scripts/check_release_workflow.py b/scripts/check_release_workflow.py index 513e06b3..392a5a16 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"): "5a1c640ad928939cde6666cdfd6374b477862f99b0e3c12d8d3632c27eb4c80c", + ("validate", "Validate tag, source SHA, versions, and notes"): "57f984fba1ddd7171444f54b3f086b86b563e55ac7b298b6e9271446bbfdb54f", ("validate", "Reassert exact source after validation"): "953657d26d2eda8490c18e7030c66ddb19aba64a5c8b19808da9a853fd1bfdd2", ("quality", "Assert exact checked-out SHA"): "ff0b148eecdf8603712586a6c4a05e752df0b36b5c97a366760f6cba10e58ddd", ("quality", "Free disk space"): "5848415c4d0e696f46965d62a2e17c8b7a0dd45ae600d28102af0b04108d9bf6", @@ -191,7 +191,7 @@ ("windows_x64", "Require updater signing secrets"): "d03a04c4866ac7ecb9eba9d53aabbf92de24f452e6b5448d438d863988e797c4", ("windows_x64", "Provision checksum-pinned Windows FFmpeg sidecars"): "0518296c77e12a05d7bd99327daf00782e05aa1697f853d654a0eec0fd449238", ("windows_x64", "Reassert exact source before Windows build"): "953657d26d2eda8490c18e7030c66ddb19aba64a5c8b19808da9a853fd1bfdd2", - ("windows_x64", "Build native MSI, NSIS, and signed updater artifacts"): "efa3fa9c3659a91dcbeb3b16e95bbb17482e50f5e71ef9ff4f037b69d7ee335f", + ("windows_x64", "Build native MSI, NSIS, and signed updater artifacts"): "cc2f68b639e4d188892836a0b4e0a3819eb216dc83b06f5acf2a3d0640405142", ("windows_x64", "Install NSIS and smoke installed app, sidecars, and updater artifacts"): "556af3fe7ee52b0dfde26824abf39b589c897782f1a311a8c64a044dc3cf7010", ("windows_x64", "Reassert exact source after Windows packaging"): "953657d26d2eda8490c18e7030c66ddb19aba64a5c8b19808da9a853fd1bfdd2", ("windows_x64", "Create and sign Windows updater attestations"): "40c7f2ea8696db15adf49a688a446637df5b1625fe0b39de32502e66af71f2c6", @@ -214,10 +214,10 @@ } APPROVED_JOB_SHA256 = { - "validate": "e493b3756464fe932b402b2aab9e36575bbeb58be1ae80fea28dfa0cb335cbb4", + "validate": "e0749abbec85f8a9dade905f4e98e903350168a79b2c972dcc5b839070f4e9c5", "quality": "a2947370289ebd299042159fbe8fd046f7fedf72b47037d58b4398ed8e85baee", "macos_arm64": "1785d765c96278190c25e312c9e610070619e17b7b2b0d922f0bd234501df525", - "windows_x64": "b7cff85dc4201f30fce5ade71687993ce185b913b1ba62f29d6b031faa5311f2", + "windows_x64": "63bd70d85e40a3f1177e9059d4674d7f93d4502181fc378f7706e839af953378", "publish": "c7d84471185df270f1d30129b936dccc8cb6c1069020e34924037be2976425ec", } @@ -303,6 +303,79 @@ ("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_TAG = "v1.0.0-beta.4" +EXPECTED_FAILED_RECOVERY_SOURCE_SHA = ( + "2c4efdff9d2587c90cbcac0919f9d1d333d67d6a" +) +EXPECTED_FAILED_RECOVERY_TOOLING_SHA = ( + "6162466834bbabb8a16a2c08808e03a53c2b22b6" +) +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", + ), + ( + f"Post Run pnpm/action-setup@{PINNED_ACTIONS['pnpm/action-setup']}", + "completed", + "success", + ), + ( + f"Post Run actions/checkout@{PINNED_ACTIONS['actions/checkout']}", + "completed", + "success", + ), + ("Complete job", "completed", "success"), +) +EXPECTED_FAILED_RECOVERY_WINDOWS_STEP_NUMBERS = ( + *range(1, 25), + 45, + 46, + 47, + 48, + 49, +) class ReleaseStateError(ValueError): @@ -325,12 +398,15 @@ def validate_recovery_run( expected_run_id: int, expected_tag: str, expected_sha: str, + expected_comparison_head_sha: str, ) -> None: """Validate one failed tag run before rebuilding its immutable source.""" if expected_run_id <= 0: raise RecoveryRunError("recovery run ID must be positive") if re.fullmatch(r"[0-9a-f]{40}", expected_sha) is None: raise RecoveryRunError("recovery source SHA must be lowercase 40-hex") + if re.fullmatch(r"[0-9a-f]{40}", expected_comparison_head_sha) is None: + raise RecoveryRunError("recovery comparison head must be lowercase 40-hex") required_run_fields = { "id": expected_run_id, "name": "Release", @@ -391,7 +467,11 @@ def validate_recovery_run( for step in windows_steps ) if ( - step_numbers != EXPECTED_RECOVERY_WINDOWS_STEP_NUMBERS + any( + not isinstance(number, int) or isinstance(number, bool) + for number in step_numbers + ) + or step_numbers != EXPECTED_RECOVERY_WINDOWS_STEP_NUMBERS or step_outcomes != EXPECTED_RECOVERY_WINDOWS_STEPS ): raise RecoveryRunError( @@ -400,14 +480,217 @@ def validate_recovery_run( base = comparison.get("base_commit") merge_base = comparison.get("merge_base_commit") + commits = comparison.get("commits") + total_commits = comparison.get("total_commits") + ahead_by = comparison.get("ahead_by") + behind_by = comparison.get("behind_by") if ( - comparison.get("status") not in {"ahead", "identical"} + comparison.get("status") != "ahead" + or not isinstance(total_commits, int) + or isinstance(total_commits, bool) + or total_commits <= 0 + or not isinstance(ahead_by, int) + or isinstance(ahead_by, bool) + or ahead_by != total_commits + or not isinstance(behind_by, int) + or isinstance(behind_by, bool) + or behind_by != 0 or not isinstance(base, dict) or base.get("sha") != expected_sha or not isinstance(merge_base, dict) or merge_base.get("sha") != expected_sha + or not isinstance(commits, list) + or len(commits) != total_commits + or not all(isinstance(commit, dict) for commit in commits) + or commits[-1].get("sha") != expected_comparison_head_sha ): - raise RecoveryRunError("release source is not an ancestor of current main") + raise RecoveryRunError( + "release source is not an ancestor of the approved predecessor tooling" + ) + + +def validate_failed_recovery_run( + run: dict[str, object], + jobs: dict[str, object], + artifacts: dict[str, object], + tooling_comparison: dict[str, object], + *, + expected_run_id: int, + expected_tag: str, + expected_source_sha: str, + expected_tooling_sha: str, + expected_current_tooling_sha: str, +) -> None: + """Validate the one approved failed recovery before chaining another run.""" + if ( + expected_run_id != EXPECTED_FAILED_RECOVERY_RUN_ID + or expected_tag != EXPECTED_FAILED_RECOVERY_TAG + or expected_source_sha != EXPECTED_FAILED_RECOVERY_SOURCE_SHA + or expected_tooling_sha != EXPECTED_FAILED_RECOVERY_TOOLING_SHA + ): + raise RecoveryRunError("failed recovery is not on the approved Beta 4 chain") + if ( + re.fullmatch(r"[0-9a-f]{40}", expected_current_tooling_sha) is None + or expected_current_tooling_sha == expected_tooling_sha + ): + raise RecoveryRunError("current recovery tooling SHA is invalid") + + required_run_fields = { + "id": EXPECTED_FAILED_RECOVERY_RUN_ID, + "workflow_id": 330325373, + "name": "Release", + "path": ".github/workflows/release.yml", + "event": "workflow_dispatch", + "status": "completed", + "conclusion": "failure", + "head_branch": "main", + "head_sha": EXPECTED_FAILED_RECOVERY_TOOLING_SHA, + "run_attempt": 1, + } + if any(run.get(field) != value for field, value in required_run_fields.items()): + raise RecoveryRunError("failed recovery run is not the approved exact run") + if isinstance(run.get("run_attempt"), bool): + raise RecoveryRunError("failed recovery run attempt must be an integer") + expected_repository = { + "id": 1275692189, + "full_name": "appergb/OpenTake", + "private": False, + } + for field in ("repository", "head_repository"): + repository = run.get(field) + if ( + not isinstance(repository, dict) + or repository.get("id") != expected_repository["id"] + or repository.get("full_name") != expected_repository["full_name"] + or repository.get("private") is not False + ): + raise RecoveryRunError( + f"failed recovery {field} provenance is not exact" + ) + + total_count = jobs.get("total_count") + entries = jobs.get("jobs") + expected_job_count = len(EXPECTED_RECOVERY_JOB_CONCLUSIONS) + if ( + total_count != expected_job_count + or not isinstance(entries, list) + or len(entries) != expected_job_count + or not all(isinstance(entry, dict) for entry in entries) + ): + raise RecoveryRunError("failed recovery job list is incomplete or malformed") + names = [entry.get("name") for entry in entries] + 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) + ): + 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(): + entry = by_name[name] + expected_fields = { + "run_id": EXPECTED_FAILED_RECOVERY_RUN_ID, + "run_attempt": 1, + "head_sha": EXPECTED_FAILED_RECOVERY_TOOLING_SHA, + "status": "completed", + "conclusion": conclusion, + } + if isinstance(entry.get("run_attempt"), bool) or any( + entry.get(field) != value for field, value in expected_fields.items() + ): + raise RecoveryRunError( + 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 + ): + raise RecoveryRunError( + "failed recovery Windows steps are missing or malformed" + ) + step_numbers = tuple(step.get("number") for step in windows_steps) + step_outcomes = tuple( + (step.get("name"), step.get("status"), step.get("conclusion")) + for step in windows_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 + ): + raise RecoveryRunError( + "failed recovery is not the exact Windows config argv failure" + ) + + artifact_total_count = artifacts.get("total_count") + artifact_entries = artifacts.get("artifacts") + if ( + not isinstance(artifact_total_count, int) + or isinstance(artifact_total_count, bool) + or artifact_total_count != 1 + or not isinstance(artifact_entries, list) + or len(artifact_entries) != 1 + or not isinstance(artifact_entries[0], dict) + ): + 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() + ): + raise RecoveryRunError("failed recovery macOS artifact is not exact") + artifact_run = artifact.get("workflow_run") + expected_artifact_run = { + "id": EXPECTED_FAILED_RECOVERY_RUN_ID, + "repository_id": 1275692189, + "head_repository_id": 1275692189, + "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") + + base = tooling_comparison.get("base_commit") + merge_base = tooling_comparison.get("merge_base_commit") + commits = tooling_comparison.get("commits") + total_commits = tooling_comparison.get("total_commits") + ahead_by = tooling_comparison.get("ahead_by") + behind_by = tooling_comparison.get("behind_by") + if ( + tooling_comparison.get("status") != "ahead" + or not isinstance(total_commits, int) + or isinstance(total_commits, bool) + or total_commits <= 0 + or not isinstance(ahead_by, int) + or isinstance(ahead_by, bool) + or ahead_by != total_commits + or not isinstance(behind_by, int) + or isinstance(behind_by, bool) + or behind_by != 0 + or not isinstance(base, dict) + or base.get("sha") != EXPECTED_FAILED_RECOVERY_TOOLING_SHA + or not isinstance(merge_base, dict) + or merge_base.get("sha") != EXPECTED_FAILED_RECOVERY_TOOLING_SHA + or not isinstance(commits, list) + or len(commits) != total_commits + or not all(isinstance(commit, dict) for commit in commits) + or commits[-1].get("sha") != expected_current_tooling_sha + ): + raise RecoveryRunError( + "approved failed-recovery tooling is not an ancestor of current main" + ) def resolve_remote_tag_refs(refs_text: str, expected_tag: str) -> str: @@ -749,20 +1032,33 @@ def validate_workflow(workflow: str) -> list[str]: failed_run_input = ( _as_mapping(inputs.get("failed_run_id")) if inputs is not None else None ) + failed_recovery_run_input = ( + _as_mapping(inputs.get("failed_recovery_run_id")) + if inputs is not None + else None + ) if ( push != {"tags": ["v*"]} or dispatch is None or set(dispatch) != {"inputs"} or inputs is None - or set(inputs) != {"tag", "failed_run_id"} + or set(inputs) != {"tag", "failed_run_id", "failed_recovery_run_id"} or tag_input is None or set(tag_input) != {"description", "required", "type"} or tag_input.get("required") is not True or tag_input.get("type") != "string" or failed_run_input is None or set(failed_run_input) != {"description", "required", "type"} + or failed_run_input.get("description") + != "Root failed tag-push Release run ID (31412976593) for the immutable source" or failed_run_input.get("required") is not True or failed_run_input.get("type") != "string" + 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" + or failed_recovery_run_input.get("required") is not True + or failed_recovery_run_input.get("type") != "string" ): errors.append("tag-only release trigger") @@ -1042,10 +1338,15 @@ def validate_workflow(workflow: str) -> list[str]: '[[ "$tooling_sha" =~ ^[0-9a-f]{40}$ ]]', 'if [[ "$GITHUB_EVENT_NAME" = "push" ]]; then', 'test -z "$FAILED_RUN_ID"', + 'test -z "$FAILED_RECOVERY_RUN_ID"', 'test "$tooling_sha" = "$source_sha"', 'elif [[ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]]; then', '[[ "$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 "$tooling_sha" = "$remote_main"', + 'predecessor_tooling_sha="6162466834bbabb8a16a2c08808e03a53c2b22b6"', '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"', @@ -1053,11 +1354,21 @@ def validate_workflow(workflow: str) -> list[str]: 'git cat-file blob "$tooling_sha:scripts/workflow_yaml.py" \\', 'gh api "repos/$GITHUB_REPOSITORY/actions/runs/$FAILED_RUN_ID" \\', 'gh api "repos/$GITHUB_REPOSITORY/actions/runs/$FAILED_RUN_ID/jobs?per_page=100" \\', - 'gh api "repos/$GITHUB_REPOSITORY/compare/$source_sha...$remote_main" \\', + 'gh api "repos/$GITHUB_REPOSITORY/compare/$source_sha...$predecessor_tooling_sha" \\', 'validate-recovery-run \\', '--run-id "$FAILED_RUN_ID" \\', '--tag "$RELEASE_TAG" \\', - '--sha "$source_sha"', + '--sha "$source_sha" \\', + '--comparison-head-sha "$predecessor_tooling_sha"', + 'gh api "repos/$GITHUB_REPOSITORY/actions/runs/$FAILED_RECOVERY_RUN_ID" \\', + 'gh api "repos/$GITHUB_REPOSITORY/actions/runs/$FAILED_RECOVERY_RUN_ID/jobs?per_page=100" \\', + 'gh api "repos/$GITHUB_REPOSITORY/actions/runs/$FAILED_RECOVERY_RUN_ID/artifacts?per_page=100" \\', + 'gh api "repos/$GITHUB_REPOSITORY/compare/$predecessor_tooling_sha...$remote_main" \\', + 'validate-failed-recovery-run \\', + '--run-id "$FAILED_RECOVERY_RUN_ID" \\', + '--source-sha "$source_sha" \\', + '--tooling-sha "$predecessor_tooling_sha" \\', + '--current-tooling-sha "$tooling_sha"', 'printf \'tooling_sha=%s\\n\' "$tooling_sha" >> "$GITHUB_OUTPUT"', ) recovery_provenance = ( @@ -1065,6 +1376,7 @@ def validate_workflow(workflow: str) -> list[str]: == { "RELEASE_TAG": "${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}", "FAILED_RUN_ID": "${{ github.event_name == 'workflow_dispatch' && inputs.failed_run_id || '' }}", + "FAILED_RECOVERY_RUN_ID": "${{ github.event_name == 'workflow_dispatch' && inputs.failed_recovery_run_id || '' }}", "RELEASE_TOOLING_SHA": "${{ github.workflow_sha }}", } and validate_outputs is not None @@ -1104,7 +1416,12 @@ def validate_workflow(workflow: str) -> list[str]: errors.append("validate binds exact clean checkout") required_jobs = ("quality", "macos_arm64", "windows_x64") - for name in ("validate", *required_jobs): + validate_permissions = ( + _as_mapping(validate.get("permissions")) if validate is not None else None + ) + if validate_permissions != {"actions": "read", "contents": "read"}: + errors.append("validate-only Actions read permission") + for name in required_jobs: job = structured_jobs[name] if job is not None and "permissions" in job and job.get("permissions") != { "contents": "read" @@ -1346,10 +1663,50 @@ def validate_workflow(workflow: str) -> list[str]: _has_command(_structured_step(windows, step_name), command) for step_name, command in windows_commands ) - windows_ok = windows_ok and _has_command( - windows_build, - ("&", ".\\web\\node_modules\\.bin\\tauri.cmd", "build", "--ci", "--bundles", "msi,nsis", "--config", "'{\"bundle\":{\"createUpdaterArtifacts\":true}}'"), - powershell=True, + windows_config_lines = ( + "$ErrorActionPreference = 'Stop'", + "if ([string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)) { throw 'RUNNER_TEMP is required' }", + "$runnerTemp = [System.IO.Path]::GetFullPath($env:RUNNER_TEMP)", + '$configPath = Join-Path $env:RUNNER_TEMP "opentake-windows-updater-$env:GITHUB_RUN_ID-$env:GITHUB_RUN_ATTEMPT.json"', + "$configPath = [System.IO.Path]::GetFullPath($configPath)", + "if (-not [System.IO.Path]::IsPathFullyQualified($configPath)) { throw 'Tauri config path is not absolute' }", + "if (-not [string]::Equals([System.IO.Path]::GetDirectoryName($configPath), $runnerTemp, [System.StringComparison]::OrdinalIgnoreCase)) {", + "if (Test-Path -LiteralPath $configPath) { throw 'Tauri config path already exists' }", + "$configJson = '{\"bundle\":{\"createUpdaterArtifacts\":true}}'", + "$utf8NoBom = [System.Text.UTF8Encoding]::new($false)", + "[System.IO.File]::WriteAllText($configPath, $configJson, $utf8NoBom)", + "$configItem = Get-Item -LiteralPath $configPath -Force", + "if ($configItem.PSIsContainer -or (($configItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0)) {", + "$configBytes = [System.IO.File]::ReadAllBytes($configPath)", + "if ($configBytes.Length -ne $utf8NoBom.GetByteCount($configJson)) { throw 'Tauri config is not exact UTF-8 without BOM' }", + "$configText = [System.IO.File]::ReadAllText($configPath, [System.Text.Encoding]::UTF8)", + "if ($configText -cne $configJson) { throw 'Tauri config content changed' }", + "$parsedConfig = [System.IO.File]::ReadAllText($configPath, [System.Text.Encoding]::UTF8) | ConvertFrom-Json -AsHashtable", + "if (@($parsedConfig.Keys).Count -ne 1 -or -not ($parsedConfig.Keys -ccontains 'bundle')) { throw 'Tauri config root is not exact' }", + "$bundleConfig = $parsedConfig['bundle']", + "if (-not ($bundleConfig -is [System.Collections.IDictionary]) -or @($bundleConfig.Keys).Count -ne 1 -or -not ($bundleConfig.Keys -ccontains 'createUpdaterArtifacts')) {", + "if (-not ($bundleConfig['createUpdaterArtifacts'] -is [bool]) -or $bundleConfig['createUpdaterArtifacts'] -ne $true) {", + "$tauriArguments = @(", + "'build'", + "'--ci'", + "'--bundles'", + "'msi,nsis'", + "'--config'", + "$configPath", + "& .\\web\\node_modules\\.bin\\tauri.cmd @tauriArguments", + "$tauriExitCode = $LASTEXITCODE", + "Remove-Item -LiteralPath $configPath -Force", + "if ($tauriExitCode -ne 0) { exit $tauriExitCode }", + ) + windows_build_script = _run_script(windows_build) + windows_ok = ( + windows_ok + and _has_code_lines(windows_build, windows_config_lines) + and windows_build_script.count( + "& .\\web\\node_modules\\.bin\\tauri.cmd @tauriArguments" + ) + == 1 + and "--config '{" not in windows_build_script ) windows_ok = windows_ok and windows_build is not None and _as_mapping( windows_build.get("env") @@ -1527,20 +1884,11 @@ def validate_workflow(workflow: str) -> list[str]: ) and windows_build is not None and _as_mapping(windows_build.get("env")) == signing_env - and _has_command( - windows_build, - ( - "&", - ".\\web\\node_modules\\.bin\\tauri.cmd", - "build", - "--ci", - "--bundles", - "msi,nsis", - "--config", - "'{\"bundle\":{\"createUpdaterArtifacts\":true}}'", - ), - powershell=True, + and _has_code_lines(windows_build, windows_config_lines) + and _run_script(windows_build).count( + "& .\\web\\node_modules\\.bin\\tauri.cmd @tauriArguments" ) + == 1 ) if not signed_bundles_ok: errors.append("signed Tauri v2 updater bundles") @@ -2040,9 +2388,15 @@ def validate_release_notes_contract(notes_path: Path) -> list[str]: "product source SHA 与 release tooling SHA", "当前远端 `main` HEAD", "`failed_run_id`", + "`failed_run_id=31412976593`", + "`failed_recovery_run_id=31427093503`", + "`2c4efdff9d2587c90cbcac0919f9d1d333d67d6a`", + "`6162466834bbabb8a16a2c08808e03a53c2b22b6`", "`workflow_dispatch` 恢复", "原不可变 tag SHA", "`github.workflow_sha`", + "source → `61624668…`", + "`61624668…` → 当前远端 `main`", "不创建、移动或删除 tag", "公开 release notes", "notes commit", @@ -2138,6 +2492,7 @@ def _validate_recovery_run_command(arguments: list[str]) -> None: parser.add_argument("--run-id", required=True, type=int) parser.add_argument("--tag", required=True) parser.add_argument("--sha", required=True) + parser.add_argument("--comparison-head-sha", required=True) options = parser.parse_args(arguments) try: payloads = [ @@ -2153,12 +2508,60 @@ def _validate_recovery_run_command(arguments: list[str]) -> None: expected_run_id=options.run_id, expected_tag=options.tag, expected_sha=options.sha, + expected_comparison_head_sha=options.comparison_head_sha, ) except (OSError, json.JSONDecodeError, RecoveryRunError) as error: raise SystemExit(f"unsafe failed-run recovery: {error}") from error print(f"validated failed release run {options.run_id} for {options.tag}") +def _validate_failed_recovery_run_command(arguments: list[str]) -> None: + parser = argparse.ArgumentParser( + prog="check_release_workflow.py validate-failed-recovery-run" + ) + parser.add_argument("--run", required=True, type=Path) + parser.add_argument("--jobs", required=True, type=Path) + parser.add_argument("--artifacts", required=True, type=Path) + parser.add_argument("--tooling-comparison", required=True, type=Path) + parser.add_argument("--run-id", required=True, type=int) + parser.add_argument("--tag", required=True) + parser.add_argument("--source-sha", required=True) + parser.add_argument("--tooling-sha", required=True) + parser.add_argument("--current-tooling-sha", required=True) + options = parser.parse_args(arguments) + try: + payloads = [ + json.loads(path.read_text(encoding="utf-8")) + for path in ( + options.run, + options.jobs, + options.artifacts, + options.tooling_comparison, + ) + ] + if not all(isinstance(payload, dict) for payload in payloads): + raise RecoveryRunError( + "failed-recovery API payloads must be JSON objects" + ) + validate_failed_recovery_run( + payloads[0], + payloads[1], + payloads[2], + payloads[3], + expected_run_id=options.run_id, + expected_tag=options.tag, + expected_source_sha=options.source_sha, + expected_tooling_sha=options.tooling_sha, + expected_current_tooling_sha=options.current_tooling_sha, + ) + except (OSError, json.JSONDecodeError, RecoveryRunError) as error: + raise SystemExit(f"unsafe failed-recovery chain: {error}") from error + print( + f"validated failed recovery run {options.run_id} " + f"for {options.tag} at {options.tooling_sha}" + ) + + def main(arguments: list[str] | None = None) -> None: arguments = sys.argv[1:] if arguments is None else arguments if arguments: @@ -2168,6 +2571,8 @@ def main(arguments: list[str] | None = None) -> None: _resolve_remote_tag_command(arguments[1:]) elif arguments[0] == "validate-recovery-run": _validate_recovery_run_command(arguments[1:]) + elif arguments[0] == "validate-failed-recovery-run": + _validate_failed_recovery_run_command(arguments[1:]) else: raise SystemExit(f"unknown command: {arguments[0]}") return diff --git a/scripts/check_windows_product_ci.py b/scripts/check_windows_product_ci.py index b77cc676..eeab4c99 100644 --- a/scripts/check_windows_product_ci.py +++ b/scripts/check_windows_product_ci.py @@ -36,6 +36,10 @@ MOTION_4K_UPLOAD_STEP_SHA256 = ( "129dd5a34d6c6582e4c6eda4b92a3e51deb24e9c136cc6bd9741ff6b16565863" ) +PRODUCT_BUNDLE_STEP_NAME = "Build native MSI and NSIS installers" +PRODUCT_BUNDLE_STEP_SHA256 = ( + "c3655079ed250a95a3c91e9bf66a6c545d067af80ac6ea05edb870c039ab1fce" +) ACTION_PINS = MappingProxyType( { "actions/checkout": "11d5960a326750d5838078e36cf38b85af677262", @@ -294,7 +298,7 @@ def __post_init__(self) -> None: before_reassert_name="Re-assert immutable Windows product source before gates", after_reassert_name="Re-assert immutable Windows product source after gates", first_gate_name="Rust formatting", - job_sha256="e5664d484775190143ea33abcfbf2a458950d3f11fd7a9f065b08b2ce5139b7a", + job_sha256="e660a84d438c1828b7aa534cde2047427c179bcd20ae197fba3c8faa767aa968", step_identities=( "name:Validate immutable SHA input", f"uses:{_pinned_action('actions/checkout')}", @@ -1172,7 +1176,7 @@ def _validate_structured_product(document: dict[str, object]) -> list[str]: ): errors.append("Windows Chromium 4K resource gate order") - bundle = _structured_step(job, "Build native MSI and NSIS installers") + bundle = _structured_step(job, PRODUCT_BUNDLE_STEP_NAME) if not _has_code_lines( bundle, ( @@ -1180,10 +1184,56 @@ def _validate_structured_product(document: dict[str, object]) -> list[str]: "-ErrorAction SilentlyContinue", "Remove-Item 'target/release/bundle/nsis' -Recurse -Force " "-ErrorAction SilentlyContinue", - "& .\\web\\node_modules\\.bin\\tauri.cmd build --ci --bundles msi,nsis", ), ): errors.append("clean native Tauri bundle") + bundle_config_lines = ( + "$ErrorActionPreference = 'Stop'", + "if ([string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)) { throw 'RUNNER_TEMP is required' }", + "$runnerTemp = [System.IO.Path]::GetFullPath($env:RUNNER_TEMP)", + '$configPath = Join-Path $env:RUNNER_TEMP "opentake-windows-ci-$env:GITHUB_RUN_ID-$env:GITHUB_RUN_ATTEMPT.json"', + "$configPath = [System.IO.Path]::GetFullPath($configPath)", + "if (-not [System.IO.Path]::IsPathFullyQualified($configPath)) { throw 'Tauri config path is not absolute' }", + "if (-not [string]::Equals([System.IO.Path]::GetDirectoryName($configPath), $runnerTemp, [System.StringComparison]::OrdinalIgnoreCase)) {", + "if (Test-Path -LiteralPath $configPath) { throw 'Tauri config path already exists' }", + "$configJson = '{\"bundle\":{\"createUpdaterArtifacts\":false}}'", + "$utf8NoBom = [System.Text.UTF8Encoding]::new($false)", + "[System.IO.File]::WriteAllText($configPath, $configJson, $utf8NoBom)", + "$configItem = Get-Item -LiteralPath $configPath -Force", + "if ($configItem.PSIsContainer -or (($configItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0)) {", + "$configBytes = [System.IO.File]::ReadAllBytes($configPath)", + "if ($configBytes.Length -ne $utf8NoBom.GetByteCount($configJson)) { throw 'Tauri config is not exact UTF-8 without BOM' }", + "$configText = [System.IO.File]::ReadAllText($configPath, [System.Text.Encoding]::UTF8)", + "if ($configText -cne $configJson) { throw 'Tauri config content changed' }", + "$parsedConfig = [System.IO.File]::ReadAllText($configPath, [System.Text.Encoding]::UTF8) | ConvertFrom-Json -AsHashtable", + "if (@($parsedConfig.Keys).Count -ne 1 -or -not ($parsedConfig.Keys -ccontains 'bundle')) { throw 'Tauri config root is not exact' }", + "$bundleConfig = $parsedConfig['bundle']", + "if (-not ($bundleConfig -is [System.Collections.IDictionary]) -or @($bundleConfig.Keys).Count -ne 1 -or -not ($bundleConfig.Keys -ccontains 'createUpdaterArtifacts')) {", + "if (-not ($bundleConfig['createUpdaterArtifacts'] -is [bool]) -or $bundleConfig['createUpdaterArtifacts'] -ne $false) {", + "$tauriArguments = @(", + "'build'", + "'--ci'", + "'--bundles'", + "'msi,nsis'", + "'--config'", + "$configPath", + "& .\\web\\node_modules\\.bin\\tauri.cmd @tauriArguments", + "$tauriExitCode = $LASTEXITCODE", + "Remove-Item -LiteralPath $configPath -Force", + "if ($tauriExitCode -ne 0) { exit $tauriExitCode }", + ) + bundle_script = _run_script(bundle) + if ( + not _has_code_lines(bundle, bundle_config_lines) + or bundle_script.count( + "& .\\web\\node_modules\\.bin\\tauri.cmd @tauriArguments" + ) + != 1 + or "--config '{" in bundle_script + ): + errors.append("exact runner-temp Tauri config argument") + if bundle is None or _structured_digest(bundle) != PRODUCT_BUNDLE_STEP_SHA256: + errors.append("exact Windows product Tauri bundle step") installed_script = _run_script( _structured_step( diff --git a/scripts/test_check_release_workflow.py b/scripts/test_check_release_workflow.py index 94dfdd90..0d6780d6 100644 --- a/scripts/test_check_release_workflow.py +++ b/scripts/test_check_release_workflow.py @@ -30,6 +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" RECOVERY_WINDOWS_STEP_OUTCOMES = ( ("Set up job", "success"), (f"Run actions/checkout@{CHECKOUT_SHA}", "success"), @@ -79,6 +82,37 @@ ("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", + ), + (f"Post Run actions/checkout@{CHECKOUT_SHA}", "success"), + ("Complete job", "success"), +) +RECOVERY2_WINDOWS_STEP_NUMBERS = (*range(1, 25), 45, 46, 47, 48, 49) def recovery_windows_steps() -> list[dict[str, object]]: @@ -97,6 +131,61 @@ def recovery_windows_steps() -> list[dict[str, object]]: ] +def recovery2_windows_steps() -> list[dict[str, object]]: + return [ + { + "number": number, + "name": name, + "status": "completed", + "conclusion": conclusion, + } + for number, (name, conclusion) in zip( + RECOVERY2_WINDOWS_STEP_NUMBERS, + RECOVERY2_WINDOWS_STEP_OUTCOMES, + strict=True, + ) + ] + + +def recovery_artifacts( + run_id: int, head_branch: str, head_sha: str +) -> dict[str, object]: + return { + "total_count": 1, + "artifacts": [ + { + "id": 9077851536, + "name": f"opentake-macos-arm64-{BETA4_SOURCE_SHA}", + "size_in_bytes": 142001290, + "expired": False, + "digest": "sha256:b62a8270268087d91bc4f8d2c8aac5d2ae2fe2cf32ec21d95bd2fd46787df612", + "workflow_run": { + "id": run_id, + "repository_id": 1275692189, + "head_repository_id": 1275692189, + "head_branch": head_branch, + "head_sha": head_sha, + }, + } + ], + } + + +def recovery_comparison( + base_sha: str, + head_sha: str = BETA4_RECOVERY1_TOOLING_SHA, +) -> dict[str, object]: + return { + "status": "ahead", + "ahead_by": 1, + "behind_by": 0, + "total_commits": 1, + "base_commit": {"sha": base_sha}, + "merge_base_commit": {"sha": base_sha}, + "commits": [{"sha": head_sha}], + } + + class ReleaseWorkflowContractTests(unittest.TestCase): def assert_rejected(self, workflow: str, expected: str) -> None: self.assertIn(expected, contract.validate_workflow(workflow)) @@ -181,8 +270,12 @@ def test_failed_run_recovery_binds_the_original_tag_push_and_source(self) -> Non } compare = { "status": "ahead", + "ahead_by": 1, + "behind_by": 0, + "total_commits": 1, "base_commit": {"sha": source_sha}, "merge_base_commit": {"sha": source_sha}, + "commits": [{"sha": BETA4_RECOVERY1_TOOLING_SHA}], } contract.validate_recovery_run( @@ -192,6 +285,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, ) mutations = ( @@ -223,6 +317,24 @@ def test_failed_run_recovery_binds_the_original_tag_push_and_source(self) -> Non jobs, {**compare, "merge_base_commit": {"sha": "3" * 40}}, ), + ( + "comparison head", + run, + jobs, + {**compare, "commits": [{"sha": "4" * 40}]}, + ), + ( + "missing comparison head", + run, + jobs, + {key: value for key, value in compare.items() if key != "commits"}, + ), + ( + "truncated comparison commits", + run, + jobs, + {**compare, "ahead_by": 2, "total_commits": 2}, + ), ) for name, mutated_run, mutated_jobs, mutated_compare in mutations: with self.subTest(name=name): @@ -234,6 +346,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, ) def test_failed_run_recovery_requires_the_exact_windows_failure_job_set( @@ -276,8 +389,12 @@ def entry(name: str, conclusion: str) -> dict[str, object]: exact = [entry(name, conclusion) for name, conclusion in outcomes.items()] compare = { "status": "ahead", + "ahead_by": 1, + "behind_by": 0, + "total_commits": 1, "base_commit": {"sha": source_sha}, "merge_base_commit": {"sha": source_sha}, + "commits": [{"sha": BETA4_RECOVERY1_TOOLING_SHA}], } invalid_job_sets = { "missing": exact[:-1], @@ -323,6 +440,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, ) def test_failed_run_recovery_requires_the_exact_sidecar_failure_step( @@ -367,8 +485,12 @@ def jobs_with_steps(steps: list[dict[str, object]]) -> dict[str, object]: compare = { "status": "ahead", + "ahead_by": 1, + "behind_by": 0, + "total_commits": 1, "base_commit": {"sha": source_sha}, "merge_base_commit": {"sha": source_sha}, + "commits": [{"sha": BETA4_RECOVERY1_TOOLING_SHA}], } exact_steps = recovery_windows_steps() mutations: dict[str, list[dict[str, object]]] = { @@ -439,24 +561,472 @@ 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, ) - def test_dispatch_contract_requires_an_explicit_failed_run_id(self) -> None: - tag_input = ( - " tag:\n" - " description: Existing v tag from a failed run; this workflow never creates or moves tags\n" - " required: true\n" - " type: string\n" + def test_second_recovery_run_is_an_exact_fail_closed_chain_link(self) -> None: + run = { + "id": BETA4_RECOVERY2_RUN_ID, + "workflow_id": 330325373, + "name": "Release", + "path": ".github/workflows/release.yml", + "event": "workflow_dispatch", + "status": "completed", + "conclusion": "failure", + "head_branch": "main", + "head_sha": BETA4_RECOVERY1_TOOLING_SHA, + "run_attempt": 1, + "repository": { + "id": 1275692189, + "full_name": "appergb/OpenTake", + "private": False, + }, + "head_repository": { + "id": 1275692189, + "full_name": "appergb/OpenTake", + "private": False, + }, + } + 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"), ) - recovered = tag_input + ( - " failed_run_id:\n" - " description: Failed tag-push Release run ID whose exact source is being recovered\n" - " required: true\n" - " type: string\n" + + def jobs_with_steps(steps: list[dict[str, object]]) -> dict[str, object]: + entries = [] + for name, conclusion in outcomes: + entry: dict[str, object] = { + "name": name, + "run_id": BETA4_RECOVERY2_RUN_ID, + "run_attempt": 1, + "head_sha": BETA4_RECOVERY1_TOOLING_SHA, + "status": "completed", + "conclusion": conclusion, + } + if name == "Windows x64 MSI and NSIS": + 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 + ) + run_comparison = recovery_comparison( + BETA4_RECOVERY1_TOOLING_SHA, + BETA4_NEXT_TOOLING_SHA, ) - candidate = self.mutate(tag_input, recovered) - self.assertNotIn("tag-only release trigger", contract.validate_workflow(candidate)) + contract.validate_failed_recovery_run( + run, + exact_jobs, + exact_artifacts, + run_comparison, + expected_run_id=BETA4_RECOVERY2_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, + ) + + moved_failure_steps = [ + { + **step, + "conclusion": ( + "success" + if step["name"] + == "Build native MSI, NSIS, and signed updater artifacts" + else "failure" + if step["name"] == "Rust workspace tests" + else step["conclusion"] + ), + } + for step in recovery2_windows_steps() + ] + artifact = exact_artifacts["artifacts"][0] + assert isinstance(artifact, dict) + artifact_run = artifact["workflow_run"] + assert isinstance(artifact_run, dict) + job_entries = exact_jobs["jobs"] + assert isinstance(job_entries, list) + run_repository = run["repository"] + run_head_repository = run["head_repository"] + assert isinstance(run_repository, dict) + assert isinstance(run_head_repository, dict) + mutations = ( + ( + "wrong dispatcher head", + {**run, "head_sha": "3" * 40}, + exact_jobs, + exact_artifacts, + run_comparison, + ), + ( + "wrong workflow ID", + {**run, "workflow_id": 1}, + exact_jobs, + exact_artifacts, + run_comparison, + ), + ( + "wrong repository", + {**run, "repository": {**run_repository, "id": 1}}, + exact_jobs, + exact_artifacts, + run_comparison, + ), + ( + "non-boolean repository visibility", + {**run, "repository": {**run_repository, "private": 0}}, + exact_jobs, + exact_artifacts, + run_comparison, + ), + ( + "wrong head repository", + {**run, "head_repository": {**run_head_repository, "id": 1}}, + exact_jobs, + exact_artifacts, + run_comparison, + ), + ( + "wrong run attempt", + {**run, "run_attempt": 2}, + exact_jobs, + exact_artifacts, + run_comparison, + ), + ( + "boolean run attempt", + {**run, "run_attempt": True}, + exact_jobs, + exact_artifacts, + run_comparison, + ), + ( + "missing job", + run, + {"total_count": 4, "jobs": job_entries[:-1]}, + exact_artifacts, + run_comparison, + ), + ( + "extra job", + run, + { + "total_count": 6, + "jobs": [ + *job_entries, + { + "name": "unexpected job", + "run_id": BETA4_RECOVERY2_RUN_ID, + "run_attempt": 1, + "head_sha": BETA4_RECOVERY1_TOOLING_SHA, + "status": "completed", + "conclusion": "failure", + }, + ], + }, + exact_artifacts, + run_comparison, + ), + ( + "boolean job attempt", + run, + { + **exact_jobs, + "jobs": [ + {**job_entries[0], "run_attempt": True}, + *job_entries[1:], + ], + }, + exact_artifacts, + run_comparison, + ), + ( + "failure moved to workspace tests", + run, + jobs_with_steps(moved_failure_steps), + exact_artifacts, + run_comparison, + ), + ( + "boolean Windows step number", + run, + jobs_with_steps( + [ + {**recovery2_windows_steps()[0], "number": True}, + *recovery2_windows_steps()[1:], + ] + ), + exact_artifacts, + run_comparison, + ), + ( + "artifact ID", + run, + exact_jobs, + { + **exact_artifacts, + "artifacts": [{**artifact, "id": 9077851537}], + }, + run_comparison, + ), + ( + "artifact name", + run, + exact_jobs, + { + **exact_artifacts, + "artifacts": [{**artifact, "name": "unbound-artifact"}], + }, + run_comparison, + ), + ( + "artifact size drift", + run, + exact_jobs, + { + **exact_artifacts, + "artifacts": [{**artifact, "size_in_bytes": 142001291}], + }, + run_comparison, + ), + ( + "artifact digest", + run, + exact_jobs, + { + **exact_artifacts, + "artifacts": [{**artifact, "digest": "sha256:" + "0" * 64}], + }, + run_comparison, + ), + ( + "artifact repository", + run, + exact_jobs, + { + **exact_artifacts, + "artifacts": [ + { + **artifact, + "workflow_run": { + **artifact_run, + "repository_id": 1, + }, + } + ], + }, + run_comparison, + ), + ( + "artifact head", + run, + exact_jobs, + { + **exact_artifacts, + "artifacts": [ + { + **artifact, + "workflow_run": { + **artifact_run, + "head_sha": BETA4_SOURCE_SHA, + }, + } + ], + }, + run_comparison, + ), + ( + "missing artifact payload", + run, + exact_jobs, + {"total_count": 0, "artifacts": []}, + run_comparison, + ), + ( + "boolean artifact count", + run, + exact_jobs, + {**exact_artifacts, "total_count": True}, + run_comparison, + ), + ( + "malformed artifact payload", + run, + exact_jobs, + {"total_count": 1, "artifacts": ["not-an-object"]}, + run_comparison, + ), + ( + "expired artifact", + run, + exact_jobs, + { + **exact_artifacts, + "artifacts": [{**artifact, "expired": True}], + }, + run_comparison, + ), + ( + "non-boolean artifact expiry", + run, + exact_jobs, + { + **exact_artifacts, + "artifacts": [{**artifact, "expired": 0}], + }, + run_comparison, + ), + ( + "empty artifact", + run, + exact_jobs, + { + **exact_artifacts, + "artifacts": [{**artifact, "size_in_bytes": 0}], + }, + run_comparison, + ), + ( + "artifact run", + run, + exact_jobs, + { + **exact_artifacts, + "artifacts": [ + { + **artifact, + "workflow_run": { + **artifact_run, + "id": BETA4_FAILED_RUN_ID, + }, + } + ], + }, + run_comparison, + ), + ( + "prior tooling ancestry", + run, + exact_jobs, + exact_artifacts, + { + **run_comparison, + "merge_base_commit": {"sha": BETA4_SOURCE_SHA}, + }, + ), + ( + "current tooling comparison head", + run, + exact_jobs, + exact_artifacts, + { + **run_comparison, + "commits": [{"sha": "4" * 40}], + }, + ), + ( + "truncated tooling comparison commits", + run, + exact_jobs, + exact_artifacts, + { + **run_comparison, + "ahead_by": 2, + "total_commits": 2, + }, + ), + ( + "boolean comparison counts", + run, + exact_jobs, + exact_artifacts, + { + **run_comparison, + "ahead_by": True, + "behind_by": False, + }, + ), + ) + for name, mutated_run, mutated_jobs, mutated_artifacts, mutated_compare in mutations: + with self.subTest(name=name): + with self.assertRaises(contract.RecoveryRunError): + contract.validate_failed_recovery_run( + mutated_run, + mutated_jobs, + mutated_artifacts, + mutated_compare, + expected_run_id=BETA4_RECOVERY2_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, + ) + + def test_recovery_rejects_an_unlisted_failed_run(self) -> None: + with self.assertRaises(contract.RecoveryRunError): + contract.validate_failed_recovery_run( + { + "id": 99999999999, + "name": "Release", + "path": ".github/workflows/release.yml", + "event": "workflow_dispatch", + "status": "completed", + "conclusion": "failure", + "head_branch": "main", + "head_sha": BETA4_RECOVERY1_TOOLING_SHA, + "run_attempt": 1, + }, + {"total_count": 0, "jobs": []}, + {"total_count": 0, "artifacts": []}, + recovery_comparison( + BETA4_RECOVERY1_TOOLING_SHA, + BETA4_NEXT_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, + ) + + def test_dispatch_contract_requires_root_and_predecessor_run_ids(self) -> None: + document = contract._parse_workflow_yaml(WORKFLOW) + inputs = document["on"]["workflow_dispatch"]["inputs"] + self.assertEqual( + {"tag", "failed_run_id", "failed_recovery_run_id"}, + set(inputs), + ) + self.assertTrue(inputs["failed_run_id"]["required"]) + self.assertEqual( + "Root failed tag-push Release run ID (31412976593) for the immutable source", + inputs["failed_run_id"]["description"], + ) + self.assertEqual( + { + "description": "Previous failed workflow_dispatch recovery Release run ID (31427093503) chained to the same immutable source", + "required": True, + "type": "string", + }, + inputs["failed_recovery_run_id"], + ) + + 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" + " required: true\n" + " type: string\n", + " failed_recovery_run_id:\n" + " description: Optional arbitrary run\n" + " required: false\n" + " type: string\n", + ) + self.assert_rejected(mutated, "tag-only release trigger") def test_contract_paths_can_be_bound_to_an_external_workflow_copy(self) -> None: with tempfile.TemporaryDirectory() as directory: @@ -496,14 +1066,34 @@ def test_dispatch_recovery_cannot_bypass_failed_run_provenance(self) -> None: ' test "$tooling_sha" = "$remote_main"\n', ' test -n "$tooling_sha"\n', ), + ( + ' test "$FAILED_RUN_ID" = "31412976593"\n', + ' test -n "$FAILED_RUN_ID"\n', + ), + ( + ' test "$FAILED_RECOVERY_RUN_ID" = "31427093503"\n', + ' test -n "$FAILED_RECOVERY_RUN_ID"\n', + ), ( ' --run-id "$FAILED_RUN_ID" \\\n', ' --run-id 31412976593 \\\n', ), ( + ' gh api "repos/$GITHUB_REPOSITORY/compare/$source_sha...$predecessor_tooling_sha" \\\n', ' gh api "repos/$GITHUB_REPOSITORY/compare/$source_sha...$remote_main" \\\n', + ), + ( + ' gh api "repos/$GITHUB_REPOSITORY/compare/$predecessor_tooling_sha...$remote_main" \\\n', ' gh api "repos/$GITHUB_REPOSITORY/compare/$remote_main...$remote_main" \\\n', ), + ( + ' gh api "repos/$GITHUB_REPOSITORY/actions/runs/$FAILED_RECOVERY_RUN_ID/artifacts?per_page=100" \\\n', + ' gh api "repos/$GITHUB_REPOSITORY/actions/runs/$FAILED_RECOVERY_RUN_ID/artifacts?per_page=1" \\\n', + ), + ( + ' FAILED_RECOVERY_RUN_ID: ${{ github.event_name == \'workflow_dispatch\' && inputs.failed_recovery_run_id || \'\' }}\n', + ' FAILED_RECOVERY_RUN_ID: 31427093503\n', + ), ) for old, new in mutations: with self.subTest(mutation=old.strip()): @@ -998,6 +1588,19 @@ def test_remote_tag_is_revalidated_before_draft_and_publication(self) -> None: self.assert_rejected(second, "remote tag rebound before publication") def test_permissions_are_read_by_default_and_write_only_for_publish(self) -> None: + document = contract._parse_workflow_yaml(WORKFLOW) + self.assertEqual( + {"actions": "read", "contents": "read"}, + document["jobs"]["validate"]["permissions"], + ) + validate_without_actions = self.mutate( + " permissions:\n actions: read\n contents: read\n", + " permissions:\n contents: read\n", + ) + self.assert_rejected( + validate_without_actions, + "validate-only Actions read permission", + ) top_level_write = self.mutate( "permissions:\n contents: read\n", "permissions:\n contents: write\n", @@ -1187,11 +1790,71 @@ def test_macos_package_command_cannot_be_faked_by_an_echo_string(self) -> None: def test_windows_package_command_cannot_be_faked_by_a_string(self) -> None: mutated = self.mutate( - " & .\\web\\node_modules\\.bin\\tauri.cmd build --ci --bundles msi,nsis --config '{\"bundle\":{\"createUpdaterArtifacts\":true}}'\n", - " $decoy = '& .\\web\\node_modules\\.bin\\tauri.cmd build --ci --bundles msi,nsis --config {\"bundle\":{\"createUpdaterArtifacts\":true}}'\n", + " & .\\web\\node_modules\\.bin\\tauri.cmd @tauriArguments\n", + " $decoy = '& .\\web\\node_modules\\.bin\\tauri.cmd @tauriArguments'\n", ) self.assert_rejected(mutated, "complete Windows x64 installer gate") + def test_windows_build_config_is_one_runner_temp_file_argument(self) -> None: + document = contract._parse_workflow_yaml(WORKFLOW) + windows = document["jobs"]["windows_x64"] + build = next( + step + for step in windows["steps"] + if step.get("name") + == "Build native MSI, NSIS, and signed updater artifacts" + ) + script = build["run"] + required = ( + '$configPath = Join-Path $env:RUNNER_TEMP "opentake-windows-updater-$env:GITHUB_RUN_ID-$env:GITHUB_RUN_ATTEMPT.json"', + "$configJson = '{\"bundle\":{\"createUpdaterArtifacts\":true}}'", + "$utf8NoBom = [System.Text.UTF8Encoding]::new($false)", + "[System.IO.File]::WriteAllText($configPath, $configJson, $utf8NoBom)", + "$parsedConfig = [System.IO.File]::ReadAllText($configPath, [System.Text.Encoding]::UTF8) | ConvertFrom-Json -AsHashtable", + "$tauriArguments = @(", + "'--config'", + "$configPath", + "& .\\web\\node_modules\\.bin\\tauri.cmd @tauriArguments", + ) + for marker in required: + with self.subTest(marker=marker): + self.assertIn(marker, script) + self.assertNotIn("--config '{", script) + + def test_windows_build_config_file_contract_is_fail_closed(self) -> None: + mutations = ( + ( + '$configPath = Join-Path $env:RUNNER_TEMP "opentake-windows-updater-$env:GITHUB_RUN_ID-$env:GITHUB_RUN_ATTEMPT.json"', + '$configPath = Join-Path $env:GITHUB_WORKSPACE "tauri-release.json"', + ), + ( + "$utf8NoBom = [System.Text.UTF8Encoding]::new($false)", + "$utf8NoBom = [System.Text.UTF8Encoding]::new($true)", + ), + ( + "$configJson = '{\"bundle\":{\"createUpdaterArtifacts\":true}}'", + "$configJson = '{\"bundle\":{\"createUpdaterArtifacts\":false}}'", + ), + ( + "if ($configItem.PSIsContainer -or (($configItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0)) {", + "if ($configItem.PSIsContainer) {", + ), + ( + " '--config'\n $configPath\n", + " '--config'\n $configJson\n", + ), + ( + "& .\\web\\node_modules\\.bin\\tauri.cmd @tauriArguments", + "Write-Host '& .\\web\\node_modules\\.bin\\tauri.cmd @tauriArguments'", + ), + ) + for before, after in mutations: + with self.subTest(before=before): + self.assert_rejected( + self.mutate(before, after), + "complete Windows x64 installer gate", + ) + 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', @@ -1216,6 +1879,26 @@ def test_release_notes_document_normal_push_and_dual_sha_recovery(self) -> None: ["Beta 4 release notes document dual-SHA recovery provenance"], contract.validate_release_notes_contract(notes), ) + canonical = RELEASE_NOTES_PATH.read_text(encoding="utf-8") + for marker in ( + "`failed_run_id=31412976593`", + "`failed_recovery_run_id=31427093503`", + "`2c4efdff9d2587c90cbcac0919f9d1d333d67d6a`", + "`6162466834bbabb8a16a2c08808e03a53c2b22b6`", + ): + with self.subTest(marker=marker): + with tempfile.TemporaryDirectory() as directory: + notes = Path(directory) / "notes.md" + notes.write_text( + canonical.replace(marker, "`redacted`"), + encoding="utf-8", + ) + self.assertEqual( + [ + "Beta 4 release notes document dual-SHA recovery provenance" + ], + contract.validate_release_notes_contract(notes), + ) class ReleaseRepositoryMetadataTests(unittest.TestCase): diff --git a/scripts/test_check_windows_product_ci.py b/scripts/test_check_windows_product_ci.py index f3c31e28..7ff394c0 100644 --- a/scripts/test_check_windows_product_ci.py +++ b/scripts/test_check_windows_product_ci.py @@ -384,6 +384,80 @@ def test_product_cache_cannot_restore_target_outputs(self) -> None: ) self.assert_rejected(mutated, "product cache excludes target") + def test_product_build_uses_exact_runner_temp_tauri_config_argument( + self, + ) -> None: + document = contract.parse_workflow_yaml(WORKFLOW) + job = document["jobs"]["windows-product"] + bundle = next( + step + for step in job["steps"] + if step.get("name") == "Build native MSI and NSIS installers" + ) + script = bundle["run"] + required = ( + '$configPath = Join-Path $env:RUNNER_TEMP "opentake-windows-ci-$env:GITHUB_RUN_ID-$env:GITHUB_RUN_ATTEMPT.json"', + "$configJson = '{\"bundle\":{\"createUpdaterArtifacts\":false}}'", + "$utf8NoBom = [System.Text.UTF8Encoding]::new($false)", + "[System.IO.File]::WriteAllText($configPath, $configJson, $utf8NoBom)", + "[System.IO.FileAttributes]::ReparsePoint", + "ConvertFrom-Json -AsHashtable", + "$bundleConfig['createUpdaterArtifacts'] -ne $false", + "$tauriArguments = @(", + "'--config'", + "$configPath", + "& .\\web\\node_modules\\.bin\\tauri.cmd @tauriArguments", + ) + for marker in required: + with self.subTest(marker=marker): + self.assertIn(marker, script) + self.assertNotIn("--config '{", script) + self.assertRegex(contract.PRODUCT_BUNDLE_STEP_SHA256, r"^[0-9a-f]{64}$") + + def test_product_tauri_config_step_drift_breaks_frozen_digest(self) -> None: + self.assert_job_mutation_rejected( + "windows-product", + "throw 'Tauri config path escaped RUNNER_TEMP'", + "throw 'Tauri config left its temporary directory'", + "exact Windows product Tauri bundle step", + ) + + def test_product_tauri_config_file_contract_is_fail_closed(self) -> None: + mutations = ( + ( + '$configPath = Join-Path $env:RUNNER_TEMP "opentake-windows-ci-$env:GITHUB_RUN_ID-$env:GITHUB_RUN_ATTEMPT.json"', + '$configPath = Join-Path $env:GITHUB_WORKSPACE "tauri-ci.json"', + ), + ( + "$utf8NoBom = [System.Text.UTF8Encoding]::new($false)", + "$utf8NoBom = [System.Text.UTF8Encoding]::new($true)", + ), + ( + "$configJson = '{\"bundle\":{\"createUpdaterArtifacts\":false}}'", + "$configJson = '{\"bundle\":{\"createUpdaterArtifacts\":true}}'", + ), + ( + "if ($configItem.PSIsContainer -or (($configItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0)) {", + "if ($configItem.PSIsContainer) {", + ), + ( + " '--config'\n $configPath\n", + " '--config'\n $configJson\n", + ), + ( + "& .\\web\\node_modules\\.bin\\tauri.cmd @tauriArguments", + "Write-Host '& .\\web\\node_modules\\.bin\\tauri.cmd @tauriArguments'", + ), + ) + for before, after in mutations: + with self.subTest(before=before): + self.assert_job_mutation_rejected( + "windows-product", + before, + after, + "exact runner-temp Tauri config argument", + ) + def test_installed_product_must_launch(self) -> None: without_launch = WORKFLOW.replace( " $app = Start-Process -FilePath $application -PassThru\n",