Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/workflows/native-cli-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 32 additions & 3 deletions docs/release-recovery-runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <run-id> --repo <owner>/<repo> --failed
Expand Down Expand Up @@ -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.
Expand Down
12 changes: 12 additions & 0 deletions scripts/test-native-cli-publish-workflow.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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="" ;;'
Expand Down Expand Up @@ -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
Expand Down