diff --git a/.github/workflows/bot-sweep.yml b/.github/workflows/bot-sweep.yml new file mode 100644 index 00000000..e19ef05e --- /dev/null +++ b/.github/workflows/bot-sweep.yml @@ -0,0 +1,41 @@ +name: Bot sweep + +# Hourly upkeep of the bot's PRs (branches autobump/*, opened by github-actions[bot] +# with GITHUB_TOKEN, so their own pull_request runs wait for approval). +# +# ci-fill: hack/bot-ci-dispatch runs ci.yml by workflow_dispatch on each bot PR +# branch with no run at its head, and re-runs a first-attempt failure +# once, one bot run in flight at a time and none while a human PR's CI +# is queued. It fills in what the nightly deferred. + +on: + schedule: + - cron: '13 * * * *' + workflow_dispatch: + +permissions: {} + +concurrency: + group: bot-sweep + cancel-in-progress: false + +jobs: + ci-fill: + name: CI on bot PRs + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + actions: write + contents: read + pull-requests: read + steps: + - name: Checkout main + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: main + persist-credentials: false + + - name: Dispatch CI on bot PRs that have none at their head + env: + GH_TOKEN: ${{ github.token }} + run: hack/bot-ci-dispatch diff --git a/.github/workflows/build-and-push.yml b/.github/workflows/build-and-push.yml index 46c75bd8..8a60b807 100644 --- a/.github/workflows/build-and-push.yml +++ b/.github/workflows/build-and-push.yml @@ -440,8 +440,11 @@ jobs: # component on branch autobump/. Merging it changes share/** on main, # which auto-release.yml turns into a launcher release, and that release # dispatches the `release` job above to build the image. A PR opened with - # GITHUB_TOKEN starts no CI workflows, so the tests run here before it opens. - # A new component is a new hack/components// dir; the matrix picks it up. + # GITHUB_TOKEN gets pull_request CI runs that wait for a maintainer's approval, + # so the tests run here before it opens, and hack/bot-ci-dispatch then runs + # ci.yml on the branch by workflow_dispatch (no approval): that run is the PR's + # CI evidence. A new component is a new hack/components// dir; the matrix + # picks it up. list-components: if: ${{ !inputs.release }} runs-on: ubuntu-latest @@ -466,6 +469,7 @@ jobs: permissions: contents: write pull-requests: write + actions: write strategy: fail-fast: false matrix: @@ -503,6 +507,12 @@ jobs: - name: Render tests run: tests/render_test.sh + # create-pull-request force-pushes an existing bump branch, which GitHub + # refuses from GITHUB_TOKEN once main's workflow files differ from the + # branch's: delete such a branch first (its PR closes; a fresh one opens). + - name: Delete a bump branch whose workflows differ from main's + run: hack/autobump-branch-reset "autobump/${COMPONENT}" + - name: Open or update the bump PR uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 with: @@ -522,27 +532,38 @@ jobs: - Pins (version, chart sha256, image digests) moved in `hack/components/${{ matrix.component }}/component.conf`. - `tests/detect_test.sh` and `tests/render_test.sh` passed in the workflow run that opened this PR. + - CI: this PR's own pull_request runs wait for approval (a GITHUB_TOKEN PR). + The evidence is the ci.yml run dispatched on `autobump/${{ matrix.component }}` + at the head SHA: `gh run list -w ci.yml -e workflow_dispatch -b autobump/${{ matrix.component }}`. - Merging rebuilds the node image with the new manifest baked in. Review the manifest diff for new CRDs, RBAC, images and flags before merging. + - name: Dispatch CI on the bump PR's branch + env: + GH_TOKEN: ${{ github.token }} + run: hack/bot-ci-dispatch --branch "autobump/${COMPONENT}" + # ─── Image digests ─────────────────────────────────────────────────────── # Every image reference is pinned name:tag@sha256 (scripts/images-lock pins). # A registry can re-point a tag (a rebuilt base image, a security respin) with # no new tag, which the version checks above never see. Daily: re-resolve the # tag of every pinned reference, write the registry's digest where it moved, # re-pin images.lock, run the image tests, and open or update one PR on branch - # autobump/image-digests. A PR opened with GITHUB_TOKEN starts no CI workflows, - # so the tests run here before it opens. GITHUB_TOKEN cannot push a change to - # .github/workflows/ (no `workflows` permission), so repin leaves those files - # alone; a workflow reads its image pins from a file outside them (the - # Dockerfile, docs-build/Dockerfile, hack/ci-images.env), which repin moves. + # autobump/image-digests. A PR opened with GITHUB_TOKEN gets pull_request CI + # runs that wait for a maintainer's approval, so the tests run here before it + # opens, and hack/bot-ci-dispatch then runs ci.yml on the branch by + # workflow_dispatch: that run is the PR's CI evidence. GITHUB_TOKEN cannot push + # a change to .github/workflows/ (no `workflows` permission), so repin leaves + # those files alone; a workflow reads its image pins from a file outside them + # (the Dockerfile, docs-build/Dockerfile, hack/ci-images.env), which repin moves. repin-images: if: ${{ !inputs.release }} runs-on: ubuntu-latest permissions: contents: write pull-requests: write + actions: write steps: - name: Checkout main uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -594,12 +615,7 @@ jobs: # its open PR; the action then cuts a fresh branch off main and opens a new one. - name: Delete a digest branch whose workflows differ from main's if: steps.repin.outputs.changes != '' - run: | - if git fetch -q --depth=1 origin autobump/image-digests 2>/dev/null \ - && ! git diff --quiet FETCH_HEAD HEAD -- .github/workflows/; then - echo "autobump/image-digests was cut from a main with other workflow files; deleting it" - git push origin --delete autobump/image-digests - fi + run: hack/autobump-branch-reset autobump/image-digests - name: Open or update the digest PR if: steps.repin.outputs.changes != '' @@ -622,21 +638,32 @@ jobs: - `tests/image_pins_test.sh`, `tests/audit_test.sh`, `tests/detect_test.sh`, `tests/render_test.sh` and the render check passed in the workflow run that opened this PR. + - CI: this PR's own pull_request runs wait for approval (a GITHUB_TOKEN PR). + The evidence is the ci.yml run dispatched on `autobump/image-digests` at the + head SHA: `gh run list -w ci.yml -e workflow_dispatch -b autobump/image-digests`. Review what the upstream rebuild changed before merging. A change under `share/`, `scripts/` or the Dockerfile cuts a launcher release on merge. + - name: Dispatch CI on the digest PR's branch + if: steps.repin.outputs.changes != '' + env: + GH_TOKEN: ${{ github.token }} + run: hack/bot-ci-dispatch --branch autobump/image-digests + # ─── QAT conversion toolchain (console/convert-pins.json) ────────────────── # The model optimizer's one-time OpenVINO conversion pins its pip toolchain # exactly. Daily: move each pin to the newest release optimum-intel's own # requirements allow (hack/bump-convert-pins), run the console tests, and open - # or update one PR on branch autobump/convert-pins. + # or update one PR on branch autobump/convert-pins, whose CI evidence is the + # ci.yml run hack/bot-ci-dispatch runs on the branch. bump-convert-pins: if: ${{ !inputs.release }} runs-on: ubuntu-latest permissions: contents: write pull-requests: write + actions: write steps: - name: Checkout main uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -663,6 +690,12 @@ jobs: tests/console_test.sh tests/model_opt_test.sh + # As in repin-images: GitHub refuses create-pull-request's force-push from + # GITHUB_TOKEN once main's workflow files differ from the branch's. + - name: Delete a pins branch whose workflows differ from main's + if: steps.bump.outputs.changes != '' + run: hack/autobump-branch-reset autobump/convert-pins + - name: Open or update the pins PR if: steps.bump.outputs.changes != '' uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 @@ -682,3 +715,13 @@ jobs: `tests/console_test.sh` and `tests/model_opt_test.sh` passed in the workflow run that opened this PR. + + CI: this PR's own pull_request runs wait for approval (a GITHUB_TOKEN PR). + The evidence is the ci.yml run dispatched on `autobump/convert-pins` at the + head SHA: `gh run list -w ci.yml -e workflow_dispatch -b autobump/convert-pins`. + + - name: Dispatch CI on the pins PR's branch + if: steps.bump.outputs.changes != '' + env: + GH_TOKEN: ${{ github.token }} + run: hack/bot-ci-dispatch --branch autobump/convert-pins diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 81cd1259..a93c7b54 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,11 +5,14 @@ on: branches: [main] pull_request: branches: [main] + # hack/bot-ci-dispatch runs CI on a bot PR's branch this way: the PR's own + # pull_request runs wait for approval (GITHUB_TOKEN opened it), a dispatch does not. + workflow_dispatch: permissions: contents: read -# A new push to a PR supersedes its older run; main runs always finish. +# A new push to a PR supersedes its older run; main and dispatched runs always finish. concurrency: group: ci-${{ github.event.pull_request.number || github.sha }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} @@ -55,6 +58,7 @@ jobs: - run: tests/prefill_guard_test.sh - run: tests/ci_wiring_test.sh - run: tests/gate_merge_test.sh + - run: tests/bot_ci_dispatch_test.sh - run: tests/auto_release_cas_test.sh quickstart-tests: @@ -484,3 +488,55 @@ jobs: - run: python3 tests/known_issues_test.py - run: python3 tests/ledger_freshness_test.py - run: hack/known-issues check + + # ─── Bot PR CI and alarm surfacing ─────────────────────────────────────── + # The watchdog files a ci-alarm issue for a red or missing scheduled run or a + # broken release (email went unread for 9 nights). Every open alarm, and a + # Watchdog workflow that is missing, disabled or without a success in 3 h, + # shows as a warning on every CI run. It never fails CI: the script exits 0, + # and the job is continue-on-error. + alarms: + name: Open CI alarms + runs-on: ubuntu-latest + timeout-minutes: 5 + continue-on-error: true + permissions: + issues: read + actions: read + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + steps: + - name: Warn on open ci-alarm issues and a quiet Watchdog + run: | + set +e + err=$(mktemp) + # Workflow-command data escapes %, CR and LF. + oneline() { tr '\r\n' ' ' < "$err" | sed 's/ *$//; s/%/%25/g'; } + if alarms=$(gh issue list --label ci-alarm --state open --limit 100 --json number,title,url,labels 2>"$err"); then + jq -r '.[] + | "open ci-alarm #\(.number): \(.title) \(.url) (\(if any(.labels[]; .name == "alarm-ack") then "acked" + else "NOT acked; after reading it: gh issue edit \(.number) --add-label alarm-ack" end))" + | "::warning::" + (gsub("%"; "%25") | gsub("\r"; "%0D") | gsub("\n"; "%0A"))' <<<"$alarms" + else + echo "::warning::could not list the open ci-alarm issues: $(oneline)" + fi + if ! wd=$(gh workflow list --all --json name,path,state \ + -q '[.[] | select(.name == "Watchdog")][0] // empty | "\(.state) \(.path)"' 2>"$err"); then + echo "::warning::could not list the workflows to check the Watchdog: $(oneline)" + elif [ -z "$wd" ]; then + echo "::warning::there is no Watchdog workflow, so a red or missing scheduled run raises no ci-alarm" + else + read -r state path <<<"$wd" + if [ "$state" != active ]; then + echo "::warning::the Watchdog workflow is $state, so a red or missing scheduled run raises no ci-alarm" + else + last=$(gh run list -w "${path##*/}" -s success -L 1 --json updatedAt -q '.[0].updatedAt // empty' 2>"$err") + ts=$(date -u -d "${last:-@0}" +%s 2>/dev/null) || ts=0 + if [ $(( $(date -u +%s) - ts )) -gt 10800 ]; then + echo "::warning::the Watchdog workflow has no successful run in the last 3 h (last: ${last:-none}); alarms may be missing" + fi + fi + fi + rm -f "$err" + exit 0 diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml deleted file mode 100644 index cd7a4ecf..00000000 --- a/.github/workflows/dependabot-auto-merge.yml +++ /dev/null @@ -1,18 +0,0 @@ -name: Auto-merge Dependabot PRs - -on: pull_request - -permissions: - contents: write - pull-requests: write - -jobs: - automerge: - if: github.actor == 'dependabot[bot]' - runs-on: ubuntu-latest - steps: - - name: Enable auto-merge (squash) - run: gh pr merge --auto --squash "$PR_URL" - env: - PR_URL: ${{ github.event.pull_request.html_url }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index 8e7aad3f..7a5da5cb 100644 --- a/README.md +++ b/README.md @@ -1715,7 +1715,9 @@ tests/render_test.sh CI fails when a committed manifest differs from its pinned render. The daily build-and-push workflow runs `--latest` for every component and opens one PR per component (branch `autobump/`); merging it changes `share/`, so auto-release cuts a -launcher release and the node image is rebuilt for it. +launcher release and the node image is rebuilt for it. GITHUB_TOKEN opens these PRs, so +their own pull_request CI runs wait for approval; `hack/bot-ci-dispatch` runs `ci.yml` on +the branch by workflow_dispatch instead, and that run is the PR's CI. --- diff --git a/hack/autobump-branch-reset b/hack/autobump-branch-reset new file mode 100755 index 00000000..795943eb --- /dev/null +++ b/hack/autobump-branch-reset @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# hack/autobump-branch-reset : delete origin's autobump/ branch when its +# .github/workflows/ differ from origin/main's. Run from a checkout whose origin +# push credentials are the workflow's GITHUB_TOKEN, before create-pull-request. +# +# create-pull-request resets an existing bump branch (a PR still open, or a merged +# one whose branch stayed) onto main and force-pushes it. GitHub refuses that push +# from GITHUB_TOKEN (it has no `workflows` permission) when the branch's workflow +# files differ from main's, though the bump commit touches none. Deleting the +# branch closes its open PR; create-pull-request then cuts a fresh branch off main +# and opens a new PR, whose dispatched CI runs main's current workflows. +set -euo pipefail +b=${1:-} +case $b in + autobump/?*) ;; + *) echo "usage: hack/autobump-branch-reset autobump/ (refusing '${b}')" >&2; exit 2;; +esac +git ls-remote --exit-code --heads origin "refs/heads/$b" >/dev/null \ + || { rc=$?; [ "$rc" -eq 2 ] && { echo "$b: not on origin; nothing to reset"; exit 0; }; exit "$rc"; } +git fetch -q --depth=1 origin "+refs/heads/main:refs/remotes/origin/main" "+refs/heads/$b:refs/remotes/origin/$b" +if git diff --quiet "origin/main" "origin/$b" -- .github/workflows; then + echo "$b: its workflows match main's; kept" +else + echo "$b: its workflows differ from main's; deleting it (its open PR closes, and a fresh one opens)" + git push origin --delete "$b" +fi diff --git a/hack/bot-ci-dispatch b/hack/bot-ci-dispatch new file mode 100755 index 00000000..f756a589 --- /dev/null +++ b/hack/bot-ci-dispatch @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +# hack/bot-ci-dispatch [--branch B] [--workflow ci.yml]: give the bot's PRs real CI. +# +# A PR that GITHUB_TOKEN opened or pushed gets pull_request runs that wait for a +# maintainer's approval (conclusion action_required); a workflow_dispatch run +# needs none. For each open PR into main from a same-repo autobump/* branch opened +# by github-actions[bot] (never a fork, never a human's branch), oldest first: +# - no run of the workflow at the head SHA except held ones: dispatch the +# workflow on the branch (gh workflow run --ref); +# - the latest run at the head failed or timed out on its first attempt, and no +# run there was re-run yet: re-run its failed jobs, once per SHA; +# - otherwise: leave it. +# A run at the head is a pull_request run, or a workflow_dispatch run on the branch. +# +# Runner pool: one ci.yml run is ~39 jobs and a public repo gets 20-40 concurrent +# jobs, so it dispatches or re-runs only while fewer than BOT_CI_MAX_INFLIGHT +# (default 1) runs of the workflow on autobump/* branches are queued or running, +# and not at all while a ci.yml run of a human PR is queued. A deferred PR is +# picked up by the next call (.github/workflows/bot-sweep.yml, hourly). +# +# A branch whose workflow file lacks the workflow_dispatch trigger (cut before +# ci.yml had it) is skipped; the nightly's hack/autobump-branch-reset recreates it. +# Prints one line per decision. Idempotent. Exit 0, or 1 when a gh call failed +# (the rest still ran), 2 on bad usage. +set -uo pipefail + +usage() { echo "usage: hack/bot-ci-dispatch [--branch autobump/] [--workflow ci.yml]" >&2; exit 2; } +wf=ci.yml only= +while [ $# -gt 0 ]; do + case $1 in + --branch) [ $# -ge 2 ] || usage; only=$2; shift 2;; + --workflow) [ $# -ge 2 ] || usage; wf=$2; shift 2;; + -h|--help) sed -n '2,/^set /{/^set /d;s/^# \{0,1\}//;p}' "$0"; exit 0;; + *) usage;; + esac +done +cap=${BOT_CI_MAX_INFLIGHT:-1} +[[ $cap =~ ^[0-9]+$ ]] || { echo "BOT_CI_MAX_INFLIGHT must be a whole number, got '$cap'" >&2; exit 2; } +RUNF=databaseId,event,status,conclusion,headBranch,attempt,createdAt +rc=0 + +prs=$(gh pr list --state open --base main --limit 200 \ + --json number,headRefName,headRefOid,baseRefName,isCrossRepository,author) \ + || { echo "error: cannot list open PRs"; exit 1; } +cands=$(jq -r --arg only "$only" ' + .[] | select(.isCrossRepository == false and .baseRefName == "main" + and (.headRefName | startswith("autobump/")) + and (.author.login | IN("app/github-actions", "github-actions[bot]", "github-actions")) + and (.author.is_bot != false) + and ($only == "" or .headRefName == $only)) + | "\(.number) \(.headRefName) \(.headRefOid)"' <<<"$prs" | sort -n) +[ -n "$cands" ] || { echo "no open github-actions[bot] PR from ${only:-an autobump/ branch}"; exit 0; } + +# The pool: bot runs of the workflow in flight, and queued ci.yml runs of human PRs. +recent=$(gh run list -w "$wf" -L 100 --json status,event,headBranch) || { echo "error: cannot list $wf runs"; exit 1; } +inflight=$(jq '[.[] | select(.status != "completed" and ((.headBranch // "") | startswith("autobump/")))] | length' <<<"$recent") +if [ "$wf" != ci.yml ]; then + recent=$(gh run list -w ci.yml -L 100 --json status,event,headBranch) || { echo "error: cannot list ci.yml runs"; exit 1; } +fi +human=$(jq '[.[] | select(.event == "pull_request" and (.status | IN("queued", "requested", "pending", "waiting")) + and ((.headBranch // "") | startswith("autobump/") | not))] | length' <<<"$recent") +[[ $inflight =~ ^[0-9]+$ && $human =~ ^[0-9]+$ ]] || { echo "error: cannot read the run pool from gh run list"; exit 1; } +# busy LINE: print LINE with the reason and succeed when the pool has no room. +busy() { + if [ "$human" -gt 0 ]; then echo "$1: deferred, $human ci.yml run(s) of human PRs queued"; return 0; fi + if [ "$inflight" -ge "$cap" ]; then echo "$1: deferred, $inflight bot $wf run(s) in flight (cap $cap)"; return 0; fi + return 1 +} + +while read -r num br sha; do + tag="#$num $br ${sha:0:7}" + runs=$(gh run list -w "$wf" -c "$sha" -L 100 --json "$RUNF") \ + || { echo "$tag: error: cannot list its $wf runs"; rc=1; continue; } + # " " + read -r count held id st co att reran <<<"$(jq -r --arg b "$br" ' + def word: if . == null or . == "" then "-" else tostring end; + [.[] | select(.event == "pull_request" or (.event == "workflow_dispatch" and .headBranch == $b))] as $at + | ($at | map(select(.conclusion != "action_required"))) as $e + | ($e | max_by(.createdAt // "")) as $l + | "\($e | length) \(($at | length) - ($e | length)) \($l.databaseId | word) \($l.status | word) \($l.conclusion | word) \($l.attempt // 1) \(any($e[]; (.attempt // 1) > 1))"' <<<"$runs")" + if [ "$count" -eq 0 ]; then + why="no $wf run at the head"; [ "$held" -gt 0 ] && why="only held $wf runs at the head" + busy "$tag: $why" && continue + if out=$(gh workflow run "$wf" --ref "$br" 2>&1); then + inflight=$((inflight + 1)) + # Wait until the run shows, so that a call right after this one sees it. + new= + for _ in 1 2 3 4 5 6 7 8 9 10; do + new=$(gh run list -w "$wf" -b "$br" -e workflow_dispatch -c "$sha" -L 1 --json databaseId -q '.[0].databaseId // empty' 2>/dev/null) + [ -n "$new" ] && break; sleep 3 + done + echo "$tag: $why; dispatched $wf${new:+ (run $new)}" + elif grep -qi 'workflow_dispatch' <<<"$out"; then + echo "$tag: skipped, $wf on $br has no workflow_dispatch trigger (the nightly recreates the branch from main)" + else + echo "$tag: error: dispatching $wf failed: $(tr '\n' ' ' <<<"$out")"; rc=1 + fi + elif [ "$st" = completed ] && [[ $co =~ ^(failure|timed_out)$ ]] && [ "$att" = 1 ] && [ "$reran" = false ]; then + busy "$tag: $wf run $id $co on attempt 1" && continue + if out=$(gh run rerun "$id" --failed 2>&1); then + inflight=$((inflight + 1)); echo "$tag: $wf run $id $co on attempt 1; re-ran its failed jobs" + else + echo "$tag: error: re-running run $id failed: $(tr '\n' ' ' <<<"$out")"; rc=1 + fi + elif [ "$st" = completed ] && [ "$co" != success ]; then + echo "$tag: $wf run $id is $co (attempt $att); left for review" + else + echo "$tag: $wf run $id at the head is $st/$co; nothing to do" + fi +done <<<"$cands" +exit "$rc" diff --git a/hack/gate_lib.sh b/hack/gate_lib.sh new file mode 100644 index 00000000..6bdb82e1 --- /dev/null +++ b/hack/gate_lib.sh @@ -0,0 +1,186 @@ +# shellcheck shell=bash +# gate_lib.sh: what the merge gate (hack/gate_merge.sh) reads to decide a PR is +# green and current, for sourcing. Run from inside the repository; gh and git do +# the reading. +# +# READ jq program over `gh pr view --json headRefOid,statusCheckRollup` +# (takes --argjson held_ok BOOL): " +# ", the latest entry per check. +# RUNS jq program over `gh run list -c SHA -e pull_request --json +# workflowName,status,conclusion,createdAt` (takes --argjson +# held_ok BOOL): " ", the latest +# run per workflow. +# ci_status SHA BR green | pending | failed | none: ci.yml at SHA, through a +# pull_request run or a workflow_dispatch run on branch BR. +# ci_eval SHA BR " ", held_ok as below. +# base_ok SHA main is an ancestor of SHA, or main moved only in the ledger or +# by release-only bot commits, or SHA changes only the ledger. +# rules_stale SHA the tree main + SHA would merge to carries a +# console/ledger-rules.json its EXPERIMENTS.md does not generate. +# open_alarms the open ci-alarm issues as a JSON array (gh issue list). +# gated_paths reads paths on stdin, prints those a merge must not ship while +# an alarm is open and not acked (see below). +# +# held_ok: a PR that GITHUB_TOKEN opened or pushed gets pull_request runs held for +# approval (conclusion action_required, no jobs). hack/bot-ci-dispatch runs ci.yml +# on the PR branch by workflow_dispatch instead. Once the latest such dispatched +# run at the SHA succeeded, a held entry of workflow 'CI' or 'Auto-merge Dependabot +# PRs' no longer counts as terminal (READ) or failed (RUNS). Without it, a held +# entry stays terminal, as for a fork PR. + +GATE_LIB_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +# The workflows whose held pull_request runs a green dispatched ci.yml run stands in for. +GATE_HELD_WORKFLOWS='["CI", "Auto-merge Dependabot PRs"]' + +# One snapshot: " ", latest run per check. +# An in-progress CheckRun reports conclusion "", a legacy StatusContext only .state. +# READ and RUNS are jq programs ($names are jq's) for the gate that sources this file. +# shellcheck disable=SC2016,SC2034 +READ='def green: IN("SUCCESS", "SKIPPED", "NEUTRAL"); + def terminal: IN("FAILURE", "ERROR", "CANCELLED", "TIMED_OUT", "STARTUP_FAILURE", "ACTION_REQUIRED", "STALE"); + ('"$GATE_HELD_WORKFLOWS"') as $held + | .headRefOid as $sha + | [.statusCheckRollup[] + | {k: [.__typename, (.name // .context), (.workflowName // "")], t: (.startedAt // ""), n: (.name // .context), + w: (.workflowName // ""), + v: ((if (.conclusion // "") != "" then .conclusion else (.state // "PENDING") end) | ascii_upcase)} + | select(($held_ok and .v == "ACTION_REQUIRED" and (.w | IN($held[]))) | not)] + | [group_by(.k)[] | if any(.v | (green or terminal) | not) then .[0] + {v: "PENDING"} else max_by(.t) end] as $c + | ($c | map(select(.v | green)) | length) as $g + | ($c | map(select(.v | terminal))) as $b + | "\($sha) \($g) \($b | length) \($c | length) \($b | map("\(.n)=\(.v)") | join(","))"' +# Every pull_request workflow run for a SHA, latest per workflow: " ", +# state = green | pending | failed. +# shellcheck disable=SC2016,SC2034 +RUNS='('"$GATE_HELD_WORKFLOWS"') as $held + | [.[] | select(($held_ok and .conclusion == "action_required" and (.workflowName | IN($held[]))) | not)] + | [group_by(.workflowName)[] | max_by(.createdAt // "")] as $r + | ($r | map(select(.status != "completed"))) as $p + | ($r | map(select(.status == "completed" and (.conclusion | IN("success", "skipped", "neutral") | not)))) as $f + | if ($f | length) > 0 then "failed \($f | map("\(.workflowName)=\(.conclusion)") | join(","))" + elif ($p | length) > 0 then "pending -" else "green -" end' +# ci.yml runs at a SHA: $p its pull_request runs, $d its workflow_dispatch runs, $b the +# PR branch. Held runs (action_required) are not attempts: the latest completed other +# run decides, a queued or running one means pending, and held runs alone are failed. +# shellcheck disable=SC2016 +CI_EVAL='def ok: .conclusion == "success"; + def word: if . == null or . == "" then "-" else . end; + ($p | map(. + {via: "pull_request"})) + ($d | map(select(.headBranch == $b) | . + {via: "workflow_dispatch"})) as $all + | ($all | map(select(.conclusion != "action_required"))) as $e + | ($e | map(select(.status == "completed")) | max_by(.createdAt // "")) as $last + | ($e | map(select(.via == "workflow_dispatch" and .status == "completed")) | max_by(.createdAt // "")) as $dl + | (if $last != null and ($last | ok) then "green" + elif any($e[]; .status != "completed") then "pending" + elif $last != null or ($all | length) > 0 then "failed" + else "none" end) as $s + | ($s == "green" and $dl != null and ($dl | ok)) as $held_ok + | ($last // ($all | max_by(.createdAt // "")) // {}) as $show + | "\($s) \($held_ok) \($show.via | word):\($show.status | word)/\($show.conclusion | word)"' +ci_eval() { + local p d + p=$(gh run list -w ci.yml -c "$1" -e pull_request -L 100 --json event,headBranch,status,conclusion,createdAt 2>/dev/null) || p= + d=$(gh run list -w ci.yml -c "$1" -e workflow_dispatch -b "$2" -L 100 --json event,headBranch,status,conclusion,createdAt 2>/dev/null) || d= + jq -rn --arg b "$2" --argjson p "${p:-[]}" --argjson d "${d:-[]}" "$CI_EVAL" +} +ci_status() { local s; read -r s _ <<<"$(ci_eval "$1" "$2")"; echo "${s:-none}"; } + +# base_ok SHA: main is an ancestor of SHA, or main moved only in the ledger (or by +# release-only bot commits), or the PR is ledger-only. +code() { git diff --name-only "$1" "$2" | grep -qvxE 'EXPERIMENTS\.md|docs/provenance\.md'; } +# release_only C: C is one of the release commits auto-release.yml and its followers +# push to main: a 'release: ... [skip release]' subject by 88plug-bot or +# github-actions[bot], touching only the release files, and in scripts/k3d-gpu only +# its VERSION="..." line. +release_only() { + local c=$1 f + case $(git log -1 --format=%s "$c") in 'release:'*'[skip release]'*) ;; *) return 1;; esac + case $(git log -1 --format=%an "$c") in 88plug-bot|'github-actions[bot]') ;; *) return 1;; esac + while IFS= read -r f; do + case $f in + packaging/aur/PKGBUILD|packaging/aur/.SRCINFO|Formula/k3d-gpu.rb|packaging/install.sh|console/ledger-rules.json) ;; + scripts/k3d-gpu) + git diff -U0 "$c^" "$c" -- scripts/k3d-gpu \ + | awk '/^@@/ {h = 1; next} h && /^[-+]/ && !/^[-+]VERSION="/ {bad = 1} END {exit bad ? 0 : 1}' && return 1;; + *) return 1;; + esac + done < <(git diff --name-only "$c^" "$c") + return 0 +} +# main_code MB: since MB, main changed a file outside the ledger in a commit that is +# not release-only. When no commit can be listed, that counts as a change. +main_code() { + code "$1" origin/main || return 1 + local c n=0 + while read -r c; do + n=$((n + 1)) + release_only "$c" && continue + code "$c^" "$c" && return 0 + done < <(git rev-list "$1..origin/main") + [ "$n" -eq 0 ] +} +base_ok() { + git fetch -q origin || return 1 + git merge-base --is-ancestor origin/main "$1" && return 0 + local mb; mb=$(git merge-base origin/main "$1") || return 1 + ! { main_code "$mb" && code "$mb" "$1"; } +} + +# rules_stale SHA: the tree main + SHA would merge to carries a console/ledger-rules.json +# that EXPERIMENTS.md there does not generate (read with that tree's own model_opt.py). +# Unknown (a conflict, no such files) is not stale: a conflict syncs anyway. +rules_stale() { + local tree d rc=1 + tree=$(git merge-tree --write-tree origin/main "$1" 2>/dev/null | head -1) + [ -n "$tree" ] || return 1 + d=$(mktemp -d) + if git show "$tree:console/model_opt.py" > "$d/model_opt.py" 2>/dev/null \ + && git show "$tree:console/convert-pins.json" > "$d/convert-pins.json" 2>/dev/null \ + && git show "$tree:console/ledger-rules.json" > "$d/have.json" 2>/dev/null \ + && git show "$tree:EXPERIMENTS.md" > "$d/EXPERIMENTS.md" 2>/dev/null \ + && [ -s "$d/model_opt.py" ] && [ -s "$d/have.json" ] \ + && python3 "$d/model_opt.py" ledger-rules --ledger "$d/EXPERIMENTS.md" --out "$d/want.json" >/dev/null 2>&1 \ + && ! cmp -s "$d/have.json" "$d/want.json"; then + rc=0 + fi + rm -rf "$d" + return "$rc" +} + +# open_alarms: the open ci-alarm issues, [{number, title, url, labels}]; fails when gh does. +open_alarms() { + local a + a=$(gh issue list --label ci-alarm --state open --limit 100 --json number,title,url,labels) || return 1 + jq -c 'if type == "array" then . else error("not an array") end' <<<"${a:-[]}" +} +# gated_paths: of the paths on stdin, print those a merge must not ship while an +# alarm is open and not acked: .github/** and the shipped set. A PR that changes +# only EXPERIMENTS.md, docs/, bench/ or tests/ (the ledger work) prints nothing. +# Shipped set: packaging/, scripts/, share/, Dockerfile, console/, images.lock, plus +# every pattern in hack/release-paths (one per line, # comments, dir/ or dir/** for +# a tree, else a shell glob; an executable file is run for its lines). It is read +# from $GATE_RELEASE_PATHS, else next to this file, else the repository's hack/. +gated_paths() { + local paths p f pats=(packaging/ scripts/ share/ Dockerfile console/ images.lock) rp + paths=$(grep -v '^$') + [ -n "$paths" ] || return 0 + grep -qvE '^(EXPERIMENTS\.md|(docs|bench|tests)/.*)$' <<<"$paths" || return 0 + rp=${GATE_RELEASE_PATHS:-} + [ -n "$rp" ] || for f in "$GATE_LIB_DIR/release-paths" hack/release-paths; do [ -f "$f" ] && { rp=$f; break; }; done + if [ -n "$rp" ] && [ -f "$rp" ]; then + while IFS= read -r p; do + p=${p%%#*}; p=${p#"${p%%[![:space:]]*}"}; p=${p%"${p##*[![:space:]]}"}; p=${p#- }; p=${p//[\"\']/} + [ -n "$p" ] && pats+=("$p") + done < <(if [ -x "$rp" ]; then "$rp"; else cat "$rp"; fi) + fi + while IFS= read -r f; do + case $f in .github/*) echo "$f"; continue;; esac + for p in "${pats[@]}"; do + case $p in */\*\*) p=${p%\*\*};; esac + # shellcheck disable=SC2254 # p is a glob on purpose + case $p in + */) case $f in "$p"*) echo "$f"; break;; esac;; + *) case $f in $p|"$p"/*) echo "$f"; break;; esac;; + esac + done + done <<<"$paths" +} diff --git a/hack/gate_merge.sh b/hack/gate_merge.sh index 94f131bd..6bfcbbaa 100755 --- a/hack/gate_merge.sh +++ b/hack/gate_merge.sh @@ -2,14 +2,28 @@ # gate_merge.sh : keep the PR branch current with main # (ledger/provenance conflicts resolved keep-both; anything else -> stop), # then squash-merge the head SHA whose checks were read. Up to 5 sync rounds. +# The checks it reads live in hack/gate_lib.sh, which it sources from its own +# directory, else from the repository's hack/. # # Green means: every latest rollup entry is SUCCESS, SKIPPED or NEUTRAL (a -# check with any queued or running attempt is pending); the ci.yml -# pull_request run for that exact SHA completed with success; and the latest -# run of every other pull_request workflow for that SHA completed with -# success, skipped or neutral. A terminal non-green entry (FAILURE, ERROR, -# CANCELLED, TIMED_OUT, STARTUP_FAILURE, ACTION_REQUIRED, STALE) or a failed -# workflow run exits 1. +# check with any queued or running attempt is pending); ci.yml for that exact +# SHA succeeded, through a pull_request run or a workflow_dispatch run on the PR +# branch (the latest completed one decides); and the latest run of every other +# pull_request workflow for that SHA completed with success, skipped or neutral. +# A terminal non-green entry (FAILURE, ERROR, CANCELLED, TIMED_OUT, +# STARTUP_FAILURE, ACTION_REQUIRED, STALE), a failed workflow run or a failed +# ci.yml exits 1. +# +# Bot PRs: a PR that GITHUB_TOKEN opened or pushed gets pull_request runs held +# for approval (ACTION_REQUIRED). hack/bot-ci-dispatch runs ci.yml on its branch +# by workflow_dispatch instead; once that run is green, the held entries of 'CI' +# and 'Auto-merge Dependabot PRs' no longer block. Held runs alone still exit 1. +# +# Alarms: the gate first prints every open ci-alarm issue. While any of them +# lacks the label alarm-ack, it refuses (exit 4) a PR that changes .github/ or a +# shipped path (see gated_paths in gate_lib.sh), at the start and again right +# before merging; a PR that changes only EXPERIMENTS.md, docs/, bench/ or tests/ +# is never refused. Read the alarm, then ack it: gh issue edit N --add-label alarm-ack. # # Derived file: console/ledger-rules.json is EXPERIMENTS.md's rule rows as packages # ship them. A ledger-only PR (or base) merges without a CI re-run, so the gate @@ -19,9 +33,10 @@ # Moved base: if main is no longer an ancestor of the head, and both main # (since the merge base) and the PR change files other than EXPERIMENTS.md and # docs/provenance.md, the gate syncs the branch so CI re-runs on the merged -# head. A base that moved only in the ledger, or under a ledger-only PR, is -# merged without a re-run. Another PR can still land between this check and -# the merge; that window stays open. +# head. A base that moved only in the ledger or by release-only bot commits +# ('release: ... [skip release]' on the release files), or under a ledger-only +# PR, is merged without a re-run. Another PR can still land between this check +# and the merge; that window stays open. # # The merge carries --match-head-commit, so GitHub merges only if the head is # still that SHA. On a refusal the gate prints GitHub's reason and re-reads the @@ -29,8 +44,11 @@ # sleeps, and the next round re-checks mergeable (sync on a conflict) and the # base before merging again. # +# Exit: 0 merged, 1 failed or gave up, 2 gh lacks --match-head-commit, 4 refused +# for an open alarm. # Env: GATE_REPO (default: the caller's git toplevel), GATE_WT_ROOT (sync # worktree parent, default $TMPDIR), GATE_POLLS (polls per round, default 60), +# GATE_RELEASE_PATHS (shipped-path patterns, default hack/release-paths), # GATE_SELF_DIR (a directory removed on exit; set by the scratchpad shim). set -u; pr=$1; subj=$2; body=$3 HERE=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) @@ -42,9 +60,38 @@ cleanup() { if [ -n "${GATE_SELF_DIR:-}" ]; then rm -rf "$GATE_SELF_DIR"; fi } trap cleanup EXIT +for lib in "$HERE/gate_lib.sh" hack/gate_lib.sh ""; do [ -f "$lib" ] && break; done +[ -n "$lib" ] || { echo "gate_lib.sh not found next to $0 or in $PWD/hack; copy it beside the gate"; exit 1; } +# shellcheck source=hack/gate_lib.sh +. "$lib" gh pr merge --help 2>/dev/null | grep -q -- --match-head-commit \ || { echo "gh pr merge lacks --match-head-commit; refusing to merge unpinned"; exit 2; } br=$(gh pr view "$pr" --json headRefName -q .headRefName) || exit 1 +# alarm_check [list]: with `list`, print every open ci-alarm issue. Exit 4 when one +# is not acked and the PR changes a gated path (or its paths cannot be read). +alarm_check() { + local a un paths gated e + e=$(mktemp) + if ! a=$(open_alarms 2>"$e"); then + echo "WARNING: could not list the open ci-alarm issues ($(tr '\n' ' ' < "$e" | sed 's/ *$//')); the alarm check was skipped" + rm -f "$e"; return 0 + fi + rm -f "$e" + [ "${1:-}" = list ] && jq -r '.[] | "open ci-alarm #\(.number): \(.title) \(.url)\(if any(.labels[]; .name == "alarm-ack") then " (acked)" else " (NOT acked)" end)"' <<<"$a" + un=$(jq -r '.[] | select(any(.labels[]; .name == "alarm-ack") | not) | " #\(.number) \(.title) \(.url)"' <<<"$a") + [ -n "$un" ] || return 0 + if paths=$(gh api --paginate "repos/{owner}/{repo}/pulls/$pr/files" -q '.[].filename' 2>/dev/null); then + gated=$(gated_paths <<<"$paths") + [ -n "$gated" ] || return 0 + echo "PR$pr REFUSED: it changes $(head -5 <<<"$gated" | paste -sd, -)$([ "$(wc -l <<<"$gated")" -gt 5 ] && echo ', ...') while these ci-alarm issues are open and not acked:" + else + echo "PR$pr REFUSED: its changed files could not be listed, and these ci-alarm issues are open and not acked:" + fi + echo "$un" + jq -r '.[] | select(any(.labels[]; .name == "alarm-ack") | not) | "Read #\(.number), then ack it: gh issue edit \(.number) --add-label alarm-ack"' <<<"$a" + exit 4 +} +alarm_check list sync() { git fetch -q origin git worktree remove --force "$W" 2>/dev/null; git worktree add -q -B "gm/$pr" "$W" "origin/$br" || return 1 @@ -70,69 +117,22 @@ sync() { fi ) || return 1 git -C "$W" push -q origin "HEAD:refs/heads/$br" && echo "synced $br" } -# One snapshot: " ", latest run per check. -# An in-progress CheckRun reports conclusion "", a legacy StatusContext only .state. -READ='def green: IN("SUCCESS", "SKIPPED", "NEUTRAL"); - def terminal: IN("FAILURE", "ERROR", "CANCELLED", "TIMED_OUT", "STARTUP_FAILURE", "ACTION_REQUIRED", "STALE"); - .headRefOid as $sha - | [.statusCheckRollup[] - | {k: [.__typename, (.name // .context), (.workflowName // "")], t: (.startedAt // ""), n: (.name // .context), - v: ((if (.conclusion // "") != "" then .conclusion else (.state // "PENDING") end) | ascii_upcase)}] - | [group_by(.k)[] | if any(.v | (green or terminal) | not) then .[0] + {v: "PENDING"} else max_by(.t) end] as $c - | ($c | map(select(.v | green)) | length) as $g - | ($c | map(select(.v | terminal))) as $b - | "\($sha) \($g) \($b | length) \($c | length) \($b | map("\(.n)=\(.v)") | join(","))"' -# Every pull_request workflow run for a SHA, latest per workflow: " ", -# state = green | pending | failed. -RUNS='[group_by(.workflowName)[] | max_by(.createdAt // "")] as $r - | ($r | map(select(.status != "completed"))) as $p - | ($r | map(select(.status == "completed" and (.conclusion | IN("success", "skipped", "neutral") | not)))) as $f - | if ($f | length) > 0 then "failed \($f | map("\(.workflowName)=\(.conclusion)") | join(","))" - elif ($p | length) > 0 then "pending -" else "green -" end' -# base_ok SHA: main is an ancestor of SHA, or main moved only in the ledger, or the PR is ledger-only. -code() { git diff --name-only "$1" "$2" | grep -qvxE 'EXPERIMENTS\.md|docs/provenance\.md'; } -base_ok() { - git fetch -q origin || return 1 - git merge-base --is-ancestor origin/main "$1" && return 0 - local mb; mb=$(git merge-base origin/main "$1") || return 1 - ! { code "$mb" origin/main && code "$mb" "$1"; } -} -# rules_stale SHA: the tree main + SHA would merge to carries a console/ledger-rules.json -# that EXPERIMENTS.md there does not generate (read with that tree's own model_opt.py). -# Unknown (a conflict, no such files) is not stale: a conflict syncs anyway. -rules_stale() { - local tree d rc=1 - tree=$(git merge-tree --write-tree origin/main "$1" 2>/dev/null | head -1) - [ -n "$tree" ] || return 1 - d=$(mktemp -d) - if git show "$tree:console/model_opt.py" > "$d/model_opt.py" 2>/dev/null \ - && git show "$tree:console/convert-pins.json" > "$d/convert-pins.json" 2>/dev/null \ - && git show "$tree:console/ledger-rules.json" > "$d/have.json" 2>/dev/null \ - && git show "$tree:EXPERIMENTS.md" > "$d/EXPERIMENTS.md" 2>/dev/null \ - && [ -s "$d/model_opt.py" ] && [ -s "$d/have.json" ] \ - && python3 "$d/model_opt.py" ledger-rules --ledger "$d/EXPERIMENTS.md" --out "$d/want.json" >/dev/null 2>&1 \ - && ! cmp -s "$d/have.json" "$d/want.json"; then - rc=0 - fi - rm -rf "$d" - return "$rc" -} [ "$(gh pr view "$pr" --json isDraft -q .isDraft)" = true ] && gh pr ready "$pr" >/dev/null merged() { [ "$(gh pr view "$pr" --json state -q .state)" = MERGED ]; } for round in 1 2 3 4 5; do merged && { echo "PR$pr MERGED"; exit 0; } [ "$(gh pr view "$pr" --json mergeable -q .mergeable)" = CONFLICTING ] && { sync || exit 1; sleep 60; } for _ in $(seq 1 "${GATE_POLLS:-60}"); do - s=$(gh pr view "$pr" --json headRefOid,statusCheckRollup -q "$READ" 2>/dev/null) || { sleep 60; continue; } - read -r sha g b n bad <<<"$s" + pj=$(gh pr view "$pr" --json headRefOid,statusCheckRollup 2>/dev/null) && [ -n "$pj" ] || { sleep 60; continue; } + sha=$(jq -r .headRefOid <<<"$pj") + read -r ci held_ok ci_last <<<"$(ci_eval "$sha" "$br")" + read -r sha g b n bad <<<"$(jq -r --argjson held_ok "${held_ok:-false}" "$READ" <<<"$pj")" [ "$b" -gt 0 ] && { echo "PR$pr FAIL at $sha: $bad"; exit 1; } if [ "$n" -gt 0 ] && [ "$g" -eq "$n" ]; then - ci=$(gh run list -w ci.yml -c "$sha" -e pull_request -L 1 --json status,conclusion \ - -q '.[0] | "\(.status)/\(.conclusion)"' 2>/dev/null) case $ci in - completed/success) + green) read -r rs rbad <<<"$(gh run list -c "$sha" -e pull_request -L 100 \ - --json workflowName,status,conclusion,createdAt -q "$RUNS" 2>/dev/null)" + --json workflowName,status,conclusion,createdAt 2>/dev/null | jq -r --argjson held_ok "$held_ok" "$RUNS" 2>/dev/null)" case $rs in failed) echo "PR$pr FAIL: workflow runs for $sha: $rbad"; exit 1;; green) ;; @@ -146,6 +146,7 @@ for round in 1 2 3 4 5; do echo "PR$pr round $round: console/ledger-rules.json would not match EXPERIMENTS.md after the merge: sync regenerates it" sync || exit 1; sleep 60; break fi + alarm_check why=$(gh pr merge "$pr" --squash --match-head-commit "$sha" --subject "$subj" --body "$body" 2>&1 >/dev/null) merged && { git push -q origin --delete "$br" 2>/dev/null; echo "PR$pr MERGED at $sha"; exit 0; } now=$(gh pr view "$pr" --json headRefOid -q .headRefOid 2>/dev/null) @@ -156,7 +157,7 @@ for round in 1 2 3 4 5; do sleep 60 fi break;; - completed/*) echo "PR$pr FAIL: ci.yml run for $sha is $ci"; exit 1;; + failed) echo "PR$pr FAIL: ci.yml for $sha did not succeed (latest $ci_last)"; exit 1;; esac # no ci.yml run for this SHA yet, or still running: wait fi sleep 60 diff --git a/tests/bot_ci_dispatch_test.sh b/tests/bot_ci_dispatch_test.sh new file mode 100755 index 00000000..0c70bb74 --- /dev/null +++ b/tests/bot_ci_dispatch_test.sh @@ -0,0 +1,178 @@ +#!/usr/bin/env bash +# hack/bot-ci-dispatch against a fake gh (no network). +# The fake keeps open PRs in prs.json and workflow runs in runs.json. `gh run +# list` filters runs like the API (-w workflow file, -c head SHA, -b branch, +# -e event, -L count, newest first). `gh workflow run` appends a queued +# workflow_dispatch run at the branch's head, unless the branch is listed in +# no_dispatch (its workflow file lacks the trigger: HTTP 422) or dispatch_fail +# exists (HTTP 500). `gh run rerun ID --failed` queues attempt 2 of that run. +# Every dispatch and rerun is logged to actions. +# +# Usage: tests/bot_ci_dispatch_test.sh + +set -uo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +BCD=${BCD:-${ROOT}/hack/bot-ci-dispatch} +T=$(mktemp -d "${TMPDIR:-/tmp}/bot_ci_dispatch.XXXXXX") +trap 'rm -rf "$T"' EXIT +PASS=0 FAILS=0 +ok() { PASS=$((PASS + 1)); echo "ok $1"; } +fail() { FAILS=$((FAILS + 1)); echo "FAIL $1"; } + +mkdir -p "$T/bin" +cat > "$T/bin/gh" <<'STUB' +#!/usr/bin/env bash +F=$FIX +echo "gh $*" >> "$F/calls" +case "$1 $2" in + "pr list") cat "$F/prs.json"; exit 0;; + "run list") + shift 2; wf= c= b= e= q= L=1000 + while [ $# -gt 0 ]; do case $1 in + -w|--workflow) wf=$2; shift 2;; -c|--commit) c=$2; shift 2;; -b|--branch) b=$2; shift 2;; + -e|--event) e=$2; shift 2;; -q|--jq) q=$2; shift 2;; -L|--limit) L=$2; shift 2;; --json) shift 2;; *) shift;; esac; done + out=$(jq --arg w "$wf" --arg c "$c" --arg b "$b" --arg e "$e" --argjson L "$L" \ + '[.[] | select(($w == "" or .workflow == $w) and ($c == "" or .headSha == $c) + and ($b == "" or .headBranch == $b) and ($e == "" or .event == $e))] + | sort_by(.createdAt) | reverse | .[:$L]' "$F/runs.json") + if [ -n "$q" ]; then jq -r "$q" <<<"$out"; else echo "$out"; fi + exit 0;; + "workflow run") + wf=$3; shift 3; ref= + while [ $# -gt 0 ]; do case $1 in -r|--ref) ref=$2; shift 2;; *) shift;; esac; done + if grep -qxF "$ref" "$F/no_dispatch" 2>/dev/null; then + echo "could not create workflow dispatch event: HTTP 422: Workflow does not have 'workflow_dispatch' trigger (https://api.github.com/repos/o/r/actions/workflows/1/dispatches)" >&2 + exit 1 + fi + [ -f "$F/dispatch_fail" ] && { echo "could not create workflow dispatch event: HTTP 500: Server Error" >&2; exit 1; } + sha=$(jq -r --arg b "$ref" '[.[] | select(.headRefName == $b)][0].headRefOid // "unknown"' "$F/prs.json") + n=$(( $(jq length "$F/runs.json") + 900 )) + jq --arg w "$wf" --arg b "$ref" --arg s "$sha" --argjson id "$n" \ + '. + [{databaseId: $id, workflow: $w, event: "workflow_dispatch", headBranch: $b, headSha: $s, + status: "queued", conclusion: "", attempt: 1, createdAt: "2026-10-29T23:00:00Z"}]' \ + "$F/runs.json" > "$F/runs.tmp" && mv "$F/runs.tmp" "$F/runs.json" + echo "dispatch $wf $ref" >> "$F/actions" + exit 0;; + "run rerun") + id=$3; echo "rerun $id ${*:4}" >> "$F/actions" + jq --argjson id "$id" 'map(if .databaseId == $id then .attempt += 1 | .status = "queued" | .conclusion = "" else . end)' \ + "$F/runs.json" > "$F/runs.tmp" && mv "$F/runs.tmp" "$F/runs.json" + exit 0;; +esac +exit 0 +STUB +printf '#!/bin/sh\nexit 0\n' > "$T/bin/sleep" +chmod +x "$T/bin/"* + +BOT='{"login":"app/github-actions","is_bot":true}' +HUMAN='{"login":"88plug","is_bot":false}' +DEPENDABOT='{"login":"app/dependabot","is_bot":true}' +# pr NUMBER BRANCH SHA [AUTHOR] [CROSS]: one open PR into main. +pr() { + printf '{"number":%s,"headRefName":"%s","headRefOid":"%s","baseRefName":"main","isCrossRepository":%s,"author":%s}' \ + "$1" "$2" "$3" "${5:-false}" "${4:-$BOT}" +} +# run ID WORKFLOW EVENT BRANCH SHA STATUS CONCLUSION [ATTEMPT] [CREATED] +run() { + printf '{"databaseId":%s,"workflow":"%s","event":"%s","headBranch":"%s","headSha":"%s","status":"%s","conclusion":"%s","attempt":%s,"createdAt":"%s"}' \ + "$1" "$2" "$3" "$4" "$5" "$6" "$7" "${8:-1}" "${9:-2026-10-29T20:00:00Z}" +} +# fixture NAME PRS RUNS +fixture() { + FIX=$T/$1; export FIX; mkdir -p "$FIX" + echo "$2" > "$FIX/prs.json"; echo "$3" > "$FIX/runs.json"; : > "$FIX/calls"; : > "$FIX/actions" +} +bcd() { OUT=$(PATH="$T/bin:$PATH" timeout 60 bash "$BCD" "$@" 2>&1); RC=$?; } +acts() { grep -c "^$1 " "$FIX/actions"; } +dispatched() { grep -qxF "dispatch ${2:-ci.yml} $1" "$FIX/actions"; } + +# 1. scope: only same-repo autobump/* PRs by github-actions[bot] with no run at the +# head (a held run does not count) get a dispatch; forks, human branches, a +# human's autobump/ branch and dependabot never do. +fixture scope "[$(pr 1 autobump/a a111),$(pr 2 autobump/fork f222 "$BOT" true),$(pr 3 claude/x c333 "$HUMAN"),$(pr 4 autobump/human h444 "$HUMAN"),$(pr 5 dependabot/pip/x d555 "$DEPENDABOT"),$(pr 6 autobump/done e666),$(pr 7 autobump/held g777)]" \ + "[$(run 1 ci.yml pull_request autobump/done e666 completed success),$(run 2 ci.yml pull_request autobump/held g777 completed action_required)]" +BOT_CI_MAX_INFLIGHT=10 bcd +if [ "$RC" -eq 0 ] && [ "$(acts dispatch)" -eq 2 ] && dispatched autobump/a && dispatched autobump/held; then + ok "scope: dispatched autobump/a and the held autobump/held only" +else fail "scope: rc=$RC actions=$(tr '\n' ';' < "$FIX/actions") out=$(tr '\n' ';' <<<"$OUT")"; fi +if grep -q '#6 autobump/done' <<<"$OUT" && ! grep -qE '#(2|3|4|5) ' <<<"$OUT"; then + ok "scope: a line for the PR with a run at its head, none for the out-of-scope PRs" +else fail "scope: decision lines: $(tr '\n' ';' <<<"$OUT")"; fi + +# 2. the in-flight cap (default 1): two PRs, nothing running -> one dispatch, one deferred. +fixture cap "[$(pr 1 autobump/a a111),$(pr 2 autobump/b b222)]" '[]' +bcd +if [ "$RC" -eq 0 ] && [ "$(acts dispatch)" -eq 1 ] && dispatched autobump/a && grep -q '#2 autobump/b.*deferred' <<<"$OUT"; then + ok "cap: one dispatch, the second deferred" +else fail "cap: rc=$RC actions=$(tr '\n' ';' < "$FIX/actions") out=$(tr '\n' ';' <<<"$OUT")"; fi +# a bot run already in flight on another autobump branch fills the cap. +fixture cap_full "[$(pr 1 autobump/a a111)]" "[$(run 5 ci.yml workflow_dispatch autobump/z z999 in_progress '')]" +bcd +if [ "$RC" -eq 0 ] && [ "$(acts dispatch)" -eq 0 ] && grep -q 'deferred' <<<"$OUT"; then ok "cap_full: deferred" +else fail "cap_full: rc=$RC actions=$(tr '\n' ';' < "$FIX/actions")"; fi + +# 3. a queued ci.yml run of a human PR defers every bot dispatch; a running one does not. +fixture human_queued "[$(pr 1 autobump/a a111)]" "[$(run 5 ci.yml pull_request claude/x c333 queued '')]" +BOT_CI_MAX_INFLIGHT=10 bcd +if [ "$RC" -eq 0 ] && [ "$(acts dispatch)" -eq 0 ] && grep -q 'human' <<<"$OUT"; then ok "human_queued: deferred" +else fail "human_queued: rc=$RC actions=$(tr '\n' ';' < "$FIX/actions") out=$(tr '\n' ';' <<<"$OUT")"; fi +# a queued run whose head branch is gone (headBranch null) counts as a human run. +fixture human_null_branch "[$(pr 1 autobump/a a111)]" \ + "[{\"databaseId\":5,\"workflow\":\"ci.yml\",\"event\":\"pull_request\",\"headBranch\":null,\"headSha\":\"c333\",\"status\":\"queued\",\"conclusion\":\"\",\"attempt\":1,\"createdAt\":\"2026-10-29T20:00:00Z\"}]" +BOT_CI_MAX_INFLIGHT=10 bcd +if [ "$RC" -eq 0 ] && [ "$(acts dispatch)" -eq 0 ] && grep -q 'deferred, 1 ci.yml run(s) of human PRs queued' <<<"$OUT"; then + ok "human_null_branch: deferred" +else fail "human_null_branch: rc=$RC actions=$(tr '\n' ';' < "$FIX/actions") out=$(tr '\n' ';' <<<"$OUT")"; fi +fixture human_running "[$(pr 1 autobump/a a111)]" "[$(run 5 ci.yml pull_request claude/x c333 in_progress '')]" +bcd +if [ "$RC" -eq 0 ] && [ "$(acts dispatch)" -eq 1 ]; then ok "human_running: dispatched" +else fail "human_running: rc=$RC actions=$(tr '\n' ';' < "$FIX/actions")"; fi + +# 4. idempotent: a second call dispatches nothing more. +fixture idempotent "[$(pr 1 autobump/a a111),$(pr 2 autobump/b b222)]" '[]' +BOT_CI_MAX_INFLIGHT=10 bcd; first=$(acts dispatch); BOT_CI_MAX_INFLIGHT=10 bcd +if [ "$first" -eq 2 ] && [ "$(acts dispatch)" -eq 2 ] && [ "$RC" -eq 0 ]; then ok "idempotent: 2 dispatches, none on the second call" +else fail "idempotent: first=$first total=$(acts dispatch) rc=$RC"; fi + +# 5. a run that failed on its first attempt gets its failed jobs re-run once. +fixture rerun "[$(pr 1 autobump/a a111)]" "[$(run 41 ci.yml workflow_dispatch autobump/a a111 completed failure)]" +bcd +if [ "$RC" -eq 0 ] && grep -qxF 'rerun 41 --failed' "$FIX/actions" && [ "$(acts dispatch)" -eq 0 ]; then ok "rerun: failed jobs of run 41 re-run" +else fail "rerun: rc=$RC actions=$(tr '\n' ';' < "$FIX/actions") out=$(tr '\n' ';' <<<"$OUT")"; fi +jq 'map(if .databaseId == 41 then .status = "completed" | .conclusion = "failure" else . end)' "$FIX/runs.json" > "$FIX/r" && mv "$FIX/r" "$FIX/runs.json" +bcd +if [ "$RC" -eq 0 ] && [ "$(acts rerun)" -eq 1 ] && [ "$(acts dispatch)" -eq 0 ]; then ok "rerun: not again after the re-run failed" +else fail "rerun: second call rc=$RC actions=$(tr '\n' ';' < "$FIX/actions") out=$(tr '\n' ';' <<<"$OUT")"; fi +# a cancelled run is someone's decision: no re-run. +fixture rerun_cancelled "[$(pr 1 autobump/a a111)]" "[$(run 41 ci.yml workflow_dispatch autobump/a a111 completed cancelled)]" +bcd +if [ "$RC" -eq 0 ] && [ "$(acts rerun)" -eq 0 ] && [ "$(acts dispatch)" -eq 0 ]; then ok "rerun_cancelled: left alone" +else fail "rerun_cancelled: rc=$RC actions=$(tr '\n' ';' < "$FIX/actions")"; fi + +# 6. a branch whose ci.yml has no workflow_dispatch: skipped with a reason, exit 0. +fixture no_trigger "[$(pr 1 autobump/old o111),$(pr 2 autobump/b b222)]" '[]'; echo autobump/old > "$FIX/no_dispatch" +BOT_CI_MAX_INFLIGHT=10 bcd +if [ "$RC" -eq 0 ] && grep -q '#1 autobump/old.*workflow_dispatch' <<<"$OUT" && [ "$(acts dispatch)" -eq 1 ] && dispatched autobump/b; then + ok "no_trigger: skipped, the next PR still dispatched" +else fail "no_trigger: rc=$RC actions=$(tr '\n' ';' < "$FIX/actions") out=$(tr '\n' ';' <<<"$OUT")"; fi +# any other dispatch failure is an error: exit 1. +fixture dispatch_error "[$(pr 1 autobump/a a111)]" '[]'; touch "$FIX/dispatch_fail" +bcd +if [ "$RC" -eq 1 ] && grep -q 'HTTP 500' <<<"$OUT"; then ok "dispatch_error: exit 1" +else fail "dispatch_error: rc=$RC out=$(tr '\n' ';' <<<"$OUT")"; fi + +# 7. --branch limits it to one PR; --workflow picks the workflow and its own runs. +fixture one_branch "[$(pr 1 autobump/a a111),$(pr 2 autobump/b b222)]" '[]' +BOT_CI_MAX_INFLIGHT=10 bcd --branch autobump/b +if [ "$RC" -eq 0 ] && [ "$(acts dispatch)" -eq 1 ] && dispatched autobump/b; then ok "one_branch: only autobump/b" +else fail "one_branch: rc=$RC actions=$(tr '\n' ';' < "$FIX/actions")"; fi +fixture canary "[$(pr 1 autobump/a a111)]" "[$(run 5 ci.yml workflow_dispatch autobump/a a111 completed success)]" +bcd --workflow canary.yml +if [ "$RC" -eq 0 ] && [ "$(acts dispatch)" -eq 1 ] && dispatched autobump/a canary.yml; then ok "workflow: canary.yml dispatched beside a green ci.yml run" +else fail "workflow: rc=$RC actions=$(tr '\n' ';' < "$FIX/actions")"; fi +bcd --bogus +if [ "$RC" -eq 2 ]; then ok "usage: unknown flag exits 2"; else fail "usage: rc=$RC"; fi + +echo "${PASS} passed, ${FAILS} failed" +[ "${FAILS}" -eq 0 ] diff --git a/tests/ci_wiring_test.sh b/tests/ci_wiring_test.sh index 6426f9b7..d021e1e4 100755 --- a/tests/ci_wiring_test.sh +++ b/tests/ci_wiring_test.sh @@ -77,34 +77,72 @@ if grep -qF 'git status --porcelain -- .github/workflows/' "${BP}"; then else fail "repin-images has no 'git status --porcelain -- .github/workflows/' check" fi -# create-pull-request resets an existing digest branch onto main and force-pushes -# it; GitHub refuses that GITHUB_TOKEN push when the branch's workflow files differ -# from main's, though the digest commit touches none. A step before the action, -# under the same if:, deletes such a branch so the action cuts a fresh one off main. -# Its script then runs against a local origin over file:// (no network). +# Every create-pull-request step (a GITHUB_TOKEN PR) sits between two steps under +# its if:. Before it, hack/autobump-branch-reset deletes the branch when its +# workflow files differ from main's: create-pull-request resets the branch onto +# main and force-pushes it, and GitHub refuses that GITHUB_TOKEN push (no +# `workflows` permission) though the bump commit touches no workflow. After it, +# hack/bot-ci-dispatch dispatches ci.yml on the branch: the PR's own pull_request +# runs wait for approval, a workflow_dispatch run does not. The job grants +# actions: write for the dispatch. Each reset step's script then runs against a +# local origin over file:// (no network), for every such branch. T=$(mktemp -d "${TMPDIR:-/tmp}/ci_wiring.XXXXXX") trap 'rm -rf "$T"' EXIT -if python3 - "${BP}" "${T}/drop.sh" <<'PY' -import sys, yaml -steps = yaml.safe_load(open(sys.argv[1]))["jobs"]["repin-images"]["steps"] -cpr = next(i for i, s in enumerate(steps) - if str(s.get("uses", "")).startswith("peter-evans/create-pull-request@")) -branch = steps[cpr]["with"]["branch"] -drop = [s for s in steps[:cpr] if f"git push origin --delete {branch}" in s.get("run", "")] -if len(drop) != 1 or drop[0].get("if") != steps[cpr].get("if"): +if python3 - "${ROOT}" "${T}" > "${T}/cases" 2> "${T}/cpr.err" <<'PY' +import glob, os, sys, yaml +root, out = sys.argv[1], sys.argv[2] +errs, n = [], 0 +for path in sorted(glob.glob(os.path.join(root, ".github/workflows/*.yml"))): + jobs = (yaml.safe_load(open(path)) or {}).get("jobs") or {} + for jid, job in jobs.items(): + steps = job.get("steps") or [] + for i, s in enumerate(steps): + if not str(s.get("uses", "")).startswith("peter-evans/create-pull-request@"): + continue + n += 1 + where = f"{os.path.basename(path)} {jid}" + cond = s.get("if") + reset = [p for p in steps[:i] if "hack/autobump-branch-reset" in p.get("run", "") and p.get("if") == cond] + disp = [p for p in steps[i + 1:] if "hack/bot-ci-dispatch --branch" in p.get("run", "") and p.get("if") == cond] + if len(reset) != 1: + errs.append(f"{where}: no single step before create-pull-request, under its if:, that runs hack/autobump-branch-reset") + if len(disp) != 1 or "GH_TOKEN" not in (disp[0].get("env") or {}): + errs.append(f"{where}: no single step after create-pull-request, under its if:, that runs hack/bot-ci-dispatch --branch with GH_TOKEN") + if (job.get("permissions") or {}).get("actions") != "write": + errs.append(f"{where}: the job lacks permissions actions: write (bot-ci-dispatch dispatches)") + if len(reset) != 1: + continue + # A matrix branch (autobump/${{ matrix.component }}) runs as component "comp". + sub = lambda v: str(v).replace("${{ matrix.component }}", "comp") + branch = sub(s["with"]["branch"]) + env = {k: sub(v) for k, v in {**(job.get("env") or {}), **(reset[0].get("env") or {})}.items()} + script = sub(reset[0]["run"]) + if "${{" in branch + script + "".join(env.values()): + errs.append(f"{where}: the reset step uses an expression this test cannot evaluate") + continue + d = os.path.join(out, f"case{n}") + os.makedirs(d) + open(os.path.join(d, "run.sh"), "w").write(script) + open(os.path.join(d, "env"), "w").write("".join(f"export {k}='{v}'\n" for k, v in env.items())) + print(f"{where}\t{branch}\t{d}") +if errs or n == 0: + sys.stderr.write("\n".join(errs or ["no create-pull-request step found"]) + "\n") sys.exit(1) -open(sys.argv[2], "w").write(drop[0]["run"]) PY then - ok "repin-images deletes a stale digest branch before create-pull-request, under its if:" + ok "every create-pull-request step: autobump-branch-reset before, bot-ci-dispatch after, under its if:, actions: write" else - fail "repin-images has no step before create-pull-request, under its if:, that runs 'git push origin --delete autobump/image-digests'" + fail "create-pull-request wiring: $(tr '\n' ';' < "${T}/cpr.err")" fi -# $1: absent (no digest branch), same (main moved, no workflow change), differ -# (main changed a workflow after the branch was cut). Prints the branch's state -# on origin after the step's script ran in a depth-1 checkout of main. +for b in autobump/image-digests autobump/convert-pins autobump/comp; do + if cut -f2 "${T}/cases" | grep -qxF "$b"; then ok "a reset step covers ${b}"; else fail "no reset step covers ${b}"; fi +done +# $1: absent (no branch), same (main moved, no workflow change), differ (main +# changed a workflow after the branch was cut); $2 the branch; $3 the case dir. +# Prints the branch's state on origin after the step's script ran in a depth-1 +# checkout of main that carries hack/autobump-branch-reset. drop_case() { - local d="${T}/$1" b=autobump/image-digests + local d="$3/$1" b=$2 ( set -e export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 @@ -112,16 +150,17 @@ drop_case() { git init -q --bare -b main "$d/origin.git" git init -q -b main "$d/seed" cd "$d/seed" - mkdir -p .github/workflows + mkdir -p .github/workflows hack echo v1 > .github/workflows/ci.yml echo 1 > images.lock - git add .github/workflows/ci.yml images.lock + cp "${ROOT}/hack/autobump-branch-reset" hack/ + git add .github/workflows/ci.yml images.lock hack/autobump-branch-reset git commit -qm base git push -q "$d/origin.git" main if [ "$1" != absent ]; then git switch -qc "$b" echo 2 > images.lock - git commit -qam digests + git commit -qam bump git push -q "$d/origin.git" "$b" git switch -q main fi @@ -135,17 +174,27 @@ drop_case() { git push -q "$d/origin.git" main git clone -q --depth=1 --branch main "file://$d/origin.git" "$d/work" cd "$d/work" - bash --noprofile --norc -eo pipefail "${T}/drop.sh" >"$d/log" 2>&1 + # shellcheck disable=SC1091 + . "$3/env" + bash --noprofile --norc -eo pipefail "$3/run.sh" >"$d/log" 2>&1 ) || { echo "script-failed"; return; } if [ -n "$(git ls-remote --heads "$d/origin.git" "refs/heads/$b")" ]; then echo present; else echo gone; fi } -for c in absent:gone same:present differ:gone; do - got=$(drop_case "${c%%:*}") - if [ "${got}" = "${c#*:}" ]; then - ok "stale-branch step, ${c%%:*}: digest branch ${got}" - else - fail "stale-branch step, ${c%%:*}: digest branch ${got}, want ${c#*:}" - fi +while IFS=$'\t' read -r where b dir; do + for c in absent:gone same:present differ:gone; do + got=$(drop_case "${c%%:*}" "$b" "$dir") + if [ "${got}" = "${c#*:}" ]; then + ok "${where} reset step, ${c%%:*}: ${b} ${got}" + else + fail "${where} reset step, ${c%%:*}: ${b} ${got}, want ${c#*:}" + fi + done +done < "${T}/cases" +# autobump-branch-reset deletes only autobump/ branches (usage error, exit 2). +for b in main claude/x autobump/ ''; do + (cd "$T" && bash "${ROOT}/hack/autobump-branch-reset" "$b" >/dev/null 2>&1); rc=$? + if [ "$rc" -eq 2 ]; then ok "autobump-branch-reset refuses '${b}'" + else fail "autobump-branch-reset '${b}': exit ${rc}, want 2"; fi done # The release job builds the 24.04 variant from the Dockerfile's pin, which the # nightly moves; a literal pin in the workflow would never move. @@ -156,5 +205,108 @@ else fail "release does not read UBUNTU_2404_IMAGE from the Dockerfile for the 24.04 variant" fi +# ─── CI on bot PRs and alarm surfacing ─────────────────────────────────────── +WF="${ROOT}/.github/workflows" +# ci.yml runs on workflow_dispatch: hack/bot-ci-dispatch runs it on bot PR branches. +# (PyYAML reads the bare key `on` as True.) +if python3 - "${WF}/ci.yml" <<'PY' +import sys, yaml +wf = yaml.safe_load(open(sys.argv[1])) +on = wf.get("on", wf.get(True)) +sys.exit(0 if isinstance(on, dict) and "workflow_dispatch" in on else 1) +PY +then ok "ci.yml has a workflow_dispatch trigger"; else fail "ci.yml has no workflow_dispatch trigger"; fi +# The dependabot auto-merge workflow ran `gh pr merge --auto` on an unprotected main +# (no required checks, so nothing ever merged) and held a run on every bot PR. +if [ ! -e "${WF}/dependabot-auto-merge.yml" ]; then ok "dependabot-auto-merge.yml is gone" +else fail "dependabot-auto-merge.yml still exists"; fi +# bot-sweep.yml: hourly off the hour, never two at once, job ci-fill runs bot-ci-dispatch. +if python3 - "${WF}/bot-sweep.yml" <<'PY' +import sys, yaml +wf = yaml.safe_load(open(sys.argv[1])) +on = wf.get("on", wf.get(True)) or {} +crons = [c["cron"] for c in on.get("schedule") or []] +conc = wf.get("concurrency") or {} +job = (wf.get("jobs") or {}).get("ci-fill") or {} +perms = job.get("permissions") or {} +runs = " ".join(s.get("run", "") for s in job.get("steps") or []) +checks = [ + wf.get("name") == "Bot sweep", + crons == ["13 * * * *"], "workflow_dispatch" in on, + conc.get("group") == "bot-sweep", conc.get("cancel-in-progress") is False, + isinstance(job.get("timeout-minutes"), int), + perms == {"actions": "write", "contents": "read", "pull-requests": "read"}, + "hack/bot-ci-dispatch" in runs, + all("timeout-minutes" in j and "permissions" in j for j in wf["jobs"].values()), +] +sys.exit(0 if all(checks) else 1) +PY +then ok "bot-sweep.yml: 'Bot sweep', cron 13 * * * *, group bot-sweep, job ci-fill runs hack/bot-ci-dispatch" +else fail "bot-sweep.yml is missing or its name, schedule, concurrency or ci-fill job is wrong"; fi + +# ci.yml's last job, alarms: never fails, reads issues and actions only, and warns +# on every open ci-alarm issue and on a missing or stale Watchdog. Its script runs +# against a fake gh. +if python3 - "${WF}/ci.yml" "${T}/alarms.sh" <<'PY' +import sys, yaml +jobs = yaml.safe_load(open(sys.argv[1]))["jobs"] +job = jobs.get("alarms") or {} +ok = (list(jobs)[-1] == "alarms" and job.get("continue-on-error") is True + and isinstance(job.get("timeout-minutes"), int) + and job.get("permissions") == {"issues": "read", "actions": "read"} + and len(job.get("steps") or []) == 1) +if ok: + open(sys.argv[2], "w").write(job["steps"][0]["run"]) +sys.exit(0 if ok else 1) +PY +then ok "ci.yml ends with job alarms: continue-on-error, timeout, issues+actions read" +else fail "ci.yml's last job is not alarms with continue-on-error, timeout-minutes, issues/actions read and one step"; fi +mkdir -p "${T}/abin" +cat > "${T}/abin/gh" <<'STUB' +#!/usr/bin/env bash +A=$AFIX +case "$1 $2" in + "issue list") f=issues;; "workflow list") f=workflows;; "run list") f=runs;; *) exit 0;; +esac +[ -f "$A/$f.fail" ] && { echo "HTTP 502" >&2; exit 1; } +q=; while [ $# -gt 0 ]; do case $1 in -q|--jq) q=$2; shift 2;; *) shift;; esac; done +if [ -n "$q" ]; then jq -r "$q" "$A/$f.json"; else cat "$A/$f.json"; fi +STUB +chmod +x "${T}/abin/gh" +ALARM7='{"number":7,"title":"[ci-alarm] nightly: Auto-update red","url":"https://github.com/o/r/issues/7","labels":[{"name":"ci-alarm"}]}' +ALARM8='{"number":8,"title":"[ci-alarm] release: 100% missing","url":"https://github.com/o/r/issues/8","labels":[{"name":"ci-alarm"},{"name":"alarm-ack"}]}' +WATCHDOG='[{"name":"Watchdog","path":".github/workflows/watchdog.yml","state":"active"}]' +ago() { date -u -d "-$1 hours" +%Y-%m-%dT%H:%M:%SZ; } +# alarm_case NAME ISSUES WORKFLOWS RUNS [fail...]: runs the alarms script; sets AOUT, ARC. +alarm_case() { + AFIX="${T}/alarm-$1"; export AFIX; mkdir -p "$AFIX" + echo "$2" > "$AFIX/issues.json"; echo "$3" > "$AFIX/workflows.json"; echo "$4" > "$AFIX/runs.json" + local f; for f in "${@:5}"; do touch "$AFIX/$f.fail"; done + AOUT=$(PATH="${T}/abin:$PATH" bash --noprofile --norc -eo pipefail "${T}/alarms.sh" 2>&1); ARC=$? +} +alarm_case two "[$ALARM7,$ALARM8]" "$WATCHDOG" "[{\"updatedAt\":\"$(ago 1)\"}]" +if [ "$ARC" -eq 0 ] && [ "$(grep -c '^::warning::' <<<"$AOUT")" -eq 2 ] \ + && grep '^::warning::.*#7' <<<"$AOUT" | grep -F 'https://github.com/o/r/issues/7' | grep -qF 'gh issue edit 7 --add-label alarm-ack' \ + && grep '^::warning::.*#8' <<<"$AOUT" | grep -F 'https://github.com/o/r/issues/8' | grep -qF '100%25 missing' \ + && ! grep -q 'Watchdog' <<<"$AOUT"; then + ok "alarms: one warning per open alarm with its URL and ack state; a fresh Watchdog is quiet" +else fail "alarms two: rc=$ARC out=$(tr '\n' ';' <<<"$AOUT")"; fi +alarm_case none '[]' '[]' '[]' +if [ "$ARC" -eq 0 ] && [ "$(grep -c '^::warning::' <<<"$AOUT")" -eq 1 ] && grep -q '^::warning::.*Watchdog' <<<"$AOUT"; then + ok "alarms: a missing Watchdog workflow warns" +else fail "alarms none: rc=$ARC out=$(tr '\n' ';' <<<"$AOUT")"; fi +alarm_case stale '[]' "$WATCHDOG" "[{\"updatedAt\":\"$(ago 5)\"}]" +if [ "$ARC" -eq 0 ] && grep -q '^::warning::.*Watchdog.*3 h' <<<"$AOUT"; then ok "alarms: a Watchdog success over 3 h old warns" +else fail "alarms stale: rc=$ARC out=$(tr '\n' ';' <<<"$AOUT")"; fi +alarm_case never '[]' "$WATCHDOG" '[]' +if [ "$ARC" -eq 0 ] && grep -q '^::warning::.*Watchdog' <<<"$AOUT"; then ok "alarms: a Watchdog with no success warns" +else fail "alarms never: rc=$ARC out=$(tr '\n' ';' <<<"$AOUT")"; fi +alarm_case disabled '[]' '[{"name":"Watchdog","path":".github/workflows/watchdog.yml","state":"disabled_manually"}]' "[{\"updatedAt\":\"$(ago 1)\"}]" +if [ "$ARC" -eq 0 ] && grep -q '^::warning::.*Watchdog.*disabled_manually' <<<"$AOUT"; then ok "alarms: a disabled Watchdog warns" +else fail "alarms disabled: rc=$ARC out=$(tr '\n' ';' <<<"$AOUT")"; fi +alarm_case gh_down '[]' '[]' '[]' issues workflows runs +if [ "$ARC" -eq 0 ] && [ "$(grep -c '^::warning::' <<<"$AOUT")" -eq 2 ]; then ok "alarms: gh failing warns and still exits 0" +else fail "alarms gh_down: rc=$ARC out=$(tr '\n' ';' <<<"$AOUT")"; fi + echo "${PASS} passed, ${FAILS} failed" [ "${FAILS}" -eq 0 ] diff --git a/tests/gate_merge_test.sh b/tests/gate_merge_test.sh index b43faa27..762e8af3 100755 --- a/tests/gate_merge_test.sh +++ b/tests/gate_merge_test.sh @@ -7,8 +7,13 @@ # files changed on main and on the PR since the merge base). The stub gh answers # both `gh pr view --json statusCheckRollup` and `gh pr checks --json bucket` # from the same rollup (bucket mapping as in cli/cli pkg/cmd/pr/checks/aggregate.go). -# Cases 18-19 run the gate's sync() with real git against a bare origin (gh and +# `gh run list -e/-b` filter runs that carry event/headBranch (bare runs match +# any). Alarms: `gh issue list` answers the next line of alarms_seq (default +# none; issues_off makes it fail), and the PR's changed files are pr_paths +# (pr_paths_fail makes that fail). +# Cases 18-19 and 24-25 run the gate with real git against a bare origin (gh and # sleep still stubbed): the head the stub gh reports is the origin's feat ref. +# The ci_status and rules_stale cases source hack/gate_lib.sh directly. # # Usage: tests/gate_merge_test.sh @@ -65,12 +70,25 @@ case "$1 $2" in if [ -n "$q" ]; then jq -r "$q" <<<"$obj"; else jq "{$fields}" <<<"$obj" 2>/dev/null || echo "$obj"; fi exit 0;; "run list") - shift 2; sha= q= wf= - while [ $# -gt 0 ]; do case $1 in -c|--commit) sha=$2; shift 2;; -w|--workflow) wf=$2; shift 2;; -q) q=$2; shift 2;; *) shift;; esac; done - echo "run-list $sha ${wf:-all}" >> "$F/calls" + shift 2; sha= q= wf= ev= rb= + while [ $# -gt 0 ]; do case $1 in -c|--commit) sha=$2; shift 2;; -w|--workflow) wf=$2; shift 2;; -e|--event) ev=$2; shift 2;; -b|--branch) rb=$2; shift 2;; -q) q=$2; shift 2;; *) shift;; esac; done + echo "run-list $sha ${wf:-all} ${ev:-any}" >> "$F/calls" if [ -n "$wf" ] || [ ! -f "$F/allruns.json" ]; then out=$(cat "$F/runs.json"); else out=$(cat "$F/allruns.json"); fi + # -e and -b filter like the API on runs that carry event / headBranch; a bare run matches any. + out=$(jq --arg e "$ev" --arg b "$rb" \ + 'map(select(($e == "" or (.event // $e) == $e) and ($b == "" or (.headBranch // $b) == $b)))' <<<"$out") if [ -n "$q" ]; then jq -r "$q" <<<"$out"; else echo "$out"; fi exit 0;; + "issue list") + # open ci-alarm issues: the next line of alarms_seq (one JSON array per call), default none + [ -f "$F/issues_off" ] && { echo "the 'o/r' repository has disabled issues" >&2; exit 1; } + if [ -f "$F/alarms_seq" ]; then pop alarms_seq; else echo '[]'; fi + exit 0;; + "api --paginate") + case $* in */pulls/*/files*) + [ -f "$F/pr_paths_fail" ] && { echo "HTTP 502: Bad Gateway" >&2; exit 1; } + cat "$F/pr_paths"; exit 0;; esac + exit 0;; esac exit 0 STUB @@ -130,6 +148,7 @@ fixture() { echo 0 > "$FIX/ancestor"; echo scripts/k3d-gpu > "$FIX/base_files"; echo scripts/k3d-gpu > "$FIX/pr_files" printf '%s\n' ${6:-MERGEABLE} > "$FIX/mergeable"; echo "${7:-$GOOD_HELP}" > "$FIX/merge_help" echo OPEN > "$FIX/state"; : > "$FIX/calls"; : > "$FIX/git"; : > "$FIX/trace" + echo scripts/k3d-gpu > "$FIX/pr_paths" } run_gate() { OUT=$(PATH="$T/bin:$PATH" GATE_REPO="$FIX" GATE_WT_ROOT="$FIX/wt" GATE_POLLS=3 \ @@ -313,8 +332,12 @@ realgit_fixture() { printf '#!/bin/sh\nexit 0\n' > "$R/tests/claims_check.sh" printf '## Hypotheses\n\n| H34 | on the i9-13900T | p | m | SUPPORTED |\n' > "$R/EXPERIMENTS.md" echo base > "$R/docs/provenance.md" + mkdir -p "$R/scripts" "$R/packaging/aur" "$R/Formula" + printf '#!/usr/bin/env bash\nset -euo pipefail\n\nVERSION="0.1.0" # kept in lockstep\nCLUSTER=gpu\n' > "$R/scripts/k3d-gpu" + echo 'pkgver=0.1.0' > "$R/packaging/aur/PKGBUILD"; echo 'pkgver = 0.1.0' > "$R/packaging/aur/.SRCINFO" + echo 'VERSION=0.1.0' > "$R/packaging/install.sh"; echo 'version "0.1.0"' > "$R/Formula/k3d-gpu.rb" "$REALPY" "$R/console/model_opt.py" ledger-rules --ledger "$R/EXPERIMENTS.md" --out "$R/console/ledger-rules.json" - repo_git add console tests docs EXPERIMENTS.md && repo_git commit -q -m base \ + repo_git add console tests docs scripts packaging Formula EXPERIMENTS.md && repo_git commit -q -m base \ && repo_git remote add origin "$FIX/origin.git" && repo_git push -q origin main && repo_git checkout -q -b feat echo "$FIX/origin.git" > "$FIX/origin_git" } @@ -373,5 +396,213 @@ if [ "$RC" -eq 1 ] && ! grep -q 'synced' <<<"$OUT" && [ "$(syncs)" -eq 1 ] && [ ok "sync_merge_commit_fails: exit 1 at once, no push" else fail "sync_merge_commit_fails: rc=$RC syncs=$(syncs) merges=$(merges) origin=$before->${after:-gone} out=$(tr '\n' ';' <<<"$OUT")"; fi +# ─── CI on bot PRs: a ci.yml run dispatched on the PR branch is CI evidence ──── +# A PR that GITHUB_TOKEN opened gets pull_request runs held for approval +# (conclusion action_required); hack/bot-ci-dispatch runs ci.yml on the branch +# by workflow_dispatch instead. The stub PR's branch is feat. +# run_json EVENT BRANCH STATUS CONCLUSION CREATED: one ci.yml run for the head SHA. +run_json() { printf '{"workflowName":"CI","event":"%s","headBranch":"%s","status":"%s","conclusion":"%s","createdAt":"%s"}' "$@"; } +HELD_PR=$(run_json pull_request feat completed action_required 2026-10-20T01:00:00Z) +DISP_OK=$(run_json workflow_dispatch feat completed success 2026-10-20T01:05:00Z) +HELD_CI_CHECK=$(concl ACTION_REQUIRED) +HELD_DEP_CHECK='{"__typename":"CheckRun","name":"automerge","workflowName":"Auto-merge Dependabot PRs","status":"COMPLETED","conclusion":"ACTION_REQUIRED","startedAt":"2026-10-20T01:00:00Z"}' +HELD_DEP_RUN='{"workflowName":"Auto-merge Dependabot PRs","event":"pull_request","headBranch":"feat","status":"completed","conclusion":"action_required","createdAt":"2026-10-20T01:00:00Z"}' + +# 20. no pull_request run at all, a green ci.yml run dispatched on the PR branch: merge. +fixture dispatch_green "[$SUCCESS]" "[$DISP_OK]" abc123; run_gate +if [ "$RC" -eq 0 ] && [ "$(merges)" -eq 1 ] && grep -q -- '--match-head-commit abc123' "$FIX/merges"; then + ok "dispatch_green: a dispatched run on the branch counts as CI" +else fail "dispatch_green: rc=$RC merges=$(merges) out=$(tr '\n' ';' <<<"$OUT")"; fi + +# 21. held pull_request runs (CI and the dependabot workflow, in the rollup and the +# run list) plus a green dispatched run: the held entries do not block; merge. +fixture held_plus_dispatch "[$SUCCESS,$HELD_CI_CHECK,$HELD_DEP_CHECK]" "[$HELD_PR,$DISP_OK]" abc123 +echo "[$HELD_PR,$HELD_DEP_RUN,$DISP_OK]" > "$FIX/allruns.json"; run_gate +if [ "$RC" -eq 0 ] && [ "$(merges)" -eq 1 ]; then ok "held_plus_dispatch: held runs ignored once a dispatched run is green" +else fail "held_plus_dispatch: rc=$RC merges=$(merges) out=$(tr '\n' ';' <<<"$OUT")"; fi + +# 22. held alone still fails at once, as before: a held CheckRun in the rollup, or +# (no check runs for a held run) only the held ci.yml run. +fixture held_alone_rollup "[$SUCCESS,$HELD_CI_CHECK]" "[$HELD_PR]" abc123; run_gate +if [ "$RC" -eq 1 ] && [ "$(merges)" -eq 0 ] && grep -q FAIL <<<"$OUT"; then ok "held_alone_rollup: exit 1" +else fail "held_alone_rollup: rc=$RC merges=$(merges)"; fi +fixture held_alone_run "[$SUCCESS]" "[$HELD_PR]" abc123; run_gate +if [ "$RC" -eq 1 ] && [ "$(merges)" -eq 0 ] && grep -q FAIL <<<"$OUT"; then ok "held_alone_run: exit 1" +else fail "held_alone_run: rc=$RC merges=$(merges)"; fi + +# 23. a green run dispatched on another branch is not this PR's CI: with nothing +# else no merge (wait), and it does not excuse a held run (exit 1). +fixture dispatch_other_branch "[$SUCCESS]" "[$(run_json workflow_dispatch other completed success 2026-10-20T01:05:00Z)]" abc123; run_gate +if [ "$(merges)" -eq 0 ] && grep -q 'GAVE UP' <<<"$OUT"; then ok "dispatch_other_branch: not accepted, waits" +else fail "dispatch_other_branch: rc=$RC merges=$(merges)"; fi +fixture dispatch_other_branch_held "[$SUCCESS,$HELD_CI_CHECK]" "[$HELD_PR,$(run_json workflow_dispatch other completed success 2026-10-20T01:05:00Z)]" abc123; run_gate +if [ "$RC" -eq 1 ] && [ "$(merges)" -eq 0 ]; then ok "dispatch_other_branch_held: held run still fails" +else fail "dispatch_other_branch_held: rc=$RC merges=$(merges)"; fi + +# 23b. the latest dispatched run failed after an earlier green one: exit 1. +fixture dispatch_latest_failed "[$SUCCESS]" "[$DISP_OK,$(run_json workflow_dispatch feat completed failure 2026-10-20T02:00:00Z)]" abc123; run_gate +if [ "$RC" -eq 1 ] && [ "$(merges)" -eq 0 ]; then ok "dispatch_latest_failed: exit 1" +else fail "dispatch_latest_failed: rc=$RC merges=$(merges)"; fi + +# ─── ci_status and rules_stale, called from hack/gate_lib.sh directly ───────── +lib_ci() { # lib_ci NAME RUNS WANT: ci_status abc123 feat over the ci.yml runs RUNS + fixture "lib_$1" '[]' "$2" abc123 + local got; got=$(PATH="$T/bin:$PATH" bash -c '. "$1"; ci_status abc123 feat' _ "$ROOT/hack/gate_lib.sh" 2>&1) + if [ "$got" = "$3" ]; then ok "ci_status $1: $got"; else fail "ci_status $1: got '$got', want $3"; fi +} +lib_ci none '[]' none +lib_ci pr_green "[$(run_json pull_request feat completed success 2026-10-20T01:00:00Z)]" green +lib_ci dispatch_green "[$DISP_OK]" green +lib_ci held_only "[$HELD_PR]" failed +lib_ci held_plus_dispatch "[$HELD_PR,$DISP_OK]" green +lib_ci held_plus_running "[$HELD_PR,$(run_json workflow_dispatch feat in_progress '' 2026-10-20T01:05:00Z)]" pending +lib_ci dispatch_queued "[$(run_json workflow_dispatch feat queued '' 2026-10-20T01:05:00Z)]" pending +lib_ci other_branch "[$(run_json workflow_dispatch other completed success 2026-10-20T01:05:00Z)]" none +lib_ci latest_failed "[$DISP_OK,$(run_json workflow_dispatch feat completed failure 2026-10-20T02:00:00Z)]" failed +lib_ci failed_then_green "[$(run_json workflow_dispatch feat completed failure 2026-10-20T01:00:00Z),$DISP_OK]" green +lib_ci failed_then_running "[$(run_json workflow_dispatch feat completed failure 2026-10-20T01:00:00Z),$(run_json workflow_dispatch feat in_progress '' 2026-10-20T01:05:00Z)]" pending + +# rules_stale keeps its behaviour: stale tree -> 0, matching tree -> 1, no tree -> 1. +lib_rules() { # lib_rules NAME WANT_RC [current] + fixture "librules_$1" '[]' '[]' abc123 + mkdir -p "$FIX/show/console"; cp "$ROOT/console/model_opt.py" "$ROOT/console/convert-pins.json" "$FIX/show/console/" + printf '## Hypotheses\n\n| H34 | on the i9-13900T | p | m | SUPPORTED |\n' > "$FIX/show/EXPERIMENTS.md" + if [ "${3:-}" = current ]; then + python3 "$ROOT/console/model_opt.py" ledger-rules --ledger "$FIX/show/EXPERIMENTS.md" --out "$FIX/show/console/ledger-rules.json" + else echo '{"schema": "k3d-gpu.ledger-rules/v1", "rows": []}' > "$FIX/show/console/ledger-rules.json"; fi + [ "$1" = notree ] || echo t1 > "$FIX/tree" + PATH="$T/bin:$PATH" bash -c '. "$1"; rules_stale abc123' _ "$ROOT/hack/gate_lib.sh" >/dev/null 2>&1; local rc=$? + if [ "$rc" -eq "$2" ]; then ok "rules_stale $1: rc $rc"; else fail "rules_stale $1: rc $rc, want $2"; fi +} +lib_rules stale 0; lib_rules current 1 current; lib_rules notree 1 + +# ─── base_ok: release-only bot commits on main do not count as a moved base ──── +# release_to_main AUTHOR SUBJECT [code]: one commit on main that moves the version +# in scripts/k3d-gpu's VERSION line and the AUR, install.sh and Formula files; +# with `code` it also changes another line of scripts/k3d-gpu. +release_to_main() { + local R=$FIX/repo + repo_git checkout -q main + sed -i 's/^VERSION="0\.1\.0"/VERSION="0.1.1"/' "$R/scripts/k3d-gpu" + [ "${3:-}" = code ] && sed -i 's/^CLUSTER=gpu$/CLUSTER=cpu/' "$R/scripts/k3d-gpu" + echo 'pkgver=0.1.1' > "$R/packaging/aur/PKGBUILD"; echo 'pkgver = 0.1.1' > "$R/packaging/aur/.SRCINFO" + repo_git add scripts packaging/aur && GIT_AUTHOR_NAME=$1 "$REALGIT" -C "$R" commit -q -m "$2" && repo_git push -q origin main +} +# install_to_main AUTHOR: the install.sh and Homebrew release commits. +install_to_main() { + local R=$FIX/repo + repo_git checkout -q main + echo 'VERSION=0.1.1' > "$R/packaging/install.sh"; repo_git add packaging/install.sh + GIT_AUTHOR_NAME=$1 "$REALGIT" -C "$R" commit -q -m "release: install.sh k3d-gpu 0.1.1 [skip release]" + echo 'version "0.1.1"' > "$R/Formula/k3d-gpu.rb"; repo_git add Formula/k3d-gpu.rb + GIT_AUTHOR_NAME=$1 "$REALGIT" -C "$R" commit -q -m "release: homebrew k3d-gpu 0.1.1 [skip release]" + repo_git push -q origin main +} + +# 24. the PR changes code, and main moved only by release-only bot commits: merge, no sync. +realgit_fixture release_only_base "[$SUCCESS]" "$CI_OK" +commit_to feat scripts.txt $'pr\n' +release_to_main 88plug-bot "release: aur-v0.1.1 [skip release]"; install_to_main 'github-actions[bot]' +run_gate_real +if [ "$RC" -eq 0 ] && [ "$(merges)" -eq 1 ] && [ "$(syncs)" -eq 0 ] && ! grep -q 'main moved under code' <<<"$OUT"; then + ok "release_only_base: merged, no sync" +else fail "release_only_base: rc=$RC merges=$(merges) syncs=$(syncs) out=$(tr '\n' ';' <<<"$OUT")"; fi + +# 25. a release commit that also changes another launcher line is a code move: sync first. +realgit_fixture release_with_code "[$SUCCESS]" "$CI_OK" +commit_to feat scripts.txt $'pr\n' +release_to_main 88plug-bot "release: aur-v0.1.1 [skip release]" code +run_gate_real +if [ "$RC" -eq 0 ] && [ "$(merges)" -eq 1 ] && [ "$(syncs)" -eq 1 ] && grep -q 'main moved under code' <<<"$OUT"; then + ok "release_with_code: synced before the merge" +else fail "release_with_code: rc=$RC merges=$(merges) syncs=$(syncs) out=$(tr '\n' ';' <<<"$OUT")"; fi + +# 25b. the same release files under a release subject, but by a human: a code move. +realgit_fixture release_by_human "[$SUCCESS]" "$CI_OK" +commit_to feat scripts.txt $'pr\n' +release_to_main gate-test "release: aur-v0.1.1 [skip release]" +run_gate_real +if [ "$RC" -eq 0 ] && [ "$(syncs)" -eq 1 ] && grep -q 'main moved under code' <<<"$OUT"; then + ok "release_by_human: synced before the merge" +else fail "release_by_human: rc=$RC merges=$(merges) syncs=$(syncs) out=$(tr '\n' ';' <<<"$OUT")"; fi + +# 25c. a bot commit on release files without [skip release] in its subject: a code move. +realgit_fixture release_no_skip "[$SUCCESS]" "$CI_OK" +commit_to feat scripts.txt $'pr\n' +release_to_main 88plug-bot "release: aur-v0.1.1" +run_gate_real +if [ "$RC" -eq 0 ] && [ "$(syncs)" -eq 1 ]; then ok "release_no_skip: synced before the merge" +else fail "release_no_skip: rc=$RC merges=$(merges) syncs=$(syncs) out=$(tr '\n' ';' <<<"$OUT")"; fi + +# ─── alarms: open ci-alarm issues are printed, and refuse shipped changes ────── +UNACKED='{"number":7,"title":"[ci-alarm] nightly: Auto-update red","url":"https://github.com/o/r/issues/7","labels":[{"name":"ci-alarm"}]}' +ACKED='{"number":8,"title":"[ci-alarm] release: 0.1.79 missing on GHCR","url":"https://github.com/o/r/issues/8","labels":[{"name":"ci-alarm"},{"name":"alarm-ack"}]}' + +# 26. an un-acked alarm and a PR that changes the launcher: exit 4, URL and ack command shown. +fixture alarm_unacked "[$SUCCESS]" "$CI_OK" abc123; echo "[$UNACKED]" > "$FIX/alarms_seq"; run_gate +if [ "$RC" -eq 4 ] && [ "$(merges)" -eq 0 ] && grep -qF 'https://github.com/o/r/issues/7' <<<"$OUT" \ + && grep -qF 'gh issue edit 7 --add-label alarm-ack' <<<"$OUT"; then + ok "alarm_unacked: refused with exit 4, URL and ack command printed" +else fail "alarm_unacked: rc=$RC merges=$(merges) out=$(tr '\n' ';' <<<"$OUT")"; fi + +# 26b. a workflow-only PR is refused too; so is a PR whose files cannot be listed. +fixture alarm_workflow "[$SUCCESS]" "$CI_OK" abc123; echo "[$UNACKED]" > "$FIX/alarms_seq" +echo .github/workflows/ci.yml > "$FIX/pr_paths"; run_gate +if [ "$RC" -eq 4 ] && [ "$(merges)" -eq 0 ]; then ok "alarm_workflow: refused" +else fail "alarm_workflow: rc=$RC merges=$(merges)"; fi +fixture alarm_files_unknown "[$SUCCESS]" "$CI_OK" abc123; echo "[$UNACKED]" > "$FIX/alarms_seq"; touch "$FIX/pr_paths_fail"; run_gate +if [ "$RC" -eq 4 ] && [ "$(merges)" -eq 0 ]; then ok "alarm_files_unknown: refused" +else fail "alarm_files_unknown: rc=$RC merges=$(merges)"; fi + +# 26c. GATE_RELEASE_PATHS adds shipped paths (hack/release-paths format: one +# pattern per line, # comments, dir/** for a tree). +fixture alarm_release_paths "[$SUCCESS]" "$CI_OK" abc123; echo "[$UNACKED]" > "$FIX/alarms_seq" +printf '# shipped by nfpm\noracle/**\n' > "$FIX/release-paths"; echo oracle/bundle.py > "$FIX/pr_paths" +OUT=$(PATH="$T/bin:$PATH" GATE_REPO="$FIX" GATE_WT_ROOT="$FIX/wt" GATE_POLLS=3 GATE_RELEASE_PATHS="$FIX/release-paths" \ + timeout 60 bash "$GATE" 1 subj body 2>&1); RC=$? +if [ "$RC" -eq 4 ] && [ "$(merges)" -eq 0 ]; then ok "alarm_release_paths: a listed path is refused" +else fail "alarm_release_paths: rc=$RC merges=$(merges) out=$(tr '\n' ';' <<<"$OUT")"; fi + +# 26d. no alarm at the start, an un-acked one by the merge: refused before merging. +fixture alarm_opens_later "[$SUCCESS]" "$CI_OK" abc123; printf '[]\n[%s]\n' "$UNACKED" > "$FIX/alarms_seq"; run_gate +if [ "$RC" -eq 4 ] && [ "$(merges)" -eq 0 ]; then ok "alarm_opens_later: re-checked and refused before the merge" +else fail "alarm_opens_later: rc=$RC merges=$(merges) out=$(tr '\n' ';' <<<"$OUT")"; fi + +# 27. every open alarm acked: listed at the start, and the PR merges. +fixture alarm_acked "[$SUCCESS]" "$CI_OK" abc123; echo "[$ACKED]" > "$FIX/alarms_seq"; run_gate +if [ "$RC" -eq 0 ] && [ "$(merges)" -eq 1 ] && grep -F '#8' <<<"$OUT" | grep -qF 'https://github.com/o/r/issues/8'; then + ok "alarm_acked: listed, merged" +else fail "alarm_acked: rc=$RC merges=$(merges) out=$(tr '\n' ';' <<<"$OUT")"; fi + +# 28. ledger, docs, bench and test changes are never refused; nor is a path outside +# the shipped set. +fixture alarm_ledger_only "[$SUCCESS]" "$CI_OK" abc123; echo "[$UNACKED,$ACKED]" > "$FIX/alarms_seq" +printf 'EXPERIMENTS.md\ndocs/provenance.md\nbench/h54/run.sh\ntests/bench_test.sh\n' > "$FIX/pr_paths"; run_gate +if [ "$RC" -eq 0 ] && [ "$(merges)" -eq 1 ] && grep -qF 'issues/7' <<<"$OUT"; then ok "alarm_ledger_only: listed, merged" +else fail "alarm_ledger_only: rc=$RC merges=$(merges) out=$(tr '\n' ';' <<<"$OUT")"; fi +fixture alarm_unshipped "[$SUCCESS]" "$CI_OK" abc123; echo "[$UNACKED]" > "$FIX/alarms_seq" +echo README.md > "$FIX/pr_paths"; run_gate +if [ "$RC" -eq 0 ] && [ "$(merges)" -eq 1 ]; then ok "alarm_unshipped: merged" +else fail "alarm_unshipped: rc=$RC merges=$(merges)"; fi + +# 30. the gate copied alone (a scratchpad shim) finds gate_lib.sh in the repository's +# hack/; with neither copy it stops before merging. +fixture solo_gate "[$SUCCESS]" "$CI_OK" abc123; mkdir -p "$FIX/solo" "$FIX/hack" +cp "$GATE" "$FIX/solo/gate_merge.sh"; cp "$ROOT/hack/gate_lib.sh" "$FIX/hack/" +OUT=$(PATH="$T/bin:$PATH" GATE_REPO="$FIX" GATE_WT_ROOT="$FIX/wt" GATE_POLLS=3 timeout 60 bash "$FIX/solo/gate_merge.sh" 1 subj body 2>&1); RC=$? +if [ "$RC" -eq 0 ] && [ "$(merges)" -eq 1 ]; then ok "solo_gate: gate_lib.sh read from the repository's hack/" +else fail "solo_gate: rc=$RC merges=$(merges) out=$(tr '\n' ';' <<<"$OUT")"; fi +rm "$FIX/hack/gate_lib.sh"; echo OPEN > "$FIX/state"; rm -f "$FIX/merges" +OUT=$(PATH="$T/bin:$PATH" GATE_REPO="$FIX" GATE_WT_ROOT="$FIX/wt" GATE_POLLS=3 timeout 60 bash "$FIX/solo/gate_merge.sh" 1 subj body 2>&1); RC=$? +if [ "$RC" -eq 1 ] && [ "$(merges)" -eq 0 ] && grep -q 'gate_lib.sh not found' <<<"$OUT"; then ok "solo_gate: no gate_lib.sh, exit 1" +else fail "solo_gate missing lib: rc=$RC merges=$(merges) out=$(tr '\n' ';' <<<"$OUT")"; fi + +# 29. issues cannot be listed (the repository has them disabled): the gate says so, +# with gh's reason, and goes on. +fixture alarm_issues_off "[$SUCCESS]" "$CI_OK" abc123; touch "$FIX/issues_off"; run_gate +if [ "$RC" -eq 0 ] && [ "$(merges)" -eq 1 ] && grep -q 'WARNING: could not list the open ci-alarm issues.*disabled issues' <<<"$OUT"; then + ok "alarm_issues_off: warned with the reason, merged" +else fail "alarm_issues_off: rc=$RC merges=$(merges) out=$(tr '\n' ';' <<<"$OUT")"; fi + echo "${PASS} passed, ${FAILS} failed" [ "${FAILS}" -eq 0 ]