Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 40 additions & 4 deletions .github/workflows/bot-sweep.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
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).
# 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
# When it runs: each ci.yml run dispatched on autobump/* or main and each canary.yml
# run dispatched on autobump/* ends with job sweep, which dispatches this workflow
# with after_run set to its own run id: those are the runs whose results the sweep
# acts on, and a run dispatched with GITHUB_TOKEN starts no workflow_run. The
# hourly cron is the backstop: GitHub started it only 3.7 to 9.8 h apart here
# (22 scheduled runs, 2026-11-01 to 11-07).
#
# ci-fill: with after_run set, it first waits (5 min at most) for that run to
# complete, so the sweep reads it finished.
# 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. Then the same for
Expand All @@ -24,6 +33,11 @@ on:
schedule:
- cron: '13 * * * *'
workflow_dispatch:
inputs:
after_run:
description: 'a run id: wait for that run to complete first (job sweep of ci.yml and canary.yml sets it)'
type: string
default: ''

permissions: {}

Expand All @@ -35,12 +49,34 @@ jobs:
ci-fill:
name: CI on bot PRs
runs-on: ubuntu-latest
timeout-minutes: 10
timeout-minutes: 15 # the wait for after_run (5 min at most), then the dispatches
permissions:
actions: write
contents: read
pull-requests: read
steps:
- name: Wait for the run that dispatched this sweep to complete
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
AFTER_RUN: ${{ inputs.after_run }}
run: |
[ -n "$AFTER_RUN" ] || exit 0
if ! [[ $AFTER_RUN =~ ^[0-9]+$ ]]; then
echo "::error::after_run must be a run id (digits only)"
exit 1
fi
st=unread
for _ in $(seq 60); do
st=$(gh run view "$AFTER_RUN" --json status -q .status 2>/dev/null) || st=unreadable
if [ "$st" = completed ]; then
echo "run $AFTER_RUN completed"
exit 0
fi
sleep 5
done
echo "::warning::run $AFTER_RUN is still ${st:-unread} after 5 min; this sweep reads its evidence as it stands"

- name: Checkout main
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
Expand Down
43 changes: 41 additions & 2 deletions .github/workflows/canary.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@ name: Canary
# every selected engine's verdict is pass with hard true.
#
# Read-only: no secrets, no registry login (public images), GitHub-hosted runners only.
# The re-dispatch job alone may write: it starts this workflow again (attempt + 1, at most
# 3) when an engine was isa-skip, the runner's CPU lacking an instruction set it needs.
# Two jobs may write, each only to dispatch a workflow: re-dispatch starts this workflow
# again (attempt + 1, at most 3) when an engine was isa-skip, the runner's CPU lacking an
# instruction set it needs; sweep starts bot-sweep.yml after a run dispatched on autobump/*.

on:
schedule:
Expand Down Expand Up @@ -235,3 +236,41 @@ jobs:
exit 0
fi
gh workflow run canary.yml --repo "$REPO" --ref "$REF" -f engines="$ENGINES" -f attempt="$((ATTEMPT + 1))"

# A run dispatched on autobump/* (a bot PR's canary) is evidence bot-sweep.yml acts
# on, and a run dispatched with GITHUB_TOKEN starts no workflow_run, so the sweep
# would wait for its hourly cron, which GitHub starts hours apart here. After
# every other job this one dispatches the sweep with after_run set to this run's id;
# the sweep waits for this run to complete before it reads it. It never fails the
# canary (continue-on-error, exit 0). The step is ci.yml's job sweep's step.
sweep:
name: Hand off to the bot sweep
needs: [plan, runtime, surface, node, report, redispatch]
if: ${{ !cancelled() && github.event_name == 'workflow_dispatch' && startsWith(github.ref, 'refs/heads/autobump/') }}
runs-on: ubuntu-latest
timeout-minutes: 5
continue-on-error: true
permissions:
actions: write
steps:
- name: Dispatch bot-sweep.yml, which waits for this run to complete
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
RUN_ID: ${{ github.run_id }}
run: |
# GITHUB_TOKEN: 1,000 REST requests an hour for the repository, every workflow's;
# a sweep reads about 10 per open bot PR. GET /rate_limit costs none.
left=$(gh api rate_limit --jq .resources.core.remaining 2>/dev/null) || left=
if [[ $left =~ ^[0-9]+$ ]] && [ "$left" -lt 500 ]; then
echo "::notice::$left GITHUB_TOKEN requests left this hour; no hand-off, the next scheduled sweep reads this run"
exit 0
fi
for n in 1 2 3; do
if gh workflow run bot-sweep.yml --ref main -f after_run="$RUN_ID"; then
echo "dispatched bot-sweep.yml with after_run=$RUN_ID"
exit 0
fi
if [ "$n" -lt 3 ]; then sleep $((10 * n)); fi
done
echo "::warning::could not dispatch bot-sweep.yml; its next scheduled run reads this run"
48 changes: 48 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -649,6 +649,54 @@ jobs:
persist-credentials: false
- run: tests/bot_merge_test.sh

# ─── Hand-off to the bot sweep ───────────────────────────────────────────
# A run dispatched on autobump/* (a bot PR's CI) or on main (the CI the sweep or the
# watchdog dispatches) is evidence bot-sweep.yml acts on, and a run dispatched with
# GITHUB_TOKEN starts no workflow_run, so the sweep would wait for its hourly cron,
# which GitHub starts hours apart here (bot-sweep.yml). After every other job this
# one dispatches the sweep with after_run set to this run's id; the sweep waits for
# this run to complete before it reads it. It never fails CI (continue-on-error,
# exit 0).
# needs lists every other job (tests/ci_wiring_test.sh checks it).
sweep:
name: Hand off to the bot sweep
needs: [shellcheck, actionlint, detect-tests, quickstart-tests, fuzz-props, remote-tests, telemetry-tests,
agent-manifests, intel-gpu-dra-manifests, share-manifests, console-tests, images-lock, report-html,
oracle, claims-check, bench-tests, fit-tests, tune-tests, capacity-tests, mcp-tests, kuberay-example,
chat-ui-manifest, sim-fixtures, sim-matrix, agent-examples, register-model-test, render,
packaging-tests, deb-rpm, homebrew, nix-flake, hadolint, arch-image, pkgbuild-lint, kserve-example,
ops-tests, known-issues, canary-tests, pin-guard, pins, bot-merge-tests, alarms]
if: >-
${{ !cancelled() && github.event_name == 'workflow_dispatch'
&& (startsWith(github.ref, 'refs/heads/autobump/') || github.ref == 'refs/heads/main') }}
runs-on: ubuntu-latest
timeout-minutes: 5
continue-on-error: true
permissions:
actions: write
steps:
- name: Dispatch bot-sweep.yml, which waits for this run to complete
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
RUN_ID: ${{ github.run_id }}
run: |
# GITHUB_TOKEN: 1,000 REST requests an hour for the repository, every workflow's;
# a sweep reads about 10 per open bot PR. GET /rate_limit costs none.
left=$(gh api rate_limit --jq .resources.core.remaining 2>/dev/null) || left=
if [[ $left =~ ^[0-9]+$ ]] && [ "$left" -lt 500 ]; then
echo "::notice::$left GITHUB_TOKEN requests left this hour; no hand-off, the next scheduled sweep reads this run"
exit 0
fi
for n in 1 2 3; do
if gh workflow run bot-sweep.yml --ref main -f after_run="$RUN_ID"; then
echo "dispatched bot-sweep.yml with after_run=$RUN_ID"
exit 0
fi
if [ "$n" -lt 3 ]; then sleep $((10 * n)); fi
done
echo "::warning::could not dispatch bot-sweep.yml; its next scheduled run reads this run"

# ─── Bot PR CI and alarm surfacing ───────────────────────────────────────
# The watchdog files a ci-alarm (an issue; with Issues disabled an entry on branch
# ci-status) for a red or missing scheduled run or a broken release (email went
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1762,8 +1762,9 @@ Freshness workflow (`pins/registry.toml`, group `nri`) runs `--latest` for the
components and opens one PR for them (branch `autobump/nri`); merging it changes `share/`,
so auto-release cuts a 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. The hourly bot sweep
(`hack/bot-merge`) merges one such PR at a time when its pins policy says auto (or a
the branch by workflow_dispatch instead, and that run is the PR's CI. The bot sweep
(`hack/bot-merge`), which each such CI or canary run starts as it ends (its hourly cron is
the backstop), merges one such PR at a time when its pins policy says auto (or a
maintainer approved its head), its CI and canary are green, its base is current and main's
CI is green; it then dispatches CI on main and a batched release. Any other bot PR stays
open with one comment that says what blocks it.
Expand Down
3 changes: 2 additions & 1 deletion tests/canary_spec_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,8 @@ assert jobs["runtime"]["timeout-minutes"] == 45 and jobs["runtime"]["strategy"][
assert jobs["surface"]["timeout-minutes"] == 30 and jobs["node"]["timeout-minutes"] == 40
assert "always()" in jobs["report"]["if"]
writes = {n for n, j in jobs.items() if any(v == "write" for v in (j["permissions"] or {}).values())}
assert writes <= {"redispatch"}, writes
assert writes <= {"redispatch", "sweep"}, writes
assert jobs["sweep"]["permissions"] == {"actions": "write"}, jobs["sweep"]["permissions"]
assert "secrets." not in src, "no secrets"
for u in re.findall(r"uses:\s*(\S+)", src):
assert re.fullmatch(r"[\w.-]+/[\w./-]+@[0-9a-f]{40}", u), "not pinned by SHA: " + u
Expand Down
Loading
Loading