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
134 changes: 133 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ on:
description: "Existing draft release tag to rebuild, for example v1.2.3"
required: true
type: string
ci_run_id:
description: "Only when GitHub dropped the release commit's push event: the release PR's CI run on the same tree"
required: false
default: ""
type: string

permissions:
contents: read
Expand Down Expand Up @@ -122,6 +127,10 @@ jobs:
permissions:
actions: read
contents: read
# commits/<sha>/pulls in the dispatch-only PR-CI step.
pull-requests: read
outputs:
pr_ci_accepted: ${{ steps.pr_ci.outputs.accepted }}
steps:
- name: Checkout release tag
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Expand All @@ -141,7 +150,87 @@ jobs:
exit 1
fi

# GitHub can drop the push event of a merge (v0.55.0's release commit got
# no run at all), and ci.yml has no dispatch trigger, so no push run can
# appear for that commit afterwards. A manual rebuild may then name the
# release PR's own CI run; it is accepted only for an identical tree and
# only while the release commit has no push CI run.
- name: Accept the release PR's CI when the push run is missing
id: pr_ci
if: github.event_name == 'workflow_dispatch' && inputs.ci_run_id != ''
shell: bash
env:
CI_RUN_ID: ${{ inputs.ci_run_id }}
GH_TOKEN: ${{ github.token }}
RELEASE_SHA: ${{ needs.release-please.outputs.release_sha }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
fail() {
echo "::error title=Release CI::$*"
exit 1
}
push_runs=$(gh api "repos/${REPO}/actions/runs?head_sha=${RELEASE_SHA}&event=push&branch=main&per_page=100" \
--jq '[.workflow_runs[] | select(.name == "CI")] | length') \
|| fail "cannot list the push runs of ${RELEASE_SHA}"
[[ "$push_runs" =~ ^[0-9]+$ ]] || fail "unexpected push-run count '${push_runs}' for ${RELEASE_SHA}"
if [ "$push_runs" != 0 ]; then
echo "A push CI run exists for ${RELEASE_SHA}; the exact-SHA gate decides."
echo "accepted=false" >>"$GITHUB_OUTPUT"
exit 0
fi

[[ "$CI_RUN_ID" =~ ^[0-9]+$ ]] || fail "ci_run_id must be a run ID, got '${CI_RUN_ID}'"
run=$(gh api "repos/${REPO}/actions/runs/${CI_RUN_ID}") || fail "cannot read CI run ${CI_RUN_ID}"
field() { jq -r "$1" <<<"$run"; }
[ "$(field '.repository.full_name')" = "$REPO" ] || fail "run ${CI_RUN_ID} belongs to another repository"
[ "$(field '.head_repository.full_name')" = "$REPO" ] || fail "run ${CI_RUN_ID} tested a head outside ${REPO}"
[ "$(field '.name')" = CI ] || fail "run ${CI_RUN_ID} is not the CI workflow"
[ "$(field '.path')" = .github/workflows/ci.yml ] || fail "run ${CI_RUN_ID} is not .github/workflows/ci.yml"
[ "$(field '.event')" = pull_request ] || fail "run ${CI_RUN_ID} is not a pull_request run"
[ "$(field '.status')" = completed ] || fail "run ${CI_RUN_ID} has not completed"
head=$(field '.head_sha')
[[ "$head" =~ ^[0-9a-f]{40}$ ]] || fail "run ${CI_RUN_ID} has no head SHA"

gate=$(gh api "repos/${REPO}/actions/runs/${CI_RUN_ID}/jobs?per_page=100" \
--jq '[.jobs[] | select(.name == "CI Success")] | last | .conclusion // empty') \
|| fail "cannot read the jobs of run ${CI_RUN_ID}"
[ "$gate" = success ] || fail "CI Success concluded ${gate:-<missing>} in run ${CI_RUN_ID}"

prs=$(gh api "repos/${REPO}/commits/${RELEASE_SHA}/pulls") \
|| fail "cannot list the pull requests of ${RELEASE_SHA}"
mapfile -t numbers < <(jq -r '.[].number' <<<"$prs")
matched=()
for number in "${numbers[@]}"; do
pr=$(gh api "repos/${REPO}/pulls/${number}") || fail "cannot read pull request #${number}"
if [ "$(jq -r '.merged' <<<"$pr")" = true ] \
&& [ "$(jq -r '.base.ref' <<<"$pr")" = main ] \
&& [ "$(jq -r '.merge_commit_sha' <<<"$pr")" = "$RELEASE_SHA" ] \
&& [ "$(jq -r '.head.sha' <<<"$pr")" = "$head" ]; then
matched+=("$number")
fi
done
[ "${#matched[@]}" -eq 1 ] \
|| fail "${#matched[@]} merged pull requests into main have merge commit ${RELEASE_SHA} and head ${head}; exactly one is required"

release=$(gh api "repos/${REPO}/commits/${RELEASE_SHA}") || fail "cannot read ${RELEASE_SHA}"
[ "$(jq -r '.parents | length' <<<"$release")" = 1 ] || fail "${RELEASE_SHA} does not have exactly one parent"
parent=$(jq -r '.parents[0].sha' <<<"$release")
release_tree=$(jq -r '.commit.tree.sha' <<<"$release")
behind=$(gh api "repos/${REPO}/compare/${parent}...${head}" --jq '.behind_by') \
|| fail "cannot compare ${parent}...${head}"
# A head that contains the merge's parent contains every earlier main,
# so the PR run tested the head's own tree.
[ "$behind" = 0 ] || fail "PR head ${head} is ${behind} commit(s) behind ${parent}"
head_tree=$(gh api "repos/${REPO}/commits/${head}" --jq '.commit.tree.sha') || fail "cannot read ${head}"
[ "$head_tree" = "$release_tree" ] \
|| fail "tree ${head_tree} of ${head} differs from the release commit's tree ${release_tree}"

echo "::warning title=Release CI::no push CI run exists for ${RELEASE_SHA}; accepted CI run ${CI_RUN_ID} of PR #${matched[0]} (head ${head}, tree ${release_tree})"
echo "accepted=true" >>"$GITHUB_OUTPUT"

- name: Wait for exact-SHA CI Success
if: steps.pr_ci.outputs.accepted != 'true'
shell: bash
env:
GH_TOKEN: ${{ github.token }}
Expand Down Expand Up @@ -526,12 +615,13 @@ jobs:

publish-release:
name: Publish Release
needs: [release-please, upload-assets]
needs: [release-please, verify-ci, upload-assets]
if: needs.release-please.outputs.build_artifacts == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 10
environment: release
permissions:
actions: read
contents: write
steps:
- name: Checkout release tooling
Expand All @@ -546,6 +636,48 @@ jobs:
name: checksums
path: dist

# verify-ci accepted the release PR's CI because no push run existed then.
# A CI push run can still appear before publication (for example when
# GitHub replays the dropped push event); publish only if it passes too.
- name: Re-check exact-SHA CI after accepting the release PR's CI
id: exact_ci_recheck
if: needs.verify-ci.outputs.pr_ci_accepted == 'true'
shell: bash
env:
GH_TOKEN: ${{ github.token }}
HEAD_SHA: ${{ needs.release-please.outputs.release_sha }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
fail() {
echo "::error title=Release CI::$*"
exit 1
}
run_id=$(gh api "repos/${REPO}/actions/runs?head_sha=${HEAD_SHA}&event=push&branch=main&per_page=100" \
--jq '[.workflow_runs[] | select(.name == "CI")] | sort_by(.created_at) | last | .id // empty') \
|| fail "cannot list the push runs of ${HEAD_SHA}"
if [ -z "$run_id" ]; then
echo "No CI push run exists for ${HEAD_SHA}; the accepted release PR CI stands."
exit 0
fi
# Stay inside the job's 10 minutes; re-run this job once a slower run finishes.
for _ in $(seq 1 30); do
job=$(gh api "repos/${REPO}/actions/runs/${run_id}/jobs?per_page=100" \
--jq '[.jobs[] | select(.name == "CI Success")] | last // {}') \
|| fail "cannot read the jobs of push run ${run_id}"
status=$(jq -r '.status // empty' <<<"$job")
conclusion=$(jq -r '.conclusion // empty' <<<"$job")
if [ "$status" != completed ]; then
sleep 15
continue
fi
[ "$conclusion" = success ] \
|| fail "CI Success concluded ${conclusion:-<missing>} in push run ${run_id} for ${HEAD_SHA}"
echo "CI Success passed in push run ${run_id} for ${HEAD_SHA}."
exit 0
done
fail "CI Success of push run ${run_id} has not completed; re-run this job after it finishes"

- name: Verify remote assets and publish the draft
shell: bash
env:
Expand Down
11 changes: 11 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,14 @@ placeholder, tags, or published release assets manually. Release Please owns
all four version surfaces. Maintainers publish only through
the draft-until-verified workflow after exact-SHA CI, all platform archives,
checksums, attestations, and archive smoke pass.

GitHub can drop the push event of a release commit. Then no CI push run can
appear for that commit, and the exact-SHA gate cannot pass. Once the next merge
to `main` has let Release Please create the draft, dispatch the Release workflow
on `main` with `tag_name` and `ci_run_id`, the ID of the release PR's own CI
run. The workflow accepts that run only if it is a completed `pull_request` run
of `ci.yml` in this repository with `CI Success` passed, its head is the head of
the merged release PR, that head contains the release commit's parent, and both
commits have the same tree. While the release commit has a CI push run, the
exact-SHA gate decides, and a CI push run that appears after that run was
accepted must pass before the publish job makes the release public.
29 changes: 26 additions & 3 deletions scripts/check-ci-gate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,32 @@ def runs(job):
if 'release-please' not in needset('source-gate'): fail('release-gate','source-gate must need release-please')
if 'scripts/check-workspace-versions.sh' not in runs('source-gate'): fail('release-gate','source-gate does not run the first-Cargo version gate')
if 'release-please' not in needset('verify-ci'): fail('release-gate','verify-ci must need release-please')
verify_ci_body=runs('verify-ci')
if 'select(.name == "CI Success")' not in verify_ci_body or 'conclusion' not in verify_ci_body or '= success' not in verify_ci_body:
fail('release-gate','verify-ci does not require exact-SHA CI Success')
verify_ci_steps=[s for s in ((rel_jobs.get('verify-ci') or {}).get('steps') or []) if isinstance(s,dict)]
exact_ci=[s for s in verify_ci_steps if 'inputs.ci_run_id' not in str(s) and 'event=push' in str(s.get('run',''))]
if len(exact_ci)!=1: fail('release-gate',f'verify-ci needs one exact-SHA push-run wait step, found {len(exact_ci)}')
else:
verify_ci_body=str(exact_ci[0].get('run',''))
if 'select(.name == "CI Success")' not in verify_ci_body or 'conclusion' not in verify_ci_body or '= success' not in verify_ci_body:
fail('release-gate','verify-ci does not require exact-SHA CI Success')
if str(exact_ci[0].get('if','')).strip() not in ('',"steps.pr_ci.outputs.accepted != 'true'"):
fail('release-gate',f"the exact-SHA wait step is skipped by {exact_ci[0].get('if')!r}")
for step in verify_ci_steps:
if 'inputs.ci_run_id' in str(step) and "github.event_name == 'workflow_dispatch'" not in str(step.get('if','')):
fail('release-gate',f"verify-ci step {step.get('name')!r} reads inputs.ci_run_id outside workflow_dispatch")
verify_ci_job=rel_jobs.get('verify-ci') or {}
if (verify_ci_job.get('permissions') or {}).get('pull-requests')!='read': fail('release-gate','verify-ci lacks pull-requests: read for the PR-CI step')
if str((verify_ci_job.get('outputs') or {}).get('pr_ci_accepted','')).replace(' ','')!='${{steps.pr_ci.outputs.accepted}}':
fail('release-gate','verify-ci does not expose pr_ci_accepted from the pr_ci step')
if 'verify-ci' not in needset('publish-release'): fail('release-gate','publish-release must need verify-ci')
publish_steps=[s for s in ((rel_jobs.get('publish-release') or {}).get('steps') or []) if isinstance(s,dict)]
recheck=[i for i,s in enumerate(publish_steps) if str(s.get('if','')).strip()=="needs.verify-ci.outputs.pr_ci_accepted == 'true'"]
publish=[i for i,s in enumerate(publish_steps) if '--publish' in str(s.get('run',''))]
if len(recheck)!=1 or not publish or recheck[0]>publish[0]:
fail('release-gate','publish-release does not re-check exact-SHA CI before publishing after the PR-CI fallback')
else:
recheck_body=str(publish_steps[recheck[0]].get('run',''))
if 'event=push' not in recheck_body or 'select(.name == "CI Success")' not in recheck_body or '= success' not in recheck_body:
fail('release-gate','the publish-time re-check does not require CI Success of the exact-SHA push run')
if not {'release-please','source-gate'} <= needset('verify'): fail('release-gate','verify must need release-please and source-gate')
if 'make check' not in runs('verify'): fail('release-gate','verify does not run the complete local gate')
if not {'release-please','source-gate'} <= needset('build-binaries'): fail('release-gate','build-binaries bypasses source-gate')
Expand Down
7 changes: 6 additions & 1 deletion scripts/tests/ci-gate.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,13 @@ F=$(fixture F_coverage); mutate "$F/codecov.yml" ' informational: true' '
G=$(fixture G_source); mutate "$G/.github/workflows/release.yml" ' run: bash scripts/check-workspace-versions.sh' ' run: echo skipped'; run G_source "$G"; expect G_source nonzero 'first-Cargo version gate'
H=$(fixture H_verify); mutate "$H/.github/workflows/release.yml" ' needs: [release-please, source-gate]' ' needs: release-please'; run H_verify "$H"; expect H_verify nonzero 'verify must need release-please and source-gate'
I=$(fixture I_upload); mutate "$I/.github/workflows/release.yml" ' needs: [release-please, source-gate, verify-ci, verify, build-binaries]' ' needs: [release-please, source-gate, verify, build-binaries]'; run I_upload "$I"; expect I_upload nonzero 'upload-assets bypasses'
J=$(fixture J_exact_ci); mutate "$J/.github/workflows/release.yml" 'select(.name == "CI Success")' 'select(.name == "CI Maybe")'; run J_exact_ci "$J"; expect J_exact_ci nonzero 'verify-ci does not require exact-SHA CI Success'
J=$(fixture J_exact_ci); mutate "$J/.github/workflows/release.yml" 'select(.name == "CI Success")] | last // {}' 'select(.name == "CI Maybe")] | last // {}'; run J_exact_ci "$J"; expect J_exact_ci nonzero 'verify-ci does not require exact-SHA CI Success'
K=$(fixture K_yaml); mutate "$K/.github/workflows/ci.yml" 'jobs:' 'jobs: [bad]\nignored:'; run K_yaml "$K"; expect K_yaml nonzero 'check-ci-gate: ERROR'
L=$(fixture L_pr_ci_on_push); mutate "$L/.github/workflows/release.yml" "if: github.event_name == 'workflow_dispatch' && inputs.ci_run_id != ''" "if: inputs.ci_run_id != ''"; run L_pr_ci_on_push "$L"; expect L_pr_ci_on_push nonzero 'reads inputs.ci_run_id outside workflow_dispatch'
M=$(fixture M_exact_skipped); mutate "$M/.github/workflows/release.yml" "if: steps.pr_ci.outputs.accepted != 'true'" "if: steps.pr_ci.outputs.accepted != 'false'"; run M_exact_skipped "$M"; expect M_exact_skipped nonzero 'exact-SHA wait step is skipped'
N=$(fixture N_pr_read); mutate "$N/.github/workflows/release.yml" $' # commits/<sha>/pulls in the dispatch-only PR-CI step.\n pull-requests: read\n' ''; run N_pr_read "$N"; expect N_pr_read nonzero 'lacks pull-requests: read'
O=$(fixture O_no_recheck); mutate "$O/.github/workflows/release.yml" "if: needs.verify-ci.outputs.pr_ci_accepted == 'true'" 'if: false'; run O_no_recheck "$O"; expect O_no_recheck nonzero 'does not re-check exact-SHA CI before publishing'
P=$(fixture P_publish_needs); mutate "$P/.github/workflows/release.yml" ' needs: [release-please, verify-ci, upload-assets]' ' needs: [release-please, upload-assets]'; run P_publish_needs "$P"; expect P_publish_needs nonzero 'publish-release must need verify-ci'

echo "ci-gate harness: $pass passed, $fail failed"
[ "$fail" -eq 0 ]
Loading
Loading