Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
182 changes: 175 additions & 7 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,18 @@ on:
workflow_dispatch:
inputs:
operation:
description: Maintain the release PR or repair an admitted immutable release
description: Maintain the release PR, resume a stranded untagged candidate, or repair an admitted immutable release
required: true
default: maintenance
type: choice
options:
- maintenance
- resume
- repair
candidate_sha:
description: Merged, proven, untagged candidate SHA to resume through protected publication
required: false
type: string
release_tag:
description: Existing vX.Y.Z tag whose incomplete publication needs repair
required: false
Expand Down Expand Up @@ -61,6 +66,7 @@ jobs:
PUSH_BEFORE_SHA: ${{ github.event.before }}
PUSH_FORCED: ${{ github.event.forced }}
REPAIR_TAG: ${{ inputs.release_tag }}
RESUME_SHA: ${{ inputs.candidate_sha }}
OPERATION: ${{ inputs.operation }}
shell: bash
run: |
Expand All @@ -84,6 +90,129 @@ jobs:
} >> "$GITHUB_OUTPUT"
exit 0
fi
if [[ "$OPERATION" == "resume" ]]; then
if [[ ! "$RESUME_SHA" =~ ^[0-9a-f]{40}$ ]]; then
echo "Manual resume requires candidate_sha as a full 40-character commit SHA." >&2
exit 1
fi
workflow_policy_sha=$(git rev-parse HEAD)
# Resume replays the admission its original run already persisted. A
# missing record means no run ever proved this candidate, so resume
# must not mint a fresh admission from GitHub state.
artifact_id=$(gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts?name=publication-candidate-${RESUME_SHA}" \
--jq '.artifacts | map(select(.expired == false)) | sort_by(.created_at) | reverse | .[0].id // empty')
if [[ -z "$artifact_id" ]]; then
echo "Resume requires the persisted publication candidate for ${RESUME_SHA}; it is absent or expired." >&2
echo "Recover by rerunning the original release run while its candidate artifact is retained." >&2
exit 1
fi
gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${artifact_id}/zip" > resume-candidate.zip
unzip -p resume-candidate.zip candidate.json > persisted-candidate.json
cp persisted-candidate.json candidate.json

version=$(jq -er '.version | select(type == "string" and length > 0)' persisted-candidate.json)
release_tag="v${version}"
if gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${release_tag}" >/dev/null 2>&1; then
Comment thread
myagentdojo marked this conversation as resolved.
Resolved
echo "Candidate tag ${release_tag} already exists; use manual incomplete-publication repair." >&2
exit 1
fi

pr_number=$(jq -er '.pullRequest | select(type == "number" and . > 0 and . == floor)' persisted-candidate.json)
admitted_automation_identity=$(jq -er '.automationIdentity | select(type == "string" and length > 0)' persisted-candidate.json)
if [[ "$admitted_automation_identity" != "$EXPECTED_RELEASE_PLEASE_LOGIN" ]]; then
echo "Persisted resume identity does not match the configured Release Please identity." >&2
exit 1
fi
release_pr=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${pr_number}")
base_branch=$(jq -r .base.ref <<< "$release_pr")
merged_pr_base_sha=$(jq -r .base.sha <<< "$release_pr")
reviewed_pr_head_sha=$(jq -r .head.sha <<< "$release_pr")
automation_identity=$(jq -r .user.login <<< "$release_pr")
merge_commit_sha=$(jq -r .merge_commit_sha <<< "$release_pr")
merged_at=$(jq -r '.merged_at // empty' <<< "$release_pr")
if [[ "$automation_identity" != "$EXPECTED_RELEASE_PLEASE_LOGIN" ]]; then
echo "Fresh resume PR identity does not match the configured Release Please identity." >&2
exit 1
fi
if [[ -z "$merged_at" || "$base_branch" != "$BASE_BRANCH" || "$merge_commit_sha" != "$RESUME_SHA" ]]; then
echo "Resume provenance does not match a merged Release Please candidate." >&2
exit 1
fi
candidate_commit=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${RESUME_SHA}")
candidate_parent_shas=$(jq -c '[.parents[].sha]' <<< "$candidate_commit")
trusted_base_sha=$(git rev-parse "${RESUME_SHA}^1")
projection=$(gh api --paginate "repos/${GITHUB_REPOSITORY}/pulls/${pr_number}/files" \
| jq -sc 'add | map({filename,status,sha}) | sort_by(.filename)')
printf '%s\n' "$projection" > resume-projection.json
# The candidate's first parent owns its historical projection policy.
git checkout --detach "$trusted_base_sha"
projection_result=$(bun run scripts/release-projection.ts \
--base "$trusted_base_sha" \
--head "$RESUME_SHA" \
--projection resume-projection.json \
--json)
projection_digest=$(jq -r .projectionDigest <<< "$projection_result")
manifest_version=$(gh api "repos/${GITHUB_REPOSITORY}/contents/plugin.config.json?ref=${RESUME_SHA}" --jq .content \
| tr -d '\n' | base64 --decode | jq -r .version)
jq -nS \
--argjson number "$pr_number" \
--arg baseBranch "$base_branch" \
--arg automationIdentity "$automation_identity" \
--arg mergeCommit "$merge_commit_sha" \
--arg projectionDigest "$projection_digest" \
--argjson candidateParentShas "$candidate_parent_shas" \
--arg trustedBaseSha "$trusted_base_sha" \
--arg mergedPrBaseSha "$merged_pr_base_sha" \
--arg reviewedPrHeadSha "$reviewed_pr_head_sha" \
--argjson changedFiles "$(jq '[.[].filename]' resume-projection.json)" \
--argjson changedFileStatuses "$(jq '[.[].status]' resume-projection.json)" \
'{number:$number,baseBranch:$baseBranch,automationIdentity:$automationIdentity,mergeCommit:$mergeCommit,changedFiles:$changedFiles,changedFileStatuses:$changedFileStatuses,projectionDigest:$projectionDigest,candidateParentShas:$candidateParentShas,trustedBaseSha:$trustedBaseSha,mergedPrBaseSha:$mergedPrBaseSha,reviewedPrHeadSha:$reviewedPrHeadSha}' \
> trusted-resume-candidate.json
# The workflow source owns the current identity and topology contract.
git checkout --detach "$workflow_policy_sha"
RESUME_REPOSITORY="$GITHUB_REPOSITORY" \
RESUME_BASE_BRANCH="$BASE_BRANCH" \
RESUME_AUTOMATION_LOGIN="$EXPECTED_RELEASE_PLEASE_LOGIN" \
RESUME_CANDIDATE_SHA="$RESUME_SHA" \
RESUME_VERSION="$manifest_version" \
bun -e '
import { readFileSync } from "node:fs";
import { validateResumeCandidateBinding } from "./scripts/release-validate.ts";
const {
candidateParentShas,
trustedBaseSha,
mergedPrBaseSha,
reviewedPrHeadSha,
...trustedCandidate
} = JSON.parse(readFileSync("trusted-resume-candidate.json", "utf8"));
validateResumeCandidateBinding({
candidate: JSON.parse(readFileSync("persisted-candidate.json", "utf8")),
trustedCandidate,
candidateParentShas,
trustedBaseSha,
mergedPrBaseSha,
reviewedPrHeadSha,
repository: process.env.RESUME_REPOSITORY ?? "",
expectedBaseBranch: process.env.RESUME_BASE_BRANCH ?? "",
expectedAutomationIdentities: [process.env.RESUME_AUTOMATION_LOGIN ?? ""],
candidateSha: process.env.RESUME_CANDIDATE_SHA ?? "",
manifestVersion: process.env.RESUME_VERSION ?? "",
tagExists: false,
});
'
{
echo "mode=resume"
echo "candidate_sha=${RESUME_SHA}"
echo "release_tag=${release_tag}"
echo "version=${version}"
echo "release_target_sha="
echo "trusted_base_sha=${trusted_base_sha}"
echo "merged_pr_base_sha=${merged_pr_base_sha}"
echo "reviewed_pr_head_sha=${reviewed_pr_head_sha}"
echo "record_reused=true"
} >> "$GITHUB_OUTPUT"
exit 0
fi
if [[ "$OPERATION" != "repair" ]]; then
echo "Unsupported manual operation: ${OPERATION}." >&2
exit 1
Expand Down Expand Up @@ -411,6 +540,38 @@ jobs:
echo "Release Please token identity ${actual_login} does not match configured identity ${EXPECTED_RELEASE_PLEASE_LOGIN}." >&2
exit 1
fi
# Release Please aborts silently on an untagged merged release PR. Detect that
# state first and name the exact recovery command, so a stranded candidate
# cannot quietly stop every later release.
- name: Detect a merged release candidate stranded before its tag
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
stranded=$(gh api --paginate \
"repos/${GITHUB_REPOSITORY}/issues?state=closed&labels=autorelease:%20pending&per_page=100" \
--jq '[.[] | select(.pull_request != null) | .number] | .[]' || true)
for pr_number in $stranded; do
release_pr=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${pr_number}")
merged_at=$(jq -r '.merged_at // empty' <<< "$release_pr")
merge_commit_sha=$(jq -r '.merge_commit_sha // empty' <<< "$release_pr")
[[ -n "$merged_at" && "$merge_commit_sha" =~ ^[0-9a-f]{40}$ ]] || continue
version=$(gh api "repos/${GITHUB_REPOSITORY}/contents/plugin.config.json?ref=${merge_commit_sha}" --jq .content \
| tr -d '\n' | base64 --decode | jq -r .version)
if gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/v${version}" >/dev/null 2>&1; then
continue
fi
echo "Release PR #${pr_number} merged at ${merge_commit_sha} but v${version} was never tagged." >&2
echo "Release Please will abort until this candidate is published or its lineage is reconciled." >&2
echo "Resume the proven candidate through the protected release environment:" >&2
echo "" >&2
echo " gh workflow run Release \\" >&2
echo " --repo ${GITHUB_REPOSITORY} \\" >&2
echo " --ref main \\" >&2
echo " -f operation=resume \\" >&2
echo " -f candidate_sha=${merge_commit_sha}" >&2
exit 1
done
# skip-github-release is set in release-please config. The action may maintain PRs but cannot tag or create a GitHub Release.
- name: Maintain release pull request
uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0
Expand All @@ -422,7 +583,10 @@ jobs:

candidate:
name: Build release candidate once
if: needs.resolve.outputs.mode == 'publish' || needs.resolve.outputs.mode == 'repair'
if: >-
needs.resolve.outputs.mode == 'publish' ||
needs.resolve.outputs.mode == 'resume' ||
needs.resolve.outputs.mode == 'repair'
needs: resolve
permissions:
actions: read
Expand Down Expand Up @@ -451,7 +615,7 @@ jobs:

compatibility:
name: Release compatibility (${{ matrix.target }})
if: needs.resolve.outputs.mode == 'publish'
if: needs.resolve.outputs.mode == 'publish' || needs.resolve.outputs.mode == 'resume'
needs:
- resolve
- candidate
Expand Down Expand Up @@ -502,7 +666,9 @@ jobs:
always() &&
needs.resolve.result == 'success' &&
needs.candidate.result == 'success' &&
(needs.resolve.outputs.mode == 'publish' || needs.resolve.outputs.mode == 'repair') &&
(needs.resolve.outputs.mode == 'publish' ||
needs.resolve.outputs.mode == 'resume' ||
needs.resolve.outputs.mode == 'repair') &&
(needs.compatibility.result == 'success' ||
(needs.resolve.outputs.mode == 'repair' && needs.compatibility.result == 'skipped'))
needs:
Expand Down Expand Up @@ -582,7 +748,9 @@ jobs:
always() &&
needs.resolve.result == 'success' &&
needs.package.result == 'success' &&
(needs.resolve.outputs.mode == 'publish' || needs.resolve.outputs.mode == 'repair')
(needs.resolve.outputs.mode == 'publish' ||
needs.resolve.outputs.mode == 'resume' ||
needs.resolve.outputs.mode == 'repair')
needs:
- resolve
- package
Expand Down Expand Up @@ -850,7 +1018,7 @@ jobs:
echo "Immutable remote tag does not target the proven candidate SHA." >&2
exit 1
fi
if [[ -z "$remote_tag_sha" && "$MODE" == "publish" ]]; then
if [[ -z "$remote_tag_sha" && ( "$MODE" == "publish" || "$MODE" == "resume" ) ]]; then
if [[ -n "$local_tag_sha" && "$local_tag_sha" != "$CANDIDATE_SHA" ]]; then
echo "Local release tag does not target the proven candidate SHA." >&2
exit 1
Expand Down Expand Up @@ -1027,7 +1195,7 @@ jobs:
"$PACKAGE_RESULT" == "skipped" &&
"$RELEASE_RESULT" == "skipped" ]] && converged=true
;;
publish)
publish|resume)
[[ "$MAINTAIN_RESULT" == "skipped" &&
"$CANDIDATE_RESULT" == "success" &&
"$COMPATIBILITY_RESULT" == "success" &&
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Contributors need Git and [Bun](https://bun.sh/docs/installation). Plugin consum
| Configure GitHub release automation | Read [Configure release automation](docs/release-setup.md). |
| Qualify fresh native capabilities | Read [Qualify fresh native capabilities](docs/native-capability-qualification.md). |
| Publish a release | Read [Publish a release](docs/publishing.md). |
| Maintain or repair release state | Read [Maintain or repair release state](docs/release-repair.md). |
| Maintain, resume, or repair release state | Read [Maintain, resume, or repair release state](docs/release-repair.md). |
| Qualify publishing-system changes with public/private canaries | Read [Qualify public and private canaries](docs/canary-qualification.md). |
| Change the distribution architecture or its language | Read [`CONTEXT.md`](CONTEXT.md), then the relevant [ADR](docs/adr/). |

Expand Down
2 changes: 1 addition & 1 deletion docs/adr/0003-reviewed-versioned-releases.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Packaging emits a deterministic `tar.gz` and `*.checksums.json`. The JSON binds

## Repair contract

Manual dispatch accepts `operation=maintenance` or `operation=repair`. Maintenance is the default and only updates the standing release PR. Repair requires `operation=repair` plus `release_tag` naming an existing `vX.Y.Z` tag. Repair begins from the immutable tag, rederives candidate topology and fresh PR authorship from GitHub, and checks identity against repository configuration rather than trusting the tag-carried record. It repeats the complete proof and validates any existing GitHub Release target. It compares each asset before writing: matching assets remain untouched, missing assets are added, and mismatches fail closed. A mismatched asset may be replaced only when `replace_mismatched_assets=true` is approved through the protected `release` environment. Repair never moves the tag and never represents a new release.
Manual dispatch accepts `operation=maintenance`, `operation=resume`, or `operation=repair`. Maintenance is the default and only updates the standing release PR; it fails closed and names the exact resume command when it detects a merged release PR whose tag was never created, so a stranded candidate cannot silently stop later releases. Resume requires `operation=resume` plus `candidate_sha` naming a merged, proven, untagged candidate. Resume recovers the publication-candidate record persisted before proof, revalidates PR identity, base branch, merge binding, topology, projection, and version against that record, requires the target tag to be absent, and repeats the complete proof before entering the protected `release` environment. It never mints a fresh admission, never reconstructs an expired record, and never creates an ad hoc tag. Repair requires `operation=repair` plus `release_tag` naming an existing `vX.Y.Z` tag. Repair begins from the immutable tag, rederives candidate topology and fresh PR authorship from GitHub, and checks identity against repository configuration rather than trusting the tag-carried record. It repeats the complete proof and validates any existing GitHub Release target. It compares each asset before writing: matching assets remain untouched, missing assets are added, and mismatches fail closed. A mismatched asset may be replaced only when `replace_mismatched_assets=true` is approved through the protected `release` environment. Repair never moves the tag and never represents a new release.

## Human-owned safeguards

Expand Down
Loading