From a388cb09c9b6a182de5ceff763f361d805ec8dfa Mon Sep 17 00:00:00 2001 From: sunerpy Date: Thu, 22 Oct 2026 05:12:26 +0800 Subject: [PATCH] ci(release): accept the release PR's CI when its push event is lost GitHub never delivered the push event for v0.55.0's release commit 5f96a741: the commit has no workflow run, and the repository activity log has no merge entry for it. Release Please runs only on push, and verify-ci accepts only a CI push run on the exact release commit, which can no longer appear because ci.yml has no dispatch trigger. The release could not finish. A manual Release dispatch can now name the release PR's own CI run in a new ci_run_id input. verify-ci uses it only while the release commit has no CI push run, and only if the run is a completed pull_request run of ci.yml in this repository whose CI Success passed, its head is the head of the one merged pull request into main whose merge commit is the release commit, that head contains the release commit's single parent, and both commits share a tree. The new step needs pull-requests: read for commits//pulls. When verify-ci took that path, publish-release checks again before it makes the release public: a CI push run that has appeared since, from a replayed event for example, must pass first. The push path is unchanged. check-ci-gate.sh now applies the exact-SHA rule to the push-run wait step itself and pins that step's condition, the dispatch-only condition of every step that reads ci_run_id, the new permission, and the publish-time check. release-ci-override.test.sh runs both new steps against a stub gh. --- .github/workflows/release.yml | 134 +++++++++++++++++++- CONTRIBUTING.md | 11 ++ scripts/check-ci-gate.sh | 29 ++++- scripts/tests/ci-gate.test.sh | 7 +- scripts/tests/release-ci-override.test.sh | 142 ++++++++++++++++++++++ 5 files changed, 318 insertions(+), 5 deletions(-) create mode 100755 scripts/tests/release-ci-override.test.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ae359d25..138aa8c4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 @@ -122,6 +127,10 @@ jobs: permissions: actions: read contents: read + # commits//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 @@ -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:-} 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 }} @@ -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 @@ -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:-} 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: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index de738cfb..1d236ed5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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. diff --git a/scripts/check-ci-gate.sh b/scripts/check-ci-gate.sh index 6913951a..3eb7d484 100755 --- a/scripts/check-ci-gate.sh +++ b/scripts/check-ci-gate.sh @@ -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') diff --git a/scripts/tests/ci-gate.test.sh b/scripts/tests/ci-gate.test.sh index aa4e25cf..b9afda1a 100755 --- a/scripts/tests/ci-gate.test.sh +++ b/scripts/tests/ci-gate.test.sh @@ -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//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 ] diff --git a/scripts/tests/release-ci-override.test.sh b/scripts/tests/release-ci-override.test.sh new file mode 100755 index 00000000..e228b792 --- /dev/null +++ b/scripts/tests/release-ci-override.test.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +# Run the Release workflow's PR-CI fallback against a stub `gh`. The dispatch-only +# verify-ci step must accept only the release PR's own CI on an identical tree, +# defer to the exact-SHA gate when a push run exists, and reject everything else; +# the publish-time re-check must stop publication when a push run that appeared +# later fails. +set -euo pipefail +root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd -P)" +work=$(mktemp -d); trap 'rm -rf "$work"' EXIT +CG_WORKFLOW="$root/.github/workflows/release.yml" CG_OUT="$work" python3 - <<'PY' +import os,sys +try: import yaml +except ImportError: print('release-ci-override: ERROR: PyYAML is required',file=sys.stderr); raise SystemExit(2) +jobs=yaml.safe_load(open(os.environ['CG_WORKFLOW'],encoding='utf-8')).get('jobs') or {} +for job,step_id,name in (('verify-ci','pr_ci','pr_ci.sh'),('publish-release','exact_ci_recheck','recheck.sh')): + steps=[s for s in (jobs.get(job) or {}).get('steps') or [] if isinstance(s,dict) and s.get('id')==step_id] + if len(steps)!=1: print(f'release-ci-override: ERROR: expected one {job} step with id {step_id}, got {len(steps)}',file=sys.stderr); raise SystemExit(2) + open(os.path.join(os.environ['CG_OUT'],name),'w',encoding='utf-8').write(steps[0]['run']) +PY + +# `gh api [--jq ]` answered from $GH_STUB: call n of a path reads +# ..json when present, else .json; a missing answer is an API failure. +mkdir -p "$work/bin" +cat >"$work/bin/gh" <<'SH' +#!/usr/bin/env bash +[ "${1:-}" = api ] || { echo "stub gh: unsupported: $*" >&2; exit 64; } +path=${2:?}; shift 2 +filter= +if [ "${1:-}" = --jq ]; then filter=${2:?}; fi +base="$GH_STUB/$(printf '%s' "$path" | tr '/?&=.' '_____')" +calls=$(( $(cat "$base.calls" 2>/dev/null || echo 0) + 1 )); echo "$calls" >"$base.calls" +file="$base.$calls.json"; [ -f "$file" ] || file="$base.json" +[ -f "$file" ] || { echo "stub gh: HTTP 404 for $path" >&2; exit 1; } +if [ -n "$filter" ]; then jq -r "$filter" "$file"; else cat "$file"; fi +SH +printf '#!/usr/bin/env bash\nexit 0\n' >"$work/bin/sleep" +chmod +x "$work/bin/gh" "$work/bin/sleep" + +repo=sunerpy/codegraph-rust +release=1111111111111111111111111111111111111111 +parent=2222222222222222222222222222222222222222 +head=3333333333333333333333333333333333333333 +tree=4444444444444444444444444444444444444444 +other=5555555555555555555555555555555555555555 +run_id=39605156993 +push_run=40000000001 +push_runs="repos/$repo/actions/runs?head_sha=$release&event=push&branch=main&per_page=100" +run_path="repos/$repo/actions/runs/$run_id" +jobs_path="repos/$repo/actions/runs/$run_id/jobs?per_page=100" +push_jobs_path="repos/$repo/actions/runs/$push_run/jobs?per_page=100" +pulls_path="repos/$repo/commits/$release/pulls" +pr_path="repos/$repo/pulls/325" +release_path="repos/$repo/commits/$release" +compare_path="repos/$repo/compare/$parent...$head" +head_path="repos/$repo/commits/$head" + +key() { printf '%s' "$1" | tr '/?&=.' '_____'; } +# answer [call] +answer() { printf '%s\n' "$3" >"$work/$1/$(key "$2")${4:+.$4}.json"; } +# Every check holds: the shape of v0.55.0's release PR #325. +scenario() { + mkdir -p "$work/$1" + answer "$1" "$push_runs" '{"workflow_runs":[]}' + answer "$1" "$run_path" "{\"name\":\"CI\",\"path\":\".github/workflows/ci.yml\",\"event\":\"pull_request\",\"status\":\"completed\",\"head_sha\":\"$head\",\"repository\":{\"full_name\":\"$repo\"},\"head_repository\":{\"full_name\":\"$repo\"}}" + answer "$1" "$jobs_path" '{"jobs":[{"name":"Linux","conclusion":"success"},{"name":"CI Success","status":"completed","conclusion":"success"}]}' + answer "$1" "$pulls_path" '[{"number":325}]' + answer "$1" "$pr_path" "{\"merged\":true,\"base\":{\"ref\":\"main\"},\"merge_commit_sha\":\"$release\",\"head\":{\"sha\":\"$head\"}}" + answer "$1" "$release_path" "{\"parents\":[{\"sha\":\"$parent\"}],\"commit\":{\"tree\":{\"sha\":\"$tree\"}}}" + answer "$1" "$compare_path" '{"behind_by":0}' + answer "$1" "$head_path" "{\"commit\":{\"tree\":{\"sha\":\"$tree\"}}}" +} +# A push CI run for the release commit whose CI Success job reads $2 on every call. +with_push_run() { + answer "$1" "$push_runs" "{\"workflow_runs\":[{\"id\":$push_run,\"name\":\"CI\",\"created_at\":\"2026-10-22T00:00:00Z\"}]}" + answer "$1" "$push_jobs_path" "{\"jobs\":[{\"name\":\"CI Success\",$2}]}" +} +edit() { + local file + file="$work/$1/$(key "$2").json" + jq "$3" "$file" >"$file.new" && mv "$file.new" "$file" +} +drop() { rm -f "$work/$1/$(key "$2").json"; } + +pass=0; fail=0 +# check pr_ci|recheck accept|defer|reject|pass [run id] +check() { + local name=$1 body=$2 want=$3 regex=$4 id=${5:-$run_id} rc ok=1 + : >"$work/$name.output" + set +e + PATH="$work/bin:$PATH" GH_STUB="$work/$name" GITHUB_OUTPUT="$work/$name.output" \ + CI_RUN_ID="$id" GH_TOKEN=stub RELEASE_SHA="$release" HEAD_SHA="$release" REPO="$repo" \ + bash "$work/$body.sh" >"$work/$name.log" 2>&1 + rc=$? + set -e + case $want in + accept) [ "$rc" -eq 0 ] && grep -qx 'accepted=true' "$work/$name.output" || ok=0 ;; + defer) [ "$rc" -eq 0 ] && grep -qx 'accepted=false' "$work/$name.output" || ok=0 ;; + reject) [ "$rc" -ne 0 ] && ! grep -q 'accepted=' "$work/$name.output" || ok=0 ;; + pass) [ "$rc" -eq 0 ] || ok=0 ;; + esac + grep -Eq -- "$regex" "$work/$name.log" || ok=0 + if [ "$ok" -eq 1 ]; then echo "PASS: $name"; pass=$((pass+1)); + else echo "FAIL: $name (exit $rc)" >&2; cat "$work/$name.log" "$work/$name.output" >&2; fail=$((fail+1)); fi +} + +# verify-ci: the dispatch-only step that may accept the release PR's CI. +scenario A_accept; check A_accept pr_ci accept "::warning title=Release CI::.*accepted CI run $run_id of PR #325 \\(head $head, tree $tree\\)" +scenario B_push_run; edit B_push_run "$push_runs" '.workflow_runs=[{"name":"CI"}]'; check B_push_run pr_ci defer 'exact-SHA gate decides' +scenario C_run_id; check C_run_id pr_ci reject "ci_run_id must be a run ID, got '12a'" 12a +scenario D_repository; edit D_repository "$run_path" '.repository.full_name="someone/else"'; check D_repository pr_ci reject 'belongs to another repository' +scenario E_fork; edit E_fork "$run_path" '.head_repository.full_name="fork/codegraph-rust"'; check E_fork pr_ci reject 'tested a head outside' +scenario F_name; edit F_name "$run_path" '.name="Release"'; check F_name pr_ci reject 'is not the CI workflow' +scenario G_path; edit G_path "$run_path" '.path=".github/workflows/other.yml"'; check G_path pr_ci reject 'is not \.github/workflows/ci\.yml' +scenario H_event; edit H_event "$run_path" '.event="push"'; check H_event pr_ci reject 'is not a pull_request run' +scenario I_status; edit I_status "$run_path" '.status="in_progress"'; check I_status pr_ci reject 'has not completed' +scenario J_gate; edit J_gate "$jobs_path" '(.jobs[] | select(.name == "CI Success") | .conclusion)="failure"'; check J_gate pr_ci reject 'CI Success concluded failure' +scenario K_no_gate; edit K_no_gate "$jobs_path" '.jobs |= map(select(.name != "CI Success"))'; check K_no_gate pr_ci reject 'CI Success concluded ' +scenario L_no_pr; edit L_no_pr "$pulls_path" '[]'; check L_no_pr pr_ci reject '0 merged pull requests' +scenario M_unmerged; edit M_unmerged "$pr_path" '.merged=false'; check M_unmerged pr_ci reject '0 merged pull requests' +scenario N_merge_sha; edit N_merge_sha "$pr_path" ".merge_commit_sha=\"$other\""; check N_merge_sha pr_ci reject '0 merged pull requests' +scenario O_pr_head; edit O_pr_head "$pr_path" ".head.sha=\"$other\""; check O_pr_head pr_ci reject '0 merged pull requests' +scenario P_base; edit P_base "$pr_path" '.base.ref="release"'; check P_base pr_ci reject '0 merged pull requests' +scenario Q_two_prs; edit Q_two_prs "$pulls_path" '[{"number":325},{"number":326}]'; cp "$work/Q_two_prs/$(key "$pr_path").json" "$work/Q_two_prs/$(key "repos/$repo/pulls/326").json"; check Q_two_prs pr_ci reject '2 merged pull requests' +scenario R_parents; edit R_parents "$release_path" ".parents += [{\"sha\":\"$other\"}]"; check R_parents pr_ci reject 'does not have exactly one parent' +scenario S_behind; edit S_behind "$compare_path" '.behind_by=1'; check S_behind pr_ci reject 'is 1 commit\(s\) behind' +scenario T_tree; edit T_tree "$head_path" ".commit.tree.sha=\"$other\""; check T_tree pr_ci reject "tree $other of $head differs from the release commit's tree $tree" +scenario U_run_api; drop U_run_api "$run_path"; check U_run_api pr_ci reject "cannot read CI run $run_id" +scenario V_push_api; drop V_push_api "$push_runs"; check V_push_api pr_ci reject 'cannot list the push runs' +scenario W_compare_api; drop W_compare_api "$compare_path"; check W_compare_api pr_ci reject 'cannot compare' + +# publish-release: the re-check after the fallback was accepted. +scenario X1_still_none; check X1_still_none recheck pass 'the accepted release PR CI stands' +scenario X2_later_pass; with_push_run X2_later_pass '"status":"completed","conclusion":"success"'; check X2_later_pass recheck pass "CI Success passed in push run $push_run" +scenario X3_later_fail; with_push_run X3_later_fail '"status":"completed","conclusion":"failure"'; check X3_later_fail recheck reject "CI Success concluded failure in push run $push_run" +scenario X4_running_pass; with_push_run X4_running_pass '"status":"completed","conclusion":"success"'; answer X4_running_pass "$push_jobs_path" '{"jobs":[{"name":"CI Success","status":"in_progress"}]}' 1; check X4_running_pass recheck pass "CI Success passed in push run $push_run" +scenario X5_running_fail; with_push_run X5_running_fail '"status":"completed","conclusion":"cancelled"'; answer X5_running_fail "$push_jobs_path" '{"jobs":[]}' 1; check X5_running_fail recheck reject 'CI Success concluded cancelled' +scenario X6_never_done; with_push_run X6_never_done '"status":"queued"'; check X6_never_done recheck reject 'has not completed; re-run this job after it finishes' +scenario X7_list_api; drop X7_list_api "$push_runs"; check X7_list_api recheck reject 'cannot list the push runs' +scenario X8_jobs_api; with_push_run X8_jobs_api '"status":"completed","conclusion":"success"'; drop X8_jobs_api "$push_jobs_path"; check X8_jobs_api recheck reject "cannot read the jobs of push run $push_run" + +echo "release-ci-override harness: $pass passed, $fail failed" +[ "$fail" -eq 0 ]