From 83cb6fa7e6f1fe6353d50f50eeada7f601cd51d0 Mon Sep 17 00:00:00 2001 From: hatayama Date: Fri, 17 Jul 2026 02:30:16 +0900 Subject: [PATCH] fix: Reject asset publishing from recovery-target dispatch runs Attestation certificates bind SourceRepositoryDigest to the workflow run head (GITHUB_SHA), never to the checked-out build commit. A recovery-target dispatch run on a newer head therefore tags the historical release commit while attesting the new head, and the dispatcher's tag-to-certificate check can never pass. This broke uloop-project-runner-v3.0.0-beta.48 on 2026-07-15: every cold-cache download fails verification on all platforms. Enforce the invariant RELEASE_SHA == GITHUB_SHA whenever a run publishes attested assets: the publish job now fails closed with guidance to rerun the original failed run (whose head is the release commit). Recovery dispatch remains usable for build validation. The alternative of always tagging GITHUB_SHA on dispatch was rejected because it abandons the "runner tag == release-please merge commit" semantics; the guard keeps the semantics and only blocks the broken publish path. The runbook now documents the invariant and the beta.48 incident. --- .github/workflows/native-cli-publish.yml | 11 +++++++ docs/release-recovery-runbook.md | 35 +++++++++++++++++++-- scripts/test-native-cli-publish-workflow.sh | 12 +++++++ 3 files changed, 55 insertions(+), 3 deletions(-) diff --git a/.github/workflows/native-cli-publish.yml b/.github/workflows/native-cli-publish.yml index 6d85c2abd..82197e13e 100644 --- a/.github/workflows/native-cli-publish.yml +++ b/.github/workflows/native-cli-publish.yml @@ -213,6 +213,17 @@ jobs: exit 1 fi release_sha="${TARGET_SHA}" + # Why publishing is blocked here: the attestation certificate binds + # SourceRepositoryDigest to the workflow run head (GITHUB_SHA), never + # to the checked-out build commit, so assets published for an older + # release commit can never pass the dispatcher's tag-to-certificate + # verification. Recovery dispatch may only validate builds. + if [ "${should_publish}" = "true" ] && [ "${release_sha}" != "${GITHUB_SHA}" ]; then + echo "Recovery-target dispatch cannot publish release assets: the release commit ${release_sha} differs from the run head ${GITHUB_SHA}, so the attestation certificate digest would never match the release tag." >&2 + echo "Rerun the original failed workflow run whose head commit is the release commit instead." >&2 + echo "See docs/release-recovery-runbook.md for the recovery procedure." >&2 + exit 1 + fi else if [ "${BUILD_SHA}" != "${GITHUB_SHA}" ] || [ "${TARGET_SHA}" != "${GITHUB_SHA}" ]; then echo "Build SHA and release target must match the approved event commit." >&2 diff --git a/docs/release-recovery-runbook.md b/docs/release-recovery-runbook.md index 5fa0d0191..6076a7990 100644 --- a/docs/release-recovery-runbook.md +++ b/docs/release-recovery-runbook.md @@ -20,6 +20,30 @@ and [29459266044](https://github.com/hatayama/unity-cli-loop/actions/runs/294592 The precise condition is a ref target different from the run's `GITHUB_SHA`, not merely an unreachable commit. Normal head-based releases are unaffected. +## Publishing invariant: assets may only be published by the original run + +Attestation certificates bind `SourceRepositoryDigest` to the workflow run's +head commit (`GITHUB_SHA`), never to the commit the build checked out. The +dispatcher verifies that the release tag's commit equals the certificate +digest, so a release is only downloadable when the publishing run's head IS +the release commit. + +Therefore the only valid way to publish recovery assets is to **rerun the +original failed run whose head is the release commit** (step 5 below). Never +publish through a new `recovery-target` workflow-dispatch run on a later +branch head: its certificates would carry the later head's digest while the +tag points at the historical release commit, producing a release that +permanently fails attestation verification. The workflow now rejects this +combination in the publish job's "Verify release metadata" step; recovery +dispatch runs remain usable for build validation only. + +This is not theoretical: `uloop-project-runner-v3.0.0-beta.48` was published +on 2026-07-15 through a recovery dispatch run after the owner pre-created the +tag and draft release. The tag points at the historical release commit +(`2d8d1b94`) while the attestation certificates carry the dispatch run's head +digest (`2c73c6ac`), so every cold-cache download of beta.48 fails +verification on all platforms. + ## Recovery procedure Run these commands with an owner-authenticated `gh` session. Replace every @@ -67,8 +91,11 @@ Run these commands with an owner-authenticated `gh` session. Replace every Add `--prerelease` when the release tag is a prerelease tag. -5. Rerun the failed publish job and approve the release environment if - prompted. +5. Rerun the failed publish job of the original run whose head is the release + commit, and approve the release environment if prompted. Do not dispatch a + new `recovery-target` run to publish instead: a dispatch run on a later + head cannot produce valid attestations for the release commit, and the + workflow rejects it (see "Publishing invariant" above). ```sh gh run rerun --repo / --failed @@ -118,7 +145,9 @@ Run these commands with an owner-authenticated `gh` session. Replace every `native-cli-publish.yml` is the workflow with `recovery-target` and now emits this runbook path when tag or draft-release creation returns the observed 403. -The creation and reuse logic is unchanged. +The creation and reuse logic is unchanged. Its publish job refuses to publish +assets from a recovery dispatch run whose head differs from the release +commit, because such a run cannot produce matching attestations. `dispatcher-publish.yml` has no recovery-target input and requires its release target to equal `GITHUB_SHA`, so it has no historical-commit creation path. diff --git a/scripts/test-native-cli-publish-workflow.sh b/scripts/test-native-cli-publish-workflow.sh index 0b9356baf..24b8de61d 100755 --- a/scripts/test-native-cli-publish-workflow.sh +++ b/scripts/test-native-cli-publish-workflow.sh @@ -193,6 +193,17 @@ test_recovery_target_403_errors_explain_the_manual_recovery() { assert_contains 'See docs/release-recovery-runbook.md for the recovery procedure.' } +test_recovery_dispatch_refuses_to_publish_from_a_different_head() { + # Verify recovery dispatch fails closed before publishing assets: the + # attestation certificate digest is always the run head, so publishing for an + # older release commit would create a release that never passes verification. + assert_contains 'if [ "${should_publish}" = "true" ] && [ "${release_sha}" != "${GITHUB_SHA}" ]; then' + assert_contains 'Recovery-target dispatch cannot publish release assets' + assert_contains 'Rerun the original failed workflow run whose head commit is the release commit instead.' + assert_before 'release_sha="${TARGET_SHA}"' 'Recovery-target dispatch cannot publish release assets' + assert_before 'Recovery-target dispatch cannot publish release assets' 'printf '\''RELEASE_SHA=%s\n'\'' "${release_sha}" >> "$GITHUB_ENV"' +} + test_draft_creation_accepts_only_the_known_missing_tag_responses() { assert_contains '*"HTTP 404"*) tag_sha="" ;;' assert_contains '*"HTTP 422"*"No commit found for SHA"*|*"No commit found for SHA"*"HTTP 422"*) tag_sha="" ;;' @@ -242,6 +253,7 @@ test_assets_are_attested_after_the_manifest_is_verified test_publish_rejects_manifest_and_existing_tag_mismatches test_release_tag_is_created_before_the_release test_recovery_target_403_errors_explain_the_manual_recovery +test_recovery_dispatch_refuses_to_publish_from_a_different_head test_draft_creation_accepts_only_the_known_missing_tag_responses test_publish_rechecks_the_tag_and_uses_least_privilege_post_publish_permissions test_build_verifies_assets_before_writing_the_publish_input