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 ]