Skip to content
Merged
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
237 changes: 88 additions & 149 deletions docs/release-recovery-runbook.md
Original file line number Diff line number Diff line change
@@ -1,163 +1,102 @@
# Release recovery runbook

## Root cause
## Purpose

In `recovery-target` mode, the release target is an earlier commit than the
workflow run's `GITHUB_SHA`. GitHub rejects the Actions `GITHUB_TOKEN` when it
tries to create a tag or release for that different commit, even when the token
has `contents: write`. This is neither a missing permission nor a tag ruleset
failure.
Use this runbook when a native CLI release has a tag commit that differs from
the `sourceRepositoryDigest` in its asset attestations. Such a release cannot
be downloaded by the dispatcher. Recover by publishing the next valid release;
do not try to repair the broken release in place.
Comment on lines +3 to +8

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Describe the dispatcher failure accurately.

The asset remains downloadable; the dispatcher rejects it during attestation verification. Replace “cannot be downloaded” with “cannot be verified or used by the dispatcher” to avoid sending operators toward the wrong diagnosis.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/release-recovery-runbook.md` around lines 3 - 8, Update the Purpose
section of the release-recovery runbook to state that the asset remains
downloadable but cannot be verified or used by the dispatcher because
attestation verification rejects it. Replace the inaccurate “cannot be
downloaded” wording while preserving the recovery guidance to publish the next
valid release.


The behavior was confirmed with the same token and `contents: write`
permission in reproduction runs [29459199798](https://github.com/hatayama/unity-cli-loop/actions/runs/29459199798)
and [29459266044](https://github.com/hatayama/unity-cli-loop/actions/runs/29459266044):
## First response: roll forward

- A tag pointing at the run head returned HTTP 201.
- A tag pointing at `2d8d1b9443843f45b48479134f846b6f540e928b`, an ancestor of
the run head, returned HTTP 403 `Resource not accessible by integration`.
- Creating a draft release directly at that past commit also returned HTTP 403.

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
`<placeholder>` before running a command.

1. Confirm the authenticated account and repository.

```sh
gh auth status
gh api user --jq .login
gh api repos/<owner>/<repo> --jq .full_name
```

2. Determine the release commit and release tag from the release PR and the
failed workflow run.
1. Confirm the affected release is broken by comparing its tag commit with an
attached asset attestation.

```sh
gh pr view <release-pr> --repo <owner>/<repo> --json mergeCommit --jq .mergeCommit.oid
gh run view <run-id> --repo <owner>/<repo> --json headSha
gh release download <release-tag> --repo <owner>/<repo> \
--pattern <asset-name> --dir <download-directory>
gh api repos/<owner>/<repo>/commits/<release-tag> --jq .sha
gh attestation verify <download-directory>/<asset-name> --repo <owner>/<repo> --format json \
| jq -r '.[].verificationResult.signature.certificate.sourceRepositoryDigest'
```

The release commit must be the commit validated by the workflow. Do not use
the current branch head when recovering an older release.

3. Create the release tag at the validated release commit.

```sh
gh api repos/<owner>/<repo>/git/refs \
-f ref=refs/tags/<tag> \
-f sha=<release-commit-sha>
```
2. Merge the pending release-please release PR for the affected branch. This
creates the next version and starts a normal head-based publish run.

4. Download the native release input artifact and create the draft release.
Creating only the tag is not sufficient: the Actions token also receives
HTTP 403 when it tries to create the draft release.
3. Verify the new runner release and the package release that pins it. The
runner tag commit, every asset's attestation digest, and the publish run's
`headSha` must match.

```sh
gh run download <run-id> --repo <owner>/<repo> \
-n native-cli-release-input -D <native-release-input-dir>
gh release create <tag> --repo <owner>/<repo> \
--draft --title <tag> \
--notes-file <native-release-input-dir>/release-input/release-notes.md \
--target <release-commit-sha>
gh run view <publish-run-id> --repo <owner>/<repo> --json headSha --jq .headSha
gh api repos/<owner>/<repo>/commits/<new-runner-tag> --jq .sha
gh attestation verify <new-asset> --repo <owner>/<repo> --format json \
| jq -r '.[].verificationResult.signature.certificate.sourceRepositoryDigest'
```
Comment on lines +26 to 35

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Verify the package pin and every published asset.

This step claims to verify the package release and “every asset,” but the commands inspect only one <new-asset> and never confirm that the package release pins the new runner tag. Add explicit package-pin verification and a loop/list of all release assets, or narrow the prose to the checks actually performed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/release-recovery-runbook.md` around lines 26 - 35, Update the release
verification step to match its stated coverage: explicitly verify that the
package release pins the new runner tag, and enumerate and attest every
published release asset rather than checking only `<new-asset>`. Keep the runner
tag and publish-run `headSha` comparisons, or narrow the step’s prose if those
checks cannot be implemented.


Add `--prerelease` when the release tag is a prerelease tag.

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
```

6. If an older `dispatcher-publish` run is waiting for the `cli-release`
environment, cancel it before retrying the recovery. The workflow uses a
concurrency group without automatic cancellation, so an old approval-waiting
run can block the newer run indefinitely.

```sh
gh run list --repo <owner>/<repo> --workflow dispatcher-publish.yml \
--branch <branch> --limit 20
gh run cancel <blocked-run-id> --repo <owner>/<repo>
```

7. Recover the Unity package release only after the runner release has
completed and its assets are available. The package sync script creates a
release at the historical release commit and can hit the same 403; create
that release as the owner before rerunning the sync.

```sh
gh release create v<package-version> --repo <owner>/<repo> \
--title v<package-version> \
--notes-file <package-release-notes> \
--target <package-release-commit-sha>
```

Add `--prerelease` when the package release is a prerelease.

Run the package sync or the relevant post-publish job after the runner
release assets are complete. The sync reuses an existing correctly targeted
release.

8. Complete the workflow-dispatch recovery manually. Push-only post-publish
steps are intentionally skipped for a `workflow_dispatch` run.

```sh
gh pr edit <release-pr> --repo <owner>/<repo> \
--remove-label 'autorelease: pending' \
--add-label 'autorelease: tagged'
gh workflow run release-please.yml --repo <owner>/<repo> --ref <branch> \
-f branch=<branch>
```

## Scope of the workflows

`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. 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.
Its concurrency group still matters during recovery because an older run can
block a retry.

`scripts/sync-release-please-package-releases.sh` can create package releases
at release-please commits from the repository history. This runbook's owner
pre-creation step covers that path; the runner release must be recovered first.

Do not add GitHub Actions to the tag-ruleset bypass actors. That would grant
the bot tag deletion and force-update capabilities without addressing the
historical-commit restriction.
The 2026-07-17 recovery followed this procedure: runner beta.48 was left
broken, the next release-please PR produced runner beta.49, and the following
package release pinned beta.49. The normal publish run, tag, and attestations
all resolved to the same commit.

## Why a broken release cannot be revived

Do not create a new tag, draft, or publish run for the historical version.
Revival is structurally impossible for three independent reasons:

1. Rerunning a workflow replays the workflow definition from the original
commit. If that workflow definition was itself broken, rerunning it repeats
the failure.
2. A new workflow run attests its own `github.sha`, which is the current branch
head. It cannot create attestations for an earlier release commit.
Comment on lines +47 to +51

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show the relevant section of the runbook with line numbers.
sed -n '35,70p' docs/release-recovery-runbook.md | cat -n

# Search for any other references to github.sha / GITHUB_SHA in the repo.
rg -n "github\.sha|GITHUB_SHA|GITHUB_REF|rerun" docs .github . -g '!**/node_modules/**' || true

Repository: hatayama/unity-cli-loop

Length of output: 10525


🌐 Web query:

GitHub Actions rerun workflow job GITHUB_SHA GITHUB_REF github.sha semantics tag-triggered workflow run

💡 Result:

When you rerun a GitHub Actions workflow or a specific job within a workflow, the system uses the exact same GITHUB_SHA and GITHUB_REF that were associated with the initial trigger of that workflow run [1][2]. These values are immutable for the duration of the workflow run, regardless of any subsequent changes to the branch or tag in the repository [1][3]. Key semantics to keep in mind regarding these environment variables: 1. Rerun Behavior: Because reruns utilize the original context, they do not "re-fetch" or re-evaluate the triggering commit or reference [1][4]. This ensures consistency, as the rerun operates on the exact codebase version that was initially intended to be tested [1][2]. 2. GITHUB_SHA Semantics: The value of GITHUB_SHA depends on the event that triggered the workflow [5][6]. - For push events, it is the SHA of the commit that triggered the push. - For pull_request events, it often refers to a temporary merge commit created by GitHub (e.g., refs/remotes/pull/:pr_number/merge), rather than the head commit of the source branch [6]. If you require the specific head SHA of a pull request, use github.event.pull_request.head.sha instead [6]. 3. Tag-Triggered Workflows: When a workflow is triggered by a tag (e.g., via a push event that includes a tag), GITHUB_REF will contain the reference to that tag (e.g., refs/tags/my-tag). During a rerun, this reference remains fixed to the specific tag that originally triggered the workflow [1][7]. In summary, rerunning a workflow is a "replay" of the original trigger context, ensuring that environment variables like GITHUB_SHA and GITHUB_REF remain consistent with the original run [1][4].

Citations:


Use the triggering commit/ref, not “current branch head”. Fresh workflow runs attest the commit that triggered the run, and reruns preserve the original GITHUB_SHA and GITHUB_REF. For tag-triggered releases, that is the tag commit, not a branch head.

🧰 Tools
🪛 LanguageTool

[uncategorized] ~50-~50: The official name of this software platform is spelled with a capital “H”.
Context: .... 2. A new workflow run attests its own github.sha, which is the current branch he...

(GITHUB)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/release-recovery-runbook.md` around lines 47 - 51, Update the release
recovery runbook guidance to state that fresh runs attest the triggering
commit/ref and reruns preserve the original GITHUB_SHA and GITHUB_REF; for
tag-triggered releases, identify the tag commit rather than the current branch
head.

3. The release resolver binds the historical version to its original release
commit. A later publish run therefore produces a tag-to-attestation digest
mismatch even when an owner pre-creates the tag or draft release.

For example, beta.48's tag pointed at `2d8d1b94` while its published asset
attestations carried `2c73c6ac`. Reusing that version would preserve the same
invariant violation.

## When rerun recovery is valid

Rerun only when all of the following are true:

- The original failed run's `headSha` is the intended release commit.
- The workflow definition at that commit is known to be healthy.
- The release tag and draft release, if they exist, point at that same commit.

Check the original run before rerunning it:

```sh
gh run view <run-id> --repo <owner>/<repo> --json headSha,workflowName,url
gh api repos/<owner>/<repo>/commits/<release-tag> --jq .sha
gh run view <run-id> --repo <owner>/<repo> --log-failed
```

If the run used an older workflow revision that lacks a required fix, or its
head SHA differs from the intended release commit, do not rerun it. Use the
roll-forward procedure instead.

When all conditions hold, rerun the failed jobs from the original run and
approve the `cli-release` environment if GitHub requests approval:

```sh
gh run rerun <run-id> --repo <owner>/<repo> --failed
```
Comment on lines +60 to +85

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

GitHub Actions rerun workflow jobs 30-day rerun window documentation

💡 Result:

According to official GitHub Actions documentation, you can re-run a workflow run, all failed jobs, or specific individual jobs up to 30 days after the initial run [1][2]. It is important to note that the ability to re-run a job is dependent on the existence of the workflow's logs [3]. While the re-run window is capped at 30 days, the retention period for the logs themselves—which determines whether the run is still accessible for a re-run—can vary [4]. By default, logs and artifacts are retained for 90 days, but this can be configured by organizations to range from 1 to 400 days for private repositories, or 1 to 90 days for public repositories [4]. Even if log retention is extended beyond 30 days, the ability to initiate a re-run remains restricted to the 30-day window following the initial execution [1][2]. Additionally, there is a limit on the number of times a workflow can be re-run: a single workflow run can be re-run a maximum of 50 times, which includes both full re-runs and re-runs of specific subsets of jobs [1][5][6].

Citations:


Include the 30-day rerun limit. In docs/release-recovery-runbook.md:60-85, add that GitHub only allows rerunning a workflow run within 30 days of the original run; after that, use the roll-forward procedure even if the other conditions still hold.

🧰 Tools
🪛 LanguageTool

[style] ~62-~62: Consider removing “of” to be more concise
Context: ...erun recovery is valid Rerun only when all of the following are true: - The original fai...

(ALL_OF_THE)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/release-recovery-runbook.md` around lines 60 - 85, Update the “When
rerun recovery is valid” section to state that GitHub permits workflow reruns
only within 30 days of the original run. Instruct operators to use the
roll-forward procedure after 30 days, even when all other rerun conditions are
satisfied.


## Operational notes

- Do not dispatch `recovery-target` to publish a historical release. It is
limited to validation because a later run cannot produce valid attestations
for an earlier commit.
- Cancel an older run waiting for `cli-release` approval before retrying. The
workflow concurrency group does not cancel it automatically, so it can block
the newer run indefinitely.
- Delete a broken release only after the version bump is merged. Deleting it
first can make the resolver target the historical commit again and cause
later push builds to fail.
- Do not grant GitHub Actions tag-ruleset bypass permissions. That does not
resolve the attestation invariant and expands bot authority unnecessarily.
- A broken release may remain published while the roll-forward release is cut;
the dispatcher rejects it, so it is harmless once consumers use the newer
package pin.