diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3997f295..4a572ffb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,6 +9,10 @@ on: description: Existing v tag from a failed run; this workflow never creates or moves tags required: true type: string + failed_run_id: + description: Failed tag-push Release run ID whose exact source is being recovered + required: true + type: string permissions: contents: read @@ -23,8 +27,11 @@ jobs: runs-on: ubuntu-latest 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 || '' }} + RELEASE_TOOLING_SHA: ${{ github.workflow_sha }} outputs: source_sha: ${{ steps.bind.outputs.source_sha }} + tooling_sha: ${{ steps.bind.outputs.tooling_sha }} tag: ${{ steps.bind.outputs.tag }} version: ${{ steps.bind.outputs.version }} notes_path: ${{ steps.bind.outputs.notes_path }} @@ -38,6 +45,8 @@ jobs: - name: Validate tag, source SHA, versions, and notes id: bind + env: + GH_TOKEN: ${{ github.token }} shell: bash run: | set -euo pipefail @@ -49,15 +58,55 @@ jobs: git cat-file -e "${source_sha}^{commit}" test -z "$(git status --porcelain=v1 --untracked-files=all)" + tooling_sha="$(printf '%s' "$RELEASE_TOOLING_SHA" | tr '[:upper:]' '[:lower:]')" + [[ "$tooling_sha" =~ ^[0-9a-f]{40}$ ]] 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" - if [[ "$source_sha" != "$remote_main" ]]; then - echo "tag commit does not equal current remote main HEAD" >&2 + if [[ "$GITHUB_EVENT_NAME" = "push" ]]; then + test -z "$FAILED_RUN_ID" + test "$tooling_sha" = "$source_sha" + if [[ "$source_sha" != "$remote_main" ]]; then + echo "tag commit does not equal current remote main HEAD" >&2 + exit 1 + fi + elif [[ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]]; then + [[ "$FAILED_RUN_ID" =~ ^[1-9][0-9]*$ ]] + test "$tooling_sha" = "$remote_main" + recovery_root="$RUNNER_TEMP/opentake-release-recovery-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" + mkdir -p "$recovery_root/tooling" + if ! git cat-file -e "${tooling_sha}^{commit}" 2>/dev/null; then + git fetch --no-tags --depth=1 origin "$tooling_sha" + fi + test "$(git rev-parse "${tooling_sha}^{commit}")" = "$tooling_sha" + git cat-file blob "$tooling_sha:scripts/check_release_workflow.py" \ + > "$recovery_root/tooling/check_release_workflow.py" + git cat-file blob "$tooling_sha:scripts/workflow_yaml.py" \ + > "$recovery_root/tooling/workflow_yaml.py" + 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" + 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" + 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-id "$FAILED_RUN_ID" \ + --tag "$RELEASE_TAG" \ + --sha "$source_sha" + else + echo "unsupported release event: $GITHUB_EVENT_NAME" >&2 exit 1 fi printf 'source_sha=%s\n' "$source_sha" >> "$GITHUB_OUTPUT" + printf 'tooling_sha=%s\n' "$tooling_sha" >> "$GITHUB_OUTPUT" RELEASE_TAG="$RELEASE_TAG" python3 - <<'PY' import json import os @@ -131,6 +180,7 @@ jobs: timeout-minutes: 120 env: TARGET_SHA: ${{ needs.validate.outputs.source_sha }} + RELEASE_TOOLING_SHA: ${{ needs.validate.outputs.tooling_sha }} CI: true steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 @@ -214,15 +264,60 @@ jobs: - name: Validate Windows and release workflow contracts run: | + set -euo pipefail python3 -B scripts/check_windows_product_ci.py python3 -B -m unittest discover -s scripts -p 'test_check_windows_product_ci.py' - python3 -B scripts/check_release_workflow.py - python3 -B -m unittest discover -s scripts -p 'test_check_release_workflow.py' python3 -B -m unittest discover -s scripts -p 'test_write_updater_attestation.py' python3 -B -m unittest discover -s scripts -p 'test_write_updater_manifest.py' + tooling_root="$RUNNER_TEMP/opentake-release-tooling-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" + mkdir -p "$tooling_root" + 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" \ + > "$tooling_root/check_release_workflow.py" + git cat-file blob "$RELEASE_TOOLING_SHA:scripts/test_check_release_workflow.py" \ + > "$tooling_root/test_check_release_workflow.py" + git cat-file blob "$RELEASE_TOOLING_SHA:scripts/workflow_yaml.py" \ + > "$tooling_root/workflow_yaml.py" + git cat-file blob "$RELEASE_TOOLING_SHA:scripts/provision_ffmpeg_sidecars.py" \ + > "$tooling_root/provision_ffmpeg_sidecars.py" + git cat-file blob "$RELEASE_TOOLING_SHA:scripts/tests/test_provision_ffmpeg_sidecars.py" \ + > "$tooling_root/test_provision_ffmpeg_sidecars.py" + git cat-file blob "$RELEASE_TOOLING_SHA:.github/workflows/release.yml" \ + > "$tooling_root/release.yml" + git cat-file blob "$RELEASE_TOOLING_SHA:docs/releases/1.0.0-beta.4.md" \ + > "$tooling_root/release-notes.md" + test -s "$tooling_root/check_release_workflow.py" + test -s "$tooling_root/test_check_release_workflow.py" + test -s "$tooling_root/workflow_yaml.py" + test -s "$tooling_root/provision_ffmpeg_sidecars.py" + test -s "$tooling_root/test_provision_ffmpeg_sidecars.py" + test -s "$tooling_root/release.yml" + test -s "$tooling_root/release-notes.md" + OPENTAKE_REPOSITORY_ROOT="$GITHUB_WORKSPACE" \ + OPENTAKE_RELEASE_WORKFLOW_PATH="$tooling_root/release.yml" \ + OPENTAKE_RELEASE_NOTES_PATH="$tooling_root/release-notes.md" \ + PYTHONDONTWRITEBYTECODE=1 PYTHONPATH="$tooling_root" \ + python3 -B "$tooling_root/check_release_workflow.py" + OPENTAKE_REPOSITORY_ROOT="$GITHUB_WORKSPACE" \ + OPENTAKE_RELEASE_WORKFLOW_PATH="$tooling_root/release.yml" \ + OPENTAKE_RELEASE_NOTES_PATH="$tooling_root/release-notes.md" \ + PYTHONDONTWRITEBYTECODE=1 PYTHONPATH="$tooling_root" \ + python3 -B -m unittest discover -s "$tooling_root" \ + -p 'test_check_release_workflow.py' - name: Provisioner unit tests - run: python3 -B -m unittest discover -s scripts/tests -p 'test_*.py' + run: | + set -euo pipefail + python3 -B -m unittest discover -s scripts/tests -p 'test_*.py' + tooling_root="$RUNNER_TEMP/opentake-release-tooling-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" + OPENTAKE_REPOSITORY_ROOT="$GITHUB_WORKSPACE" \ + OPENTAKE_PROVISIONER_PATH="$tooling_root/provision_ffmpeg_sidecars.py" \ + PYTHONDONTWRITEBYTECODE=1 PYTHONPATH="$tooling_root" \ + python3 -B -m unittest discover \ + -s "$tooling_root" -p 'test_provision_ffmpeg_sidecars.py' - name: Install locked Web dependencies run: pnpm -C web install --frozen-lockfile @@ -527,6 +622,7 @@ jobs: timeout-minutes: 120 env: TARGET_SHA: ${{ needs.validate.outputs.source_sha }} + RELEASE_TOOLING_SHA: ${{ needs.validate.outputs.tooling_sha }} RELEASE_TAG: ${{ needs.validate.outputs.tag }} RELEASE_VERSION: ${{ needs.validate.outputs.version }} CI: true @@ -575,7 +671,30 @@ jobs: ruby-version: '3.3' - name: Provision checksum-pinned Windows FFmpeg sidecars - run: python scripts/provision_ffmpeg_sidecars.py --target x86_64-pc-windows-msvc + shell: pwsh + run: | + if ($env:RELEASE_TOOLING_SHA -notmatch '^[0-9a-f]{40}$') { + throw 'release tooling SHA must be lowercase 40-hex' + } + $toolingRoot = Join-Path $env:RUNNER_TEMP "opentake-sidecar-tooling-$env:GITHUB_RUN_ID-$env:GITHUB_RUN_ATTEMPT" + New-Item -ItemType Directory -Force -Path $toolingRoot | Out-Null + $provisioner = Join-Path $toolingRoot 'provision_ffmpeg_sidecars.py' + git cat-file -e "$($env:RELEASE_TOOLING_SHA)^{commit}" 2>$null + if ($LASTEXITCODE -ne 0) { + git fetch --no-tags --depth=1 origin $env:RELEASE_TOOLING_SHA + if ($LASTEXITCODE -ne 0) { throw 'failed to fetch exact release tooling commit' } + } + $resolvedTooling = (git rev-parse "$($env:RELEASE_TOOLING_SHA)^{commit}").Trim().ToLowerInvariant() + if ($LASTEXITCODE -ne 0 -or $resolvedTooling -ne $env:RELEASE_TOOLING_SHA) { + throw 'release tooling commit did not resolve exactly' + } + git cat-file blob "$($env:RELEASE_TOOLING_SHA):scripts/provision_ffmpeg_sidecars.py" > $provisioner + if ($LASTEXITCODE -ne 0) { throw 'failed to extract exact sidecar provisioner blob' } + if (-not (Test-Path $provisioner -PathType Leaf) -or (Get-Item $provisioner).Length -le 0) { + throw 'release sidecar provisioner blob is missing or empty' + } + $env:OPENTAKE_REPOSITORY_ROOT = $env:GITHUB_WORKSPACE + python $provisioner --target x86_64-pc-windows-msvc - name: Verify pinned sidecar supply shell: bash @@ -806,6 +925,7 @@ jobs: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ needs.validate.outputs.tag }} RELEASE_SHA: ${{ needs.validate.outputs.source_sha }} + RELEASE_TOOLING_SHA: ${{ needs.validate.outputs.tooling_sha }} RELEASE_VERSION: ${{ needs.validate.outputs.version }} NOTES_PATH: ${{ needs.validate.outputs.notes_path }} PYTHONDONTWRITEBYTECODE: '1' @@ -1317,12 +1437,27 @@ jobs: shell: bash run: | set -euo pipefail - cp "$NOTES_PATH" "$PUBLISH_ROOT/release-body.md" + notes_sha="$RELEASE_SHA" + if [[ "$RELEASE_TOOLING_SHA" = "$RELEASE_SHA" ]]; then + cp "$NOTES_PATH" "$PUBLISH_ROOT/release-body.md" + else + [[ "$RELEASE_TOOLING_SHA" =~ ^[0-9a-f]{40}$ ]] + 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:$NOTES_PATH" \ + > "$PUBLISH_ROOT/release-body.md" + notes_sha="$RELEASE_TOOLING_SHA" + fi + test -s "$PUBLISH_ROOT/release-body.md" cat >> "$PUBLISH_ROOT/release-body.md" <` tag;tag 必须指向 -远端 `main` 的当前 HEAD,workflow 不创建、移动或复用 tag。Cargo、Tauri 与 Web 版本均为 -`1.0.0-beta.4`,Windows WiX 安装器版本为 `1.0.0.4`,并由独立发布合约 fail closed 校验。 +`.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` 的 +`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,验证版本、WiX、本文档、prerelease 语义和洁净 - checkout;质量门禁执行依赖锁定安装、audit、格式、clippy、workspace/Web 测试及工作流合同。 +1. validate job 解析 immutable source SHA;恢复路径还会逐项核对原 run 的五个 job 与 Windows + 失败 step,并证明 source 是当前 `main` 的祖先。随后验证版本、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 安装器仍不是 @@ -45,6 +52,8 @@ 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。 Updater 私钥不写入 checkout、receipt、manifest、日志或发布 artifact;缺少任一 secret 时流程 失败,绝不退化为未签名 updater。 diff --git a/scripts/check_release_workflow.py b/scripts/check_release_workflow.py index cc87c072..513e06b3 100644 --- a/scripts/check_release_workflow.py +++ b/scripts/check_release_workflow.py @@ -6,6 +6,7 @@ import argparse import hashlib import json +import os from pathlib import Path import re import shlex @@ -16,8 +17,21 @@ from workflow_yaml import WorkflowYamlError, parse_workflow_yaml as _parse_workflow_yaml -REPOSITORY_ROOT = Path(__file__).resolve().parents[1] -WORKFLOW_PATH = REPOSITORY_ROOT / ".github" / "workflows" / "release.yml" +REPOSITORY_ROOT = Path( + os.environ.get("OPENTAKE_REPOSITORY_ROOT", Path(__file__).resolve().parents[1]) +).resolve() +WORKFLOW_PATH = Path( + os.environ.get( + "OPENTAKE_RELEASE_WORKFLOW_PATH", + REPOSITORY_ROOT / ".github" / "workflows" / "release.yml", + ) +).resolve() +RELEASE_NOTES_PATH = Path( + os.environ.get( + "OPENTAKE_RELEASE_NOTES_PATH", + REPOSITORY_ROOT / "docs" / "releases" / "1.0.0-beta.4.md", + ) +).resolve() PINNED_ACTIONS = { "actions/checkout": "11d5960a326750d5838078e36cf38b85af677262", "actions/setup-node": "49933ea5288caeca8642d1e84afbd3f7d6820020", @@ -130,7 +144,6 @@ APPROVED_SIMPLE_RUNS = { ("quality", "Install Rust toolchain"): "rustup component add rustfmt clippy", ("quality", "Install locked Motion Canvas dependencies"): "npm --prefix plugins/motion-canvas-studio ci --ignore-scripts", - ("quality", "Provisioner unit tests"): "python3 -B -m unittest discover -s scripts/tests -p 'test_*.py'", ("quality", "Install locked Web dependencies"): "pnpm -C web install --frozen-lockfile", ("quality", "Rust formatting"): "cargo fmt --all --check", ("quality", "Rust workspace clippy"): "cargo clippy --workspace --all-targets -- -D warnings", @@ -144,7 +157,6 @@ ("macos_arm64", "Install locked Web dependencies"): "pnpm -C web install --frozen-lockfile", ("macos_arm64", "Build ad-hoc Tauri app, DMG, and signed updater"): "./web/node_modules/.bin/tauri build --ci --target aarch64-apple-darwin --bundles app,dmg --config '{\"bundle\":{\"createUpdaterArtifacts\":true,\"macOS\":{\"signingIdentity\":\"-\"}}}'", ("windows_x64", "Install Rust toolchain"): "rustup component add rustfmt clippy", - ("windows_x64", "Provision checksum-pinned Windows FFmpeg sidecars"): "python scripts/provision_ffmpeg_sidecars.py --target x86_64-pc-windows-msvc", ("windows_x64", "Verify pinned sidecar supply"): "ruby scripts/tests/packaged-sidecars-test.rb --name packaged_macos_windows_sidecars_resolve_and_execute", ("windows_x64", "Install locked Web dependencies"): "pnpm -C web install --frozen-lockfile", ("windows_x64", "Rust workspace clippy"): "cargo clippy --workspace --all-targets -- -D warnings", @@ -157,14 +169,15 @@ } APPROVED_COMPLEX_RUN_SHA256 = { - ("validate", "Validate tag, source SHA, versions, and notes"): "a8098fcb30554344d8821c32540b2116efef5877c4fc992cd2a98b955e73a346", + ("validate", "Validate tag, source SHA, versions, and notes"): "5a1c640ad928939cde6666cdfd6374b477862f99b0e3c12d8d3632c27eb4c80c", ("validate", "Reassert exact source after validation"): "953657d26d2eda8490c18e7030c66ddb19aba64a5c8b19808da9a853fd1bfdd2", ("quality", "Assert exact checked-out SHA"): "ff0b148eecdf8603712586a6c4a05e752df0b36b5c97a366760f6cba10e58ddd", ("quality", "Free disk space"): "5848415c4d0e696f46965d62a2e17c8b7a0dd45ae600d28102af0b04108d9bf6", ("quality", "Install system deps (ffmpeg + Tauri/GTK)"): "ee466d2d3fff1c3703d50f9dabe4d21e1cee4b399924d064c6d2714dae34d16b", ("quality", "Audit Motion Canvas dependencies and licenses"): "a3517fae1a8663e519138196c9f3721d8f4df19ac8f115c49a079c4aaa60c8b3", ("quality", "Test and reproduce Motion Canvas runner"): "8bcd55de9b045f9d7be6343163a5422cba0ab545f7844da50ca1a7c8623fe640", - ("quality", "Validate Windows and release workflow contracts"): "a3eee0e4912440340c2be717b3b635f9eb121f68e8f3dd1e3c58f19b4c2ace36", + ("quality", "Validate Windows and release workflow contracts"): "f70c00caca2a1ea66ce7843bd5e4b6e9702422d2ad0cc7d77d714decdb93b351", + ("quality", "Provisioner unit tests"): "f57d4d7d6df403d573d31bbda02589804109596040c2cefcca60f3e9352e891a", ("quality", "Live playback transport integration"): "461f79546009551e5e7adbf50f869abb9449c2ae7666a66a425c7cd3c24acea9", ("quality", "Reassert exact source after quality gates"): "953657d26d2eda8490c18e7030c66ddb19aba64a5c8b19808da9a853fd1bfdd2", ("macos_arm64", "Assert exact checked-out SHA"): "ff0b148eecdf8603712586a6c4a05e752df0b36b5c97a366760f6cba10e58ddd", @@ -176,6 +189,7 @@ ("macos_arm64", "Create macOS exact-SHA receipt"): "06f89a7122f8257ad14b8ae5fa59426e87ecbae4936f0db69a37ee6cc748bd2a", ("windows_x64", "Assert exact checked-out SHA"): "ff0b148eecdf8603712586a6c4a05e752df0b36b5c97a366760f6cba10e58ddd", ("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", "Install NSIS and smoke installed app, sidecars, and updater artifacts"): "556af3fe7ee52b0dfde26824abf39b589c897782f1a311a8c64a044dc3cf7010", @@ -189,7 +203,7 @@ ("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"): "efa2e3d9cd3b576a5e6ef3f25788a9d2156e0c0a5c8f53d3f8f9a1dbc1bbb5ea", + ("publish", "Prepare release notes with provenance"): "3f8dc1f9ab2680cb4d0c7989830cac37e536844bdbbee05f8955dfb40673a271", ("publish", "Reassert exact source before draft mutation"): "953657d26d2eda8490c18e7030c66ddb19aba64a5c8b19808da9a853fd1bfdd2", ("publish", "Revalidate remote tag before draft mutation"): "2eac9a1203d96969b545c9447b9637c8ad16689d2704b32c7e710e9ae4bff47c", ("publish", "Create or refresh draft prerelease"): "e3cee76806b604359715ec91cf1a7c6d7c8919e05dd5b5c99f77b98686debc6b", @@ -200,13 +214,96 @@ } APPROVED_JOB_SHA256 = { - "validate": "36c12ee29b323f04d1ac48b046de6786d130e85bae8e89d831bb64c96f2b5c26", - "quality": "54f73cef43a8a808cf481f807cf08fccfa281112cd4c365edd06a1d7d6f68187", + "validate": "e493b3756464fe932b402b2aab9e36575bbeb58be1ae80fea28dfa0cb335cbb4", + "quality": "a2947370289ebd299042159fbe8fd046f7fedf72b47037d58b4398ed8e85baee", "macos_arm64": "1785d765c96278190c25e312c9e610070619e17b7b2b0d922f0bd234501df525", - "windows_x64": "7a95b56230b09f34c3abf31a9d1c226e290115934285eab008568ade516145c3", - "publish": "945d07ecb63de230c8ba38f27a082a019370ee9e233adf9ae733f32669c231cb", + "windows_x64": "b7cff85dc4201f30fce5ade71687993ce185b913b1ba62f29d6b031faa5311f2", + "publish": "c7d84471185df270f1d30129b936dccc8cb6c1069020e34924037be2976425ec", } +EXPECTED_RECOVERY_JOB_CONCLUSIONS = { + "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", +} +EXPECTED_RECOVERY_WINDOWS_STEPS = ( + ("Set up job", "completed", "success"), + ( + f"Run actions/checkout@{PINNED_ACTIONS['actions/checkout']}", + "completed", + "success", + ), + ("Assert exact checked-out SHA", "completed", "success"), + ("Require updater signing secrets", "completed", "success"), + ("Install Rust toolchain", "completed", "success"), + ( + f"Run pnpm/action-setup@{PINNED_ACTIONS['pnpm/action-setup']}", + "completed", + "success", + ), + ( + f"Run actions/setup-node@{PINNED_ACTIONS['actions/setup-node']}", + "completed", + "success", + ), + ( + f"Run ruby/setup-ruby@{PINNED_ACTIONS['ruby/setup-ruby']}", + "completed", + "success", + ), + ( + "Provision checksum-pinned Windows FFmpeg sidecars", + "completed", + "failure", + ), + ("Verify pinned sidecar supply", "completed", "skipped"), + ("Cache Cargo dependencies", "completed", "skipped"), + ("Install locked Web dependencies", "completed", "skipped"), + ("Rust workspace clippy", "completed", "skipped"), + ("Rust workspace tests", "completed", "skipped"), + ("Web editor behavior suite", "completed", "skipped"), + ("Minimal-feature Tauri clippy", "completed", "skipped"), + ("Web production build", "completed", "skipped"), + ("Reassert exact source before Windows build", "completed", "skipped"), + ( + "Build native MSI, NSIS, and signed updater artifacts", + "completed", + "skipped", + ), + ( + "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", + ), + ( + 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_RECOVERY_WINDOWS_STEP_NUMBERS = (*range(1, 25), 46, 47, 48, 49) + class ReleaseStateError(ValueError): """The remote release state is unsafe to create, refresh, or publish.""" @@ -216,6 +313,103 @@ class RemoteTagError(ValueError): """The remote tag advertisement is missing, ambiguous, or malformed.""" +class RecoveryRunError(ValueError): + """The requested failed-run recovery is not bound to a trusted tag push.""" + + +def validate_recovery_run( + run: dict[str, object], + jobs: dict[str, object], + comparison: dict[str, object], + *, + expected_run_id: int, + expected_tag: str, + expected_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") + required_run_fields = { + "id": expected_run_id, + "name": "Release", + "path": ".github/workflows/release.yml", + "event": "push", + "status": "completed", + "conclusion": "failure", + "head_branch": expected_tag, + "head_sha": expected_sha, + } + if any(run.get(field) != value for field, value in required_run_fields.items()): + raise RecoveryRunError("recovery run is not the failed exact-tag push") + + run_attempt = run.get("run_attempt") + total_count = jobs.get("total_count") + entries = jobs.get("jobs") + expected_job_count = len(EXPECTED_RECOVERY_JOB_CONCLUSIONS) + if ( + not isinstance(run_attempt, int) + or isinstance(run_attempt, bool) + or run_attempt <= 0 + or 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("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("recovery job set is not the exact failed release") + 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_run_id, + "run_attempt": run_attempt, + "head_sha": expected_sha, + "status": "completed", + "conclusion": conclusion, + } + if any(entry.get(field) != value for field, value in expected_fields.items()): + raise RecoveryRunError( + f"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 Windows job 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 ( + step_numbers != EXPECTED_RECOVERY_WINDOWS_STEP_NUMBERS + or step_outcomes != EXPECTED_RECOVERY_WINDOWS_STEPS + ): + raise RecoveryRunError( + "Windows failure is not the exact checksum-pinned sidecar step" + ) + + base = comparison.get("base_commit") + merge_base = comparison.get("merge_base_commit") + if ( + comparison.get("status") not in {"ahead", "identical"} + or not isinstance(base, dict) + or base.get("sha") != expected_sha + or not isinstance(merge_base, dict) + or merge_base.get("sha") != expected_sha + ): + raise RecoveryRunError("release source is not an ancestor of current main") + + def resolve_remote_tag_refs(refs_text: str, expected_tag: str) -> str: """Resolve a lightweight or annotated ls-remote tag to its commit SHA.""" direct_ref = f"refs/tags/{expected_tag}" @@ -552,16 +746,23 @@ def validate_workflow(workflow: str) -> list[str]: ) inputs = _as_mapping(dispatch.get("inputs")) if dispatch is not None else None tag_input = _as_mapping(inputs.get("tag")) if inputs is not None else None + failed_run_input = ( + _as_mapping(inputs.get("failed_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"} + or set(inputs) != {"tag", "failed_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("required") is not True + or failed_run_input.get("type") != "string" ): errors.append("tag-only release trigger") @@ -652,6 +853,8 @@ def validate_workflow(workflow: str) -> list[str]: if skipped: errors.append("required steps are unconditional") scalar_strings = _all_scalar_strings(document) + if any("raw.githubusercontent.com" in value for value in scalar_strings): + errors.append("release tooling never trusts raw HTTP downloads") signing_env = { "TAURI_SIGNING_PRIVATE_KEY": "${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}", "TAURI_SIGNING_PRIVATE_KEY_PASSWORD": "${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}", @@ -830,6 +1033,49 @@ def validate_workflow(workflow: str) -> list[str]: ("read", "-r", "remote_main", "remote_ref", "<", "<(git", "ls-remote", "--exit-code", "origin", "refs/heads/main)"), ): errors.append("tag commit equals current remote main HEAD") + validate_env = _as_mapping(validate.get("env")) if validate is not None else None + validate_outputs = ( + _as_mapping(validate.get("outputs")) if validate is not None else None + ) + recovery_lines = ( + 'tooling_sha="$(printf \'%s\' "$RELEASE_TOOLING_SHA" | tr \'[:upper:]\' \'[:lower:]\')"', + '[[ "$tooling_sha" =~ ^[0-9a-f]{40}$ ]]', + 'if [[ "$GITHUB_EVENT_NAME" = "push" ]]; then', + 'test -z "$FAILED_RUN_ID"', + 'test "$tooling_sha" = "$source_sha"', + 'elif [[ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]]; then', + '[[ "$FAILED_RUN_ID" =~ ^[1-9][0-9]*$ ]]', + 'test "$tooling_sha" = "$remote_main"', + '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"', + 'git cat-file blob "$tooling_sha:scripts/check_release_workflow.py" \\', + '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" \\', + 'validate-recovery-run \\', + '--run-id "$FAILED_RUN_ID" \\', + '--tag "$RELEASE_TAG" \\', + '--sha "$source_sha"', + 'printf \'tooling_sha=%s\\n\' "$tooling_sha" >> "$GITHUB_OUTPUT"', + ) + recovery_provenance = ( + validate_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 || '' }}", + "RELEASE_TOOLING_SHA": "${{ github.workflow_sha }}", + } + and validate_outputs is not None + and validate_outputs.get("tooling_sha") + == "${{ steps.bind.outputs.tooling_sha }}" + and bind is not None + and _as_mapping(bind.get("env")) == {"GH_TOKEN": "${{ github.token }}"} + and _has_code_lines(bind, recovery_lines) + ) + if not recovery_provenance: + errors.append("failed-run recovery provenance") if not _has_code_lines( bind, ( @@ -921,8 +1167,6 @@ def validate_workflow(workflow: str) -> list[str]: ("Test and reproduce Motion Canvas runner", ("git", "diff", "--exit-code", "--", "plugins/motion-canvas-studio/bundle/runner.html", "plugins/motion-canvas-studio/package-lock.json")), ("Validate Windows and release workflow contracts", ("python3", "-B", "scripts/check_windows_product_ci.py")), ("Validate Windows and release workflow contracts", ("python3", "-B", "-m", "unittest", "discover", "-s", "scripts", "-p", "test_check_windows_product_ci.py")), - ("Validate Windows and release workflow contracts", ("python3", "-B", "scripts/check_release_workflow.py")), - ("Validate Windows and release workflow contracts", ("python3", "-B", "-m", "unittest", "discover", "-s", "scripts", "-p", "test_check_release_workflow.py")), ("Validate Windows and release workflow contracts", ("python3", "-B", "-m", "unittest", "discover", "-s", "scripts", "-p", "test_write_updater_attestation.py")), ("Validate Windows and release workflow contracts", ("python3", "-B", "-m", "unittest", "discover", "-s", "scripts", "-p", "test_write_updater_manifest.py")), ("Provisioner unit tests", ("python3", "-B", "-m", "unittest", "discover", "-s", "scripts/tests", "-p", "test_*.py")), @@ -953,6 +1197,54 @@ def validate_workflow(workflow: str) -> list[str]: if not quality_ok: errors.append("complete Ubuntu release quality gates") + quality_env = _as_mapping(quality.get("env")) if quality is not None else None + quality_release_contract = _structured_step( + quality, "Validate Windows and release workflow contracts" + ) + running_contract_provenance = ( + quality_env is not None + and quality_env.get("RELEASE_TOOLING_SHA") + == "${{ needs.validate.outputs.tooling_sha }}" + and _has_code_lines( + quality_release_contract, + ( + '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/test_check_release_workflow.py" \\', + 'git cat-file blob "$RELEASE_TOOLING_SHA:scripts/workflow_yaml.py" \\', + 'git cat-file blob "$RELEASE_TOOLING_SHA:scripts/provision_ffmpeg_sidecars.py" \\', + 'git cat-file blob "$RELEASE_TOOLING_SHA:scripts/tests/test_provision_ffmpeg_sidecars.py" \\', + 'git cat-file blob "$RELEASE_TOOLING_SHA:.github/workflows/release.yml" \\', + 'git cat-file blob "$RELEASE_TOOLING_SHA:docs/releases/1.0.0-beta.4.md" \\', + 'OPENTAKE_REPOSITORY_ROOT="$GITHUB_WORKSPACE" \\', + 'OPENTAKE_RELEASE_WORKFLOW_PATH="$tooling_root/release.yml" \\', + 'OPENTAKE_RELEASE_NOTES_PATH="$tooling_root/release-notes.md" \\', + 'PYTHONDONTWRITEBYTECODE=1 PYTHONPATH="$tooling_root" \\', + 'python3 -B "$tooling_root/check_release_workflow.py"', + 'python3 -B -m unittest discover -s "$tooling_root" \\', + "-p 'test_check_release_workflow.py'", + ), + ) + ) + if not running_contract_provenance: + errors.append("exact release tooling provenance") + provisioner_tests = _structured_step(quality, "Provisioner unit tests") + recovery_provisioner_tests = _has_code_lines( + provisioner_tests, + ( + "python3 -B -m unittest discover -s scripts/tests -p 'test_*.py'", + 'OPENTAKE_REPOSITORY_ROOT="$GITHUB_WORKSPACE" \\', + 'OPENTAKE_PROVISIONER_PATH="$tooling_root/provision_ffmpeg_sidecars.py" \\', + 'PYTHONDONTWRITEBYTECODE=1 PYTHONPATH="$tooling_root" \\', + 'python3 -B -m unittest discover \\', + '-s "$tooling_root" -p \'test_provision_ffmpeg_sidecars.py\'', + ), + ) + if not recovery_provisioner_tests: + errors.append("recovery provisioner tests") + ruby_steps = _action_step(quality, "ruby/setup-ruby") validator_position = _step_position(quality, "Validate Windows and release workflow contracts") if ( @@ -1042,7 +1334,7 @@ def validate_workflow(workflow: str) -> list[str]: windows_receipt = _structured_step(windows, "Create Windows exact-SHA receipt") windows_uploads = _action_step(windows, "actions/upload-artifact") windows_commands = ( - ("Provision checksum-pinned Windows FFmpeg sidecars", ("python", "scripts/provision_ffmpeg_sidecars.py", "--target", "x86_64-pc-windows-msvc")), + ("Provision checksum-pinned Windows FFmpeg sidecars", ("python", "$provisioner", "--target", "x86_64-pc-windows-msvc")), ("Rust workspace clippy", ("cargo", "clippy", "--workspace", "--all-targets", "--", "-D", "warnings")), ("Rust workspace tests", ("cargo", "test", "--workspace", "--", "--test-threads=1")), ("Web editor behavior suite", ("pnpm", "-C", "web", "test")), @@ -1136,6 +1428,35 @@ def validate_workflow(workflow: str) -> list[str]: ) == f"actions/upload-artifact@{PINNED_ACTIONS['actions/upload-artifact']}" if not windows_ok: errors.append("complete Windows x64 installer gate") + windows_env = _as_mapping(windows.get("env")) if windows is not None else None + windows_provision = _structured_step( + windows, "Provision checksum-pinned Windows FFmpeg sidecars" + ) + windows_tooling_provenance = ( + windows_env is not None + and windows_env.get("RELEASE_TOOLING_SHA") + == "${{ needs.validate.outputs.tooling_sha }}" + and windows_provision is not None + and windows_provision.get("shell") == "pwsh" + and _has_code_lines( + windows_provision, + ( + "if ($env:RELEASE_TOOLING_SHA -notmatch '^[0-9a-f]{40}$') {", + 'git cat-file -e "$($env:RELEASE_TOOLING_SHA)^{commit}" 2>$null', + 'git fetch --no-tags --depth=1 origin $env:RELEASE_TOOLING_SHA', + '$resolvedTooling = (git rev-parse "$($env:RELEASE_TOOLING_SHA)^{commit}").Trim().ToLowerInvariant()', + 'git cat-file blob "$($env:RELEASE_TOOLING_SHA):scripts/provision_ffmpeg_sidecars.py" > $provisioner', + '$env:OPENTAKE_REPOSITORY_ROOT = $env:GITHUB_WORKSPACE', + 'python $provisioner --target x86_64-pc-windows-msvc', + ), + ) + and not any( + "raw.githubusercontent.com" in value + for value in _all_scalar_strings(windows_provision) + ) + ) + if not windows_tooling_provenance: + errors.append("exact release tooling provenance") mac_upload_with = ( _with_mapping(mac_uploads[0][1]) if len(mac_uploads) == 1 else None @@ -1674,10 +1995,29 @@ def remote_rebind_ok(step: dict[str, object] | None, output: str) -> bool: errors.append("final API verification binds target SHA") notes = _structured_step(publish, "Prepare release notes with provenance") + if not _has_code_lines( + notes, + ( + 'notes_sha="$RELEASE_SHA"', + 'if [[ "$RELEASE_TOOLING_SHA" = "$RELEASE_SHA" ]]; then', + 'cp "$NOTES_PATH" "$PUBLISH_ROOT/release-body.md"', + '[[ "$RELEASE_TOOLING_SHA" =~ ^[0-9a-f]{40}$ ]]', + '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:$NOTES_PATH" \\', + 'notes_sha="$RELEASE_TOOLING_SHA"', + 'test -s "$PUBLISH_ROOT/release-body.md"', + "- Release notes commit: \\`$notes_sha\\`", + ), + ): + errors.append("recovery release notes use exact tooling commit") if not _has_code_lines( notes, ( "- Source commit: \\`$RELEASE_SHA\\`", + "- Release tooling commit: \\`$RELEASE_TOOLING_SHA\\`", + "- Release notes commit: \\`$notes_sha\\`", "- GitHub Actions run: [$GITHUB_RUN_ID/$GITHUB_RUN_ATTEMPT]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID)", "- Updater trust: updater packages are signed with the dedicated Tauri updater key; the private key is supplied only from GitHub Actions secrets and is never published.", "- Platform signing limits: the macOS app uses ad-hoc signing only; it is not Developer ID signed or notarized. Windows installers are not Authenticode-signed.", @@ -1688,6 +2028,30 @@ def remote_rebind_ok(step: dict[str, object] | None, output: str) -> bool: return list(dict.fromkeys(errors)) +def validate_release_notes_contract(notes_path: Path) -> list[str]: + """Require the public notes to describe normal and recovery provenance.""" + try: + notes = notes_path.read_text(encoding="utf-8") + except (OSError, UnicodeError): + return ["Beta 4 release notes document dual-SHA recovery provenance"] + normalized = " ".join(notes.split()) + required = ( + "正常 tag push", + "product source SHA 与 release tooling SHA", + "当前远端 `main` HEAD", + "`failed_run_id`", + "`workflow_dispatch` 恢复", + "原不可变 tag SHA", + "`github.workflow_sha`", + "不创建、移动或删除 tag", + "公开 release notes", + "notes commit", + ) + if not notes.strip() or any(marker not in normalized for marker in required): + return ["Beta 4 release notes document dual-SHA recovery provenance"] + return [] + + def validate_repository_metadata(repository_root: Path) -> list[str]: errors: list[str] = [] cargo_path = repository_root / "Cargo.toml" @@ -1764,6 +2128,37 @@ def _resolve_remote_tag_command(arguments: list[str]) -> None: print(resolved) +def _validate_recovery_run_command(arguments: list[str]) -> None: + parser = argparse.ArgumentParser( + prog="check_release_workflow.py validate-recovery-run" + ) + parser.add_argument("--run", required=True, type=Path) + parser.add_argument("--jobs", required=True, type=Path) + parser.add_argument("--comparison", required=True, type=Path) + parser.add_argument("--run-id", required=True, type=int) + parser.add_argument("--tag", required=True) + parser.add_argument("--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.comparison) + ] + if not all(isinstance(payload, dict) for payload in payloads): + raise RecoveryRunError("recovery API payloads must be JSON objects") + validate_recovery_run( + payloads[0], + payloads[1], + payloads[2], + expected_run_id=options.run_id, + expected_tag=options.tag, + expected_sha=options.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 main(arguments: list[str] | None = None) -> None: arguments = sys.argv[1:] if arguments is None else arguments if arguments: @@ -1771,6 +2166,8 @@ def main(arguments: list[str] | None = None) -> None: _resolve_release_state_command(arguments[1:]) elif arguments[0] == "resolve-remote-tag": _resolve_remote_tag_command(arguments[1:]) + elif arguments[0] == "validate-recovery-run": + _validate_recovery_run_command(arguments[1:]) else: raise SystemExit(f"unknown command: {arguments[0]}") return @@ -1778,6 +2175,7 @@ def main(arguments: list[str] | None = None) -> None: raise SystemExit(f"release workflow is missing: {WORKFLOW_PATH}") errors = validate_workflow(WORKFLOW_PATH.read_text(encoding="utf-8")) errors.extend(validate_repository_metadata(REPOSITORY_ROOT)) + errors.extend(validate_release_notes_contract(RELEASE_NOTES_PATH)) if errors: raise SystemExit("release workflow is missing: " + ", ".join(errors)) print("Release workflow contract is complete") diff --git a/scripts/provision_ffmpeg_sidecars.py b/scripts/provision_ffmpeg_sidecars.py index 9f5bc965..dec4f3a2 100644 --- a/scripts/provision_ffmpeg_sidecars.py +++ b/scripts/provision_ffmpeg_sidecars.py @@ -18,7 +18,9 @@ import zipfile -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path( + os.environ.get("OPENTAKE_REPOSITORY_ROOT", Path(__file__).resolve().parents[1]) +).resolve() LOCK_PATH = ROOT / "scripts" / "ffmpeg-sidecars.lock.json" BIN_DIR = ROOT / "src-tauri" / "binaries" @@ -42,7 +44,7 @@ def destination(tool: str, target: str) -> Path: return BIN_DIR / f"{tool}-{target}{extension}" -def verify(path: Path, expected_sha: str, version: str) -> None: +def verify_regular_file(path: Path, expected_sha: str) -> None: if path.is_symlink() or not path.is_file(): raise RuntimeError(f"sidecar is not a regular non-symlink file: {path}") actual_sha = sha256(path) @@ -50,6 +52,10 @@ def verify(path: Path, expected_sha: str, version: str) -> None: raise RuntimeError( f"sidecar checksum mismatch for {path}: {actual_sha} != {expected_sha}" ) + + +def verify(path: Path, expected_sha: str, version: str) -> None: + verify_regular_file(path, expected_sha) output = subprocess.check_output( [str(path), "-version"], text=True, stderr=subprocess.STDOUT ) @@ -67,6 +73,17 @@ def verify(path: Path, expected_sha: str, version: str) -> None: raise RuntimeError(f"unredistributable sidecar license rejected: {path}") +def verify_detached(path: Path, expected_sha: str, version: str) -> None: + verify_regular_file(path, expected_sha) + with tempfile.TemporaryDirectory( + prefix="opentake-sidecar-probe-", ignore_cleanup_errors=True + ) as temporary_directory: + probe_path = Path(temporary_directory) / f"sidecar{path.suffix}" + shutil.copy2(path, probe_path) + verify(probe_path, expected_sha, version) + verify_regular_file(path, expected_sha) + + def download(url: str, destination_path: Path) -> None: last_error: Exception | None = None for attempt in range(1, 5): @@ -133,24 +150,19 @@ def provision(tool: str, record: dict[str, object], target: str) -> None: assert isinstance(version, str) assert isinstance(url, str) if final_path.is_file() and sha256(final_path) == expected_sha: - verify(final_path, expected_sha, version) + verify_detached(final_path, expected_sha, version) print(f"verified {final_path.relative_to(ROOT)}") return - with tempfile.NamedTemporaryFile( - prefix=f".{tool}-{target}-", dir=BIN_DIR, delete=False - ) as stream: - temporary_path = Path(stream.name) - archive_path: Path | None = None - - try: + with tempfile.TemporaryDirectory( + prefix=f"opentake-{tool}-{target}-", ignore_cleanup_errors=True + ) as temporary_directory: + temporary_root = Path(temporary_directory) + temporary_path = temporary_root / f"{tool}-verified" + archive_path = temporary_root / f"{tool}-download" if record.get("archive") is None: download(url, temporary_path) else: - with tempfile.NamedTemporaryFile( - prefix=f".{tool}-{target}-archive-", dir=BIN_DIR, delete=False - ) as stream: - archive_path = Path(stream.name) download(url, archive_path) materialize_download(record, archive_path, temporary_path) actual_sha = sha256(temporary_path) @@ -165,15 +177,25 @@ def provision(tool: str, record: dict[str, object], target: str) -> None: | stat.S_IXGRP | stat.S_IXOTH ) - verify(temporary_path, expected_sha, version) - os.replace(temporary_path, final_path) - verify(final_path, expected_sha, version) - print(f"provisioned {final_path.relative_to(ROOT)}") - finally: - if temporary_path.exists(): - temporary_path.unlink() - if archive_path is not None and archive_path.exists(): - archive_path.unlink() + with tempfile.NamedTemporaryFile( + prefix=f".{tool}-{target}-publish-", dir=BIN_DIR, delete=False + ) as stream: + publication_path = Path(stream.name) + try: + shutil.copy2(temporary_path, publication_path) + publication_sha = sha256(publication_path) + if publication_sha != expected_sha: + raise RuntimeError( + "sidecar publication copy checksum mismatch for " + f"{tool}: {publication_sha} != {expected_sha}" + ) + verify(temporary_path, expected_sha, version) + os.replace(publication_path, final_path) + verify_regular_file(final_path, expected_sha) + print(f"provisioned {final_path.relative_to(ROOT)}") + finally: + if publication_path.exists(): + publication_path.unlink() def main() -> int: @@ -196,7 +218,7 @@ def main() -> int: for tool in ("ffmpeg", "ffprobe"): path = destination(tool, args.target) if args.verify_only: - verify(path, target[tool]["sha256"], target[tool]["version"]) + verify_detached(path, target[tool]["sha256"], target[tool]["version"]) print(f"verified {path.relative_to(ROOT)}") else: provision(tool, target[tool], args.target) diff --git a/scripts/test_check_release_workflow.py b/scripts/test_check_release_workflow.py index 89547625..94dfdd90 100644 --- a/scripts/test_check_release_workflow.py +++ b/scripts/test_check_release_workflow.py @@ -1,6 +1,8 @@ from __future__ import annotations +import importlib.util import json +import os import tempfile import unittest from pathlib import Path @@ -9,10 +11,90 @@ import check_release_workflow as contract -REPOSITORY_ROOT = Path(__file__).resolve().parents[1] -WORKFLOW_PATH = REPOSITORY_ROOT / ".github" / "workflows" / "release.yml" +REPOSITORY_ROOT = Path( + os.environ.get("OPENTAKE_REPOSITORY_ROOT", Path(__file__).resolve().parents[1]) +).resolve() +WORKFLOW_PATH = Path( + os.environ.get( + "OPENTAKE_RELEASE_WORKFLOW_PATH", + REPOSITORY_ROOT / ".github" / "workflows" / "release.yml", + ) +).resolve() +RELEASE_NOTES_PATH = Path( + os.environ.get( + "OPENTAKE_RELEASE_NOTES_PATH", + REPOSITORY_ROOT / "docs" / "releases" / "1.0.0-beta.4.md", + ) +).resolve() WORKFLOW = WORKFLOW_PATH.read_text(encoding="utf-8") if WORKFLOW_PATH.is_file() else "" CHECKOUT_SHA = "11d5960a326750d5838078e36cf38b85af677262" +BETA4_FAILED_RUN_ID = 31412976593 +BETA4_SOURCE_SHA = "2c4efdff9d2587c90cbcac0919f9d1d333d67d6a" +RECOVERY_WINDOWS_STEP_OUTCOMES = ( + ("Set up job", "success"), + (f"Run actions/checkout@{CHECKOUT_SHA}", "success"), + ("Assert exact checked-out SHA", "success"), + ("Require updater signing secrets", "success"), + ("Install Rust toolchain", "success"), + ( + "Run pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1", + "success", + ), + ( + "Run actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020", + "success", + ), + ( + "Run ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b", + "success", + ), + ("Provision checksum-pinned Windows FFmpeg sidecars", "failure"), + ("Verify pinned sidecar supply", "skipped"), + ("Cache Cargo dependencies", "skipped"), + ("Install locked Web dependencies", "skipped"), + ("Rust workspace clippy", "skipped"), + ("Rust workspace tests", "skipped"), + ("Web editor behavior suite", "skipped"), + ("Minimal-feature Tauri clippy", "skipped"), + ("Web production build", "skipped"), + ("Reassert exact source before Windows build", "skipped"), + ("Build native MSI, NSIS, and signed updater artifacts", "skipped"), + ( + "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 Run actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020", + "skipped", + ), + ( + "Post Run pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1", + "success", + ), + (f"Post Run actions/checkout@{CHECKOUT_SHA}", "success"), + ("Complete job", "success"), +) +RECOVERY_WINDOWS_STEP_NUMBERS = (*range(1, 25), 46, 47, 48, 49) + + +def recovery_windows_steps() -> list[dict[str, object]]: + return [ + { + "number": number, + "name": name, + "status": "completed", + "conclusion": conclusion, + } + for number, (name, conclusion) in zip( + RECOVERY_WINDOWS_STEP_NUMBERS, + RECOVERY_WINDOWS_STEP_OUTCOMES, + strict=True, + ) + ] class ReleaseWorkflowContractTests(unittest.TestCase): @@ -37,6 +119,467 @@ def test_trigger_must_remain_tag_only_with_existing_tag_dispatch(self) -> None: mutated = self.mutate(" tags: ['v*']\n", " branches: [main]\n") self.assert_rejected(mutated, "tag-only release trigger") + def test_failed_run_recovery_binds_the_original_tag_push_and_source(self) -> None: + source_sha = BETA4_SOURCE_SHA + run_id = BETA4_FAILED_RUN_ID + run = { + "id": run_id, + "name": "Release", + "path": ".github/workflows/release.yml", + "event": "push", + "status": "completed", + "conclusion": "failure", + "head_branch": "v1.0.0-beta.4", + "head_sha": source_sha, + "run_attempt": 1, + } + jobs = { + "total_count": 5, + "jobs": [ + { + "name": "Validate immutable release source", + "run_id": run_id, + "run_attempt": 1, + "head_sha": source_sha, + "status": "completed", + "conclusion": "success", + }, + { + "name": "Release quality gates", + "run_id": run_id, + "run_attempt": 1, + "head_sha": source_sha, + "status": "completed", + "conclusion": "success", + }, + { + "name": "macOS ARM64 app and DMG", + "run_id": run_id, + "run_attempt": 1, + "head_sha": source_sha, + "status": "completed", + "conclusion": "success", + }, + { + "name": "Windows x64 MSI and NSIS", + "run_id": run_id, + "run_attempt": 1, + "head_sha": source_sha, + "status": "completed", + "conclusion": "failure", + "steps": recovery_windows_steps(), + }, + { + "name": "Publish verified GitHub prerelease", + "run_id": run_id, + "run_attempt": 1, + "head_sha": source_sha, + "status": "completed", + "conclusion": "skipped", + }, + ], + } + compare = { + "status": "ahead", + "base_commit": {"sha": source_sha}, + "merge_base_commit": {"sha": source_sha}, + } + + contract.validate_recovery_run( + run, + jobs, + compare, + expected_run_id=run_id, + expected_tag="v1.0.0-beta.4", + expected_sha=source_sha, + ) + + mutations = ( + ("run event", {**run, "event": "workflow_dispatch"}, jobs, compare), + ("run source", {**run, "head_sha": "2" * 40}, jobs, compare), + ("published result", {**run, "conclusion": "success"}, jobs, compare), + ( + "validation gate", + run, + { + **jobs, + "jobs": [ + { + "name": "Validate immutable release source", + "run_id": run_id, + "run_attempt": 1, + "head_sha": source_sha, + "status": "completed", + "conclusion": "failure", + }, + *jobs["jobs"][1:], + ], + }, + compare, + ), + ( + "main ancestry", + run, + jobs, + {**compare, "merge_base_commit": {"sha": "3" * 40}}, + ), + ) + for name, mutated_run, mutated_jobs, mutated_compare in mutations: + with self.subTest(name=name): + with self.assertRaises(contract.RecoveryRunError): + contract.validate_recovery_run( + mutated_run, + mutated_jobs, + mutated_compare, + expected_run_id=run_id, + expected_tag="v1.0.0-beta.4", + expected_sha=source_sha, + ) + + def test_failed_run_recovery_requires_the_exact_windows_failure_job_set( + self, + ) -> None: + source_sha = BETA4_SOURCE_SHA + run_id = BETA4_FAILED_RUN_ID + run = { + "id": run_id, + "name": "Release", + "path": ".github/workflows/release.yml", + "event": "push", + "status": "completed", + "conclusion": "failure", + "head_branch": "v1.0.0-beta.4", + "head_sha": source_sha, + "run_attempt": 1, + } + 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", + } + + def entry(name: str, conclusion: str) -> dict[str, object]: + result: dict[str, object] = { + "name": name, + "run_id": run_id, + "run_attempt": 1, + "head_sha": source_sha, + "status": "completed", + "conclusion": conclusion, + } + if name == "Windows x64 MSI and NSIS": + result["steps"] = recovery_windows_steps() + return result + + exact = [entry(name, conclusion) for name, conclusion in outcomes.items()] + compare = { + "status": "ahead", + "base_commit": {"sha": source_sha}, + "merge_base_commit": {"sha": source_sha}, + } + invalid_job_sets = { + "missing": exact[:-1], + "extra": [*exact, entry("unexpected arbitrary job", "failure")], + "duplicate": [*exact, exact[-1]], + "quality failure": [ + entry( + name, + "failure" if name == "Release quality gates" else conclusion, + ) + for name, conclusion in outcomes.items() + ], + "publish failure": [ + entry( + name, + "failure" + if name == "Publish verified GitHub prerelease" + else conclusion, + ) + for name, conclusion in outcomes.items() + ], + "wrong run": [ + {**exact[0], "run_id": run_id + 1}, + *exact[1:], + ], + "wrong attempt": [ + {**exact[0], "run_attempt": 2}, + *exact[1:], + ], + "wrong source": [ + {**exact[0], "head_sha": "2" * 40}, + *exact[1:], + ], + } + + for name, entries in invalid_job_sets.items(): + with self.subTest(name=name): + with self.assertRaises(contract.RecoveryRunError): + contract.validate_recovery_run( + run, + {"total_count": len(entries), "jobs": entries}, + compare, + expected_run_id=run_id, + expected_tag="v1.0.0-beta.4", + expected_sha=source_sha, + ) + + def test_failed_run_recovery_requires_the_exact_sidecar_failure_step( + self, + ) -> None: + source_sha = BETA4_SOURCE_SHA + run_id = BETA4_FAILED_RUN_ID + run = { + "id": run_id, + "name": "Release", + "path": ".github/workflows/release.yml", + "event": "push", + "status": "completed", + "conclusion": "failure", + "head_branch": "v1.0.0-beta.4", + "head_sha": source_sha, + "run_attempt": 1, + } + 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"), + ) + + 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": run_id, + "run_attempt": 1, + "head_sha": source_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} + + compare = { + "status": "ahead", + "base_commit": {"sha": source_sha}, + "merge_base_commit": {"sha": source_sha}, + } + exact_steps = recovery_windows_steps() + mutations: dict[str, list[dict[str, object]]] = { + "missing failure step": [ + step + for step in exact_steps + if step["name"] + != "Provision checksum-pinned Windows FFmpeg sidecars" + ], + "extra step": [ + *recovery_windows_steps(), + { + "name": "unexpected arbitrary step", + "status": "completed", + "conclusion": "failure", + }, + ], + "wrong failure step number": [ + { + **step, + "number": 10 + if step["name"] + == "Provision checksum-pinned Windows FFmpeg sidecars" + else step["number"], + } + for step in recovery_windows_steps() + ], + "workspace test failure": [ + { + **step, + "status": "completed", + "conclusion": ( + "success" + if step["name"] + == "Provision checksum-pinned Windows FFmpeg sidecars" + else "failure" + if step["name"] == "Rust workspace tests" + else step["conclusion"] + ), + } + for step in recovery_windows_steps() + ], + "installer failure": [ + { + **step, + "status": "completed", + "conclusion": ( + "success" + if step["name"] + == "Provision checksum-pinned Windows FFmpeg sidecars" + else "failure" + if step["name"] + == "Build native MSI, NSIS, and signed updater artifacts" + else step["conclusion"] + ), + } + for step in recovery_windows_steps() + ], + } + + for name, steps in mutations.items(): + with self.subTest(name=name): + with self.assertRaises(contract.RecoveryRunError): + contract.validate_recovery_run( + run, + jobs_with_steps(steps), + compare, + expected_run_id=run_id, + expected_tag="v1.0.0-beta.4", + expected_sha=source_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" + ) + 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" + ) + candidate = self.mutate(tag_input, recovered) + + self.assertNotIn("tag-only release trigger", contract.validate_workflow(candidate)) + + def test_contract_paths_can_be_bound_to_an_external_workflow_copy(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + workflow = root / "release.yml" + notes = root / "release-notes.md" + with mock.patch.dict( + os.environ, + { + "OPENTAKE_REPOSITORY_ROOT": str(root), + "OPENTAKE_RELEASE_WORKFLOW_PATH": str(workflow), + "OPENTAKE_RELEASE_NOTES_PATH": str(notes), + }, + ): + isolated_spec = importlib.util.spec_from_file_location( + "isolated_check_release_workflow", + Path(contract.__file__).resolve(), + ) + assert isolated_spec is not None and isolated_spec.loader is not None + isolated = importlib.util.module_from_spec(isolated_spec) + isolated_spec.loader.exec_module(isolated) + + self.assertEqual(isolated.REPOSITORY_ROOT, root) + self.assertEqual(isolated.WORKFLOW_PATH, workflow) + self.assertEqual( + isolated.RELEASE_NOTES_PATH, + notes, + ) + + def test_dispatch_recovery_cannot_bypass_failed_run_provenance(self) -> None: + mutations = ( + ( + ' RELEASE_TOOLING_SHA: ${{ github.workflow_sha }}\n', + ' RELEASE_TOOLING_SHA: ${{ github.sha }}\n', + ), + ( + ' test "$tooling_sha" = "$remote_main"\n', + ' test -n "$tooling_sha"\n', + ), + ( + ' --run-id "$FAILED_RUN_ID" \\\n', + ' --run-id 31412976593 \\\n', + ), + ( + ' gh api "repos/$GITHUB_REPOSITORY/compare/$source_sha...$remote_main" \\\n', + ' gh api "repos/$GITHUB_REPOSITORY/compare/$remote_main...$remote_main" \\\n', + ), + ) + for old, new in mutations: + with self.subTest(mutation=old.strip()): + self.assert_rejected( + self.mutate(old, new), "failed-run recovery provenance" + ) + + def test_windows_recovery_tooling_is_bound_to_the_workflow_commit(self) -> None: + mutations = ( + ( + 'git cat-file blob "$($env:RELEASE_TOOLING_SHA):scripts/provision_ffmpeg_sidecars.py" > $provisioner', + 'Get-Content scripts/provision_ffmpeg_sidecars.py | Set-Content $provisioner', + ), + ( + 'git cat-file -e "$($env:RELEASE_TOOLING_SHA)^{commit}" 2>$null', + '$true', + ), + ( + 'git fetch --no-tags --depth=1 origin $env:RELEASE_TOOLING_SHA', + 'git fetch --no-tags --depth=1 origin main', + ), + ) + for old, new in mutations: + with self.subTest(mutation=old): + self.assert_rejected( + self.mutate(old, new), "exact release tooling provenance" + ) + + def test_recovery_tooling_never_trusts_raw_github_downloads(self) -> None: + mutations = ( + ( + 'git cat-file blob "$tooling_sha:scripts/check_release_workflow.py" \\\n', + 'curl --fail "https://raw.githubusercontent.com/appergb/OpenTake/$tooling_sha/scripts/check_release_workflow.py" \\\n', + ), + ( + 'git cat-file blob "$RELEASE_TOOLING_SHA:scripts/test_check_release_workflow.py" \\\n', + 'curl --fail "https://raw.githubusercontent.com/appergb/OpenTake/$RELEASE_TOOLING_SHA/scripts/test_check_release_workflow.py" \\\n', + ), + ( + 'git cat-file blob "$($env:RELEASE_TOOLING_SHA):scripts/provision_ffmpeg_sidecars.py" > $provisioner', + 'Invoke-WebRequest "https://raw.githubusercontent.com/appergb/OpenTake/$env:RELEASE_TOOLING_SHA/scripts/provision_ffmpeg_sidecars.py" -OutFile $provisioner', + ), + ) + for old, new in mutations: + with self.subTest(mutation=old): + self.assert_rejected( + self.mutate(old, new), + "release tooling never trusts raw HTTP downloads", + ) + + def test_recovery_runs_the_exact_tooling_provisioner_tests(self) -> None: + mutated = self.mutate( + ' -s "$tooling_root" -p \'test_provision_ffmpeg_sidecars.py\'\n', + ' -s scripts/tests -p \'test_*.py\'\n', + ) + self.assert_rejected(mutated, "recovery provisioner tests") + + def test_recovery_release_notes_are_loaded_from_exact_tooling_commit( + self, + ) -> None: + mutations = ( + ( + 'git cat-file blob "$RELEASE_TOOLING_SHA:docs/releases/1.0.0-beta.4.md" \\\n', + 'cp docs/releases/1.0.0-beta.4.md \\\n', + "exact release tooling provenance", + ), + ( + 'git cat-file blob "$RELEASE_TOOLING_SHA:$NOTES_PATH" \\\n', + 'cp "$NOTES_PATH" \\\n', + "recovery release notes use exact tooling commit", + ), + ) + for old, new, expected in mutations: + with self.subTest(mutation=old): + self.assert_rejected(self.mutate(old, new), expected) + def test_trigger_rejects_any_additional_event(self) -> None: mutated = self.mutate( " workflow_dispatch:\n", @@ -659,6 +1202,21 @@ def test_publish_command_cannot_be_faked_by_echo(self) -> None: def test_repository_metadata_is_beta4_and_release_notes_exist(self) -> None: self.assertEqual([], contract.validate_repository_metadata(REPOSITORY_ROOT)) + def test_release_notes_document_normal_push_and_dual_sha_recovery(self) -> None: + self.assertTrue(RELEASE_NOTES_PATH.is_file()) + self.assertEqual( + [], contract.validate_release_notes_contract(RELEASE_NOTES_PATH) + ) + with tempfile.TemporaryDirectory() as directory: + notes = Path(directory) / "notes.md" + notes.write_text( + "tag must always equal current main\n", encoding="utf-8" + ) + self.assertEqual( + ["Beta 4 release notes document dual-SHA recovery provenance"], + contract.validate_release_notes_contract(notes), + ) + class ReleaseRepositoryMetadataTests(unittest.TestCase): def make_repository(self) -> tuple[tempfile.TemporaryDirectory[str], Path]: diff --git a/scripts/tests/test_provision_ffmpeg_sidecars.py b/scripts/tests/test_provision_ffmpeg_sidecars.py index b2aefe8e..11ccb8bc 100644 --- a/scripts/tests/test_provision_ffmpeg_sidecars.py +++ b/scripts/tests/test_provision_ffmpeg_sidecars.py @@ -3,6 +3,7 @@ import hashlib import importlib.util import json +import os from pathlib import Path import tempfile import unittest @@ -10,8 +11,15 @@ import zipfile -ROOT = Path(__file__).resolve().parents[2] -MODULE_PATH = ROOT / "scripts" / "provision_ffmpeg_sidecars.py" +ROOT = Path( + os.environ.get("OPENTAKE_REPOSITORY_ROOT", Path(__file__).resolve().parents[2]) +).resolve() +MODULE_PATH = Path( + os.environ.get( + "OPENTAKE_PROVISIONER_PATH", + ROOT / "scripts" / "provision_ffmpeg_sidecars.py", + ) +).resolve() SPEC = importlib.util.spec_from_file_location("provision_ffmpeg_sidecars", MODULE_PATH) assert SPEC is not None and SPEC.loader is not None provisioner = importlib.util.module_from_spec(SPEC) @@ -23,6 +31,115 @@ def digest(data: bytes) -> str: class ProvisionFfmpegSidecarsTests(unittest.TestCase): + def test_repository_root_can_be_bound_when_tooling_runs_outside_checkout( + self, + ) -> None: + with tempfile.TemporaryDirectory() as directory: + expected_root = Path(directory).resolve() + with mock.patch.dict( + os.environ, + {"OPENTAKE_REPOSITORY_ROOT": str(expected_root)}, + ): + isolated_spec = importlib.util.spec_from_file_location( + "isolated_provision_ffmpeg_sidecars", MODULE_PATH + ) + assert isolated_spec is not None and isolated_spec.loader is not None + isolated = importlib.util.module_from_spec(isolated_spec) + isolated_spec.loader.exec_module(isolated) + + self.assertEqual(isolated.ROOT, expected_root) + self.assertEqual( + isolated.BIN_DIR, expected_root / "src-tauri" / "binaries" + ) + + def test_provision_publishes_an_unexecuted_copy_when_windows_locks_images( + self, + ) -> None: + binary = b"redistributable ffmpeg" + expected_sha = digest(binary) + record = { + "url": "https://example.invalid/ffmpeg.exe", + "sha256": expected_sha, + "version": "7.0", + } + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + binary_dir = root / "src-tauri" / "binaries" + binary_dir.mkdir(parents=True) + executed_paths: set[Path] = set() + real_replace = os.replace + + def download_fixture(_url: str, path: Path) -> None: + path.write_bytes(binary) + + def lock_executed_image( + path: Path, _expected_sha: str, _version: str + ) -> None: + executed_paths.add(path) + + def windows_replace(source: Path, destination: Path) -> None: + if source in executed_paths: + raise PermissionError( + 32, + "The process cannot access the file because it is being used " + "by another process", + str(source), + ) + real_replace(source, destination) + + with ( + mock.patch.object(provisioner, "ROOT", root), + mock.patch.object(provisioner, "BIN_DIR", binary_dir), + mock.patch.object(provisioner, "download", download_fixture), + mock.patch.object(provisioner, "verify", lock_executed_image), + mock.patch.object(provisioner.os, "replace", windows_replace), + ): + provisioner.provision( + "ffmpeg", record, "x86_64-pc-windows-msvc" + ) + + final_path = binary_dir / "ffmpeg-x86_64-pc-windows-msvc.exe" + self.assertEqual(final_path.read_bytes(), binary) + self.assertTrue(executed_paths) + self.assertTrue( + all(binary_dir not in path.parents for path in executed_paths) + ) + + def test_cached_sidecar_is_probed_only_from_a_system_temporary_copy(self) -> None: + binary = b"redistributable ffmpeg" + expected_sha = digest(binary) + record = { + "url": "https://example.invalid/ffmpeg.exe", + "sha256": expected_sha, + "version": "7.0", + } + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + binary_dir = root / "src-tauri" / "binaries" + binary_dir.mkdir(parents=True) + final_path = binary_dir / "ffmpeg-x86_64-pc-windows-msvc.exe" + final_path.write_bytes(binary) + executed_paths: list[Path] = [] + + def record_probe( + path: Path, _expected_sha: str, _version: str + ) -> None: + executed_paths.append(path) + + with ( + mock.patch.object(provisioner, "ROOT", root), + mock.patch.object(provisioner, "BIN_DIR", binary_dir), + mock.patch.object(provisioner, "verify", record_probe), + ): + provisioner.provision( + "ffmpeg", record, "x86_64-pc-windows-msvc" + ) + + self.assertEqual(final_path.read_bytes(), binary) + self.assertEqual(len(executed_paths), 1) + self.assertNotEqual(executed_paths[0], final_path) + self.assertNotIn(binary_dir, executed_paths[0].parents) + def test_materializes_only_the_checksum_pinned_zip_member(self) -> None: binary = b"redistributable ffmpeg" with tempfile.TemporaryDirectory() as directory: