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