From a6939e00567416efc024f94bf50e3cf17e514fb6 Mon Sep 17 00:00:00 2001 From: Mao Nakamoto <41178744+maonakamoto@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:29:45 +0200 Subject: [PATCH 1/2] ci: give this repo a gate, and let green PRs ship themselves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HamsterCheek had no CI at all. `npm run verify` (typecheck + lint + vitest) has existed since the seed commit and was only ever run by hand — which, for a repo built by dispatched agents, means it was not run. Two of the three commits here landed with nothing checking them. That also made this the one active project that could not participate in the fleet's auto-merge: the sweep refuses to merge a PR with zero checks, so "green PRs merge and deploy themselves" was structurally impossible here. - ci.yml: npm run verify, then migrate + `next build`. The build needs a real database — the home page reads DATABASE_URL at module scope, so page-data collection dies on a bare runner (verified, not assumed). A schema-only Postgres service covers it; the build needs the queries to run, not to return rows. verify itself needs no DB, the unit tests being pure. - auto-merge.yml + scripts/ci/auto-merge-sweep.sh, ported from the fleet. Two deliberate deviations from the fleet template: 1. The sweep drains OLDEST-first. Upstream merges the first PR that `gh pr list` returns, which is newest-first, so an older green PR can wait indefinitely — observed in fleetcrown today, where two sweeps merged the two newest PRs while three older green ones were never evaluated. Fixed there as #182; no reason to seed a known starvation bug into a new repo. 2. The cron is hourly, not */10. This repo is PRIVATE, so Actions minutes are billed, and every other repo running this sweep is public where they are free. At */10 the safety net alone would burn ~4,300 billed minutes a month against a 2,000 allowance, mostly to discover there are no PRs. The common path is workflow_run, which fires seconds after CI goes green, so hourly costs almost nothing in latency. Verified: `npm run verify` green locally (typecheck, lint, 6/6 tests). Co-Authored-By: Claude Opus 5 --- .github/workflows/auto-merge.yml | 64 ++++++++++ .github/workflows/ci.yml | 69 ++++++++++ scripts/ci/auto-merge-sweep.sh | 208 +++++++++++++++++++++++++++++++ 3 files changed, 341 insertions(+) create mode 100644 .github/workflows/auto-merge.yml create mode 100644 .github/workflows/ci.yml create mode 100755 scripts/ci/auto-merge-sweep.sh diff --git a/.github/workflows/auto-merge.yml b/.github/workflows/auto-merge.yml new file mode 100644 index 0000000..7c04dd2 --- /dev/null +++ b/.github/workflows/auto-merge.yml @@ -0,0 +1,64 @@ +# Auto-merge — nobody is in the merge loop. +# +# Green, ready PRs merge themselves and deploy themselves. The owner does not +# review PRs, and background-job agent sessions are barred from merging by hand, +# so the policy lives in scripts/ci/auto-merge-sweep.sh (read it — it defines +# exactly what "ready" means and how a PR is held back). +# +# Two triggers, deliberately: +# workflow_run — merges within seconds of CI going green (the common path). +# schedule — a safety net. Catches PRs whose checks finished while this +# workflow was failing/disabled, and PRs whose last check was +# an external status that reported after CI. Without it, a PR +# that went green "off-cycle" waits forever. +# +# To stop all of this: delete this file, or add a `hold` label to a PR. + +name: Auto-merge + +on: + workflow_run: + workflows: ['CI'] + types: [completed] + # HOURLY, not */10 as in the rest of the fleet — this repo is PRIVATE, so + # Actions minutes are billed. Every other repo running this sweep is public, + # where scheduled runs are free. At */10 the safety net alone would burn + # ~4,300 billed minutes a month (144 runs/day, each rounded up to a minute) + # against a 2,000-minute free allowance, and it would spend most of them + # discovering there are no PRs. Hourly costs ~720. + # + # This barely slows anything down: the schedule is only the safety net. The + # common path is workflow_run above, which fires within seconds of CI going + # green. Make this repo public and */10 becomes free again. + schedule: + - cron: '0 * * * *' + workflow_dispatch: {} + +permissions: + contents: write # merge the PR + pull-requests: write # read PR state, delete the branch + actions: write # dispatch the re-arm workflows + +# Never let two sweeps merge concurrently — they would race on the same PRs. +concurrency: + group: auto-merge + cancel-in-progress: false + +jobs: + sweep: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - name: Merge every green, ready PR + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + BASE_BRANCH: ${{ github.event.repository.default_branch }} + CI_WORKFLOW: ci.yml + # Everything that runs on push and therefore would NOT fire after a + # merge made with the default GITHUB_TOKEN. Keep this in sync when a + # push-triggered workflow is added. + REARM_WORKFLOWS: ci.yml + run: bash scripts/ci/auto-merge-sweep.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..61c4430 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,69 @@ +name: CI + +# Nothing has ever run this repo's checks. `npm run verify` (typecheck + lint + +# vitest) existed from the seed commit and was only ever run by hand, which for +# an agent-built project means: not run. This is the gate that makes it real, +# and it is also what lets auto-merge work at all — the sweep refuses to merge a +# PR with zero checks, so without CI nothing here could ever ship itself. + +on: + # Load-bearing for the merge train: a merge made by auto-merge.yml uses the + # default GITHUB_TOKEN, and a push made with that token does NOT trigger + # workflows. The sweep dispatches this explicitly afterwards to verify the + # merge on main; without this trigger that re-arm silently fails. + workflow_dispatch: {} + push: + branches: [main] + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + ci: + runs-on: ubuntu-latest + + # `next build` needs a real database. The home page reads DATABASE_URL at + # module scope, so page-data collection dies with "DATABASE_URL is not set" + # on a bare runner — verified before writing this. A schema-only Postgres is + # enough: the build only needs the queries to run, not to return rows. + services: + postgres: + image: postgres:17 + env: + POSTGRES_PASSWORD: ci + POSTGRES_DB: hamstercheek + ports: ['5432:5432'] + options: >- + --health-cmd pg_isready --health-interval 5s + --health-timeout 5s --health-retries 10 + + env: + DATABASE_URL: postgres://postgres:ci@localhost:5432/hamstercheek + + steps: + - uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + # ONE canonical bundle — the exact `npm run verify` you run locally, so a + # green local verify cannot diverge from a green CI. Needs no database: + # the unit tests are pure. + - name: Verify (typecheck + lint + test) + run: npm run verify + + - name: Apply migrations (the build queries this DB) + run: npm run db:migrate + + # Catches the class verify cannot: code that typechecks but fails to + # compile or blows up while Next collects page data. + - name: Build + run: npm run build diff --git a/scripts/ci/auto-merge-sweep.sh b/scripts/ci/auto-merge-sweep.sh new file mode 100755 index 0000000..08ae2db --- /dev/null +++ b/scripts/ci/auto-merge-sweep.sh @@ -0,0 +1,208 @@ +#!/usr/bin/env bash +# +# Merge every open PR that is ready and fully green, then re-arm CI/CD. +# +# WHY THIS EXISTS +# --------------- +# Nobody reviews PRs on this fleet — the owner explicitly does not want to be in +# the merge loop, and background-job agent sessions are barred from merging by +# hand. So the policy lives here, in the repo, where it is visible, revocable, +# and applies uniformly to every PR instead of depending on who opened it. +# +# THE POLICY +# merge a PR <=> it is not a draft +# AND carries no hold label +# AND has at least one check +# AND every check has finished green +# AND GitHub reports it cleanly mergeable +# +# Anything else is left alone for the next sweep. Nothing here forces a merge: +# a red or pending PR simply waits, and a draft waits forever. To hold a ready +# PR back, mark it a draft or add one of the hold labels below. +# +# ONE PR PER SWEEP, OLDEST FIRST, AND ONLY ONTO A GREEN BASE +# ---------------------------------------------------------- +# A PR's checks prove *that PR against the base it branched from* — not against +# the other PRs sitting next to it. Merging a batch in one pass would put a +# combination onto the base that nothing ever built. So this script merges at +# most one PR, then hands control back to CI: the merge train advances one car +# per sweep, and every car is verified on the base before the next one couples. +# +# For the same reason it refuses to merge while the base's CI is red or still +# running. Red base => stop adding changes until it is fixed; running CI => the +# answer is not in yet. Both simply defer to the next sweep. +# +# THE RE-ARM (do not remove) +# A push made with the default GITHUB_TOKEN does NOT trigger workflows. Both +# CI and the deploy workflow here run on push, so a merge from this script +# would otherwise land on the base branch and never build or ship. Worse, the +# green-base guard above keys on "a CI run exists for the current tip" — with +# no CI run ever produced by an automated merge, the very next sweep would +# block forever. The explicit workflow_dispatch calls at the end restore both. +# +# REARM_WORKFLOWS is set by .github/workflows/auto-merge.yml and lists exactly +# the workflows that would otherwise have fired on push. + +set -euo pipefail + +REPO="${GH_REPO:?GH_REPO must be set}" +BASE_BRANCH="${BASE_BRANCH:-main}" +CI_WORKFLOW="${CI_WORKFLOW:-ci.yml}" +REARM_WORKFLOWS="${REARM_WORKFLOWS:-$CI_WORKFLOW}" + +# A PR wearing any of these is never merged automatically. +HOLD_LABELS='["hold","no-automerge","do-not-merge","wip"]' + +echo "[auto-merge] sweeping open PRs against ${BASE_BRANCH} in ${REPO}" + +# Never add changes to a base that is red or mid-verification. +# +# The run has to belong to the CURRENT tip of the base branch. Checking only +# "the latest CI run" is a trap: right after a merge, the newest run is still +# the *previous* commit's — and it is green — so the guard would wave through a +# second merge onto a commit nothing has verified yet. That is exactly the +# batching this script exists to prevent. +base_sha=$(gh api "repos/${REPO}/commits/${BASE_BRANCH}" --jq '.sha') +base_ci=$(gh run list --repo "$REPO" --workflow "$CI_WORKFLOW" --branch "$BASE_BRANCH" --limit 1 \ + --json status,conclusion,headSha --jq '.[0] // empty') + +if [ -z "$base_ci" ]; then + echo "[auto-merge] no CI history for ${BASE_BRANCH} — proceeding" +else + base_status=$(printf '%s' "$base_ci" | jq -r '.status') + base_conclusion=$(printf '%s' "$base_ci" | jq -r '.conclusion // ""') + base_ci_sha=$(printf '%s' "$base_ci" | jq -r '.headSha') + + if [ "$base_ci_sha" != "$base_sha" ]; then + echo "[auto-merge] ${BASE_BRANCH} is at ${base_sha:0:8} but the newest CI run is for ${base_ci_sha:0:8} — waiting for CI to catch up" + exit 0 + fi + if [ "$base_status" != "completed" ]; then + echo "[auto-merge] ${BASE_BRANCH} CI is still running — deferring to the next sweep" + exit 0 + fi + if [ "$base_conclusion" != "success" ]; then + echo "[auto-merge] ${BASE_BRANCH} CI is ${base_conclusion} — refusing to merge onto a broken base" >&2 + exit 0 + fi +fi + +prs_json=$(gh pr list --repo "$REPO" --state open --base "$BASE_BRANCH" --limit 50 \ + --json number,title,isDraft,mergeable,mergeStateStatus,labels,statusCheckRollup) + +count=$(printf '%s' "$prs_json" | jq 'length') +if [ "$count" -eq 0 ]; then + echo "[auto-merge] no open PRs" + exit 0 +fi + +merged_any=0 + +# OLDEST FIRST. `gh pr list` returns newest-first, and this loop merges the +# first eligible PR and stops — so the newest green PR wins every sweep and an +# older one can wait indefinitely. Observed in maonakamoto/fleetcrown on +# 2026-08-06: consecutive sweeps merged the two newest PRs while three older +# green ones were never even evaluated. With several agent sessions opening PRs +# continuously, "newest wins" is starvation, and it starves the PR whose checks +# were proven against the most now-stale base. +# +# PR numbers increase monotonically with creation, so sorting ascending is FIFO. +for number in $(printf '%s' "$prs_json" | jq -r 'sort_by(.number) | .[].number'); do + pr=$(printf '%s' "$prs_json" | jq -c --argjson n "$number" '.[] | select(.number == $n)') + title=$(printf '%s' "$pr" | jq -r '.title') + + # A rollup entry is either a CheckRun (status + conclusion) or a commit + # StatusContext (state) — external services report as the latter. + verdict=$(printf '%s' "$pr" | jq -r --argjson hold "$HOLD_LABELS" ' + def ok: + if has("state") then (.state == "SUCCESS") + else ((.status == "COMPLETED") + and ((.conclusion // "") | test("^(SUCCESS|NEUTRAL|SKIPPED)$"))) end; + def pending: + if has("state") then (.state == "PENDING") + else (.status != "COMPLETED") end; + + . as $pr + | (($pr.statusCheckRollup) // []) as $checks + | if $pr.isDraft then "skip: draft" + elif ([$pr.labels[]?.name] | any(. as $l | $hold | index($l) != null)) + then "skip: hold label" + elif ($checks | length) == 0 then "skip: no checks reported yet" + elif ($checks | map(pending) | any) then "skip: checks still running" + elif (($checks | map(ok) | all) | not) then "skip: checks not green" + else "merge" end + ') + + if [ "$verdict" != "merge" ]; then + echo "[auto-merge] #${number} ${verdict} — ${title}" + + # A CANCELLED check is not a verdict, it is noise: CI workflows in this + # fleet use `concurrency: cancel-in-progress`, so an unrelated newer run on + # the same ref can kill a PR's build. Nothing ever re-runs it, the PR is + # never green, and it would sit in this queue forever. Re-run it and let a + # later sweep judge the real result. Genuine failures are left alone; only a + # run with no real failure is retried. + if [ "$verdict" = "skip: checks not green" ]; then + retry_urls=$(printf '%s' "$pr" | jq -r ' + [ .statusCheckRollup[]? + | select(has("state") | not) + | select((.conclusion // "") == "CANCELLED") + | .detailsUrl ] as $cancelled + | [ .statusCheckRollup[]? + | select(((.conclusion // .state // "") + | test("^(FAILURE|TIMED_OUT|ACTION_REQUIRED|STARTUP_FAILURE|ERROR)$"))) ] as $failed + | if ($failed | length) == 0 then $cancelled[] else empty end + ') + for url in $retry_urls; do + run_id=$(printf '%s' "$url" | grep -oE '/runs/[0-9]+' | grep -oE '[0-9]+' || true) + [ -z "$run_id" ] && continue + echo "[auto-merge] #${number} re-running cancelled run ${run_id}" + gh run rerun "$run_id" --repo "$REPO" || echo "[auto-merge] #${number} could not re-run ${run_id}" >&2 + done + fi + continue + fi + + # Mergeability is computed lazily by GitHub and is invalidated every time the + # base branch moves — so right after a merge (exactly when this workflow runs) + # every PR reports UNKNOWN. Poll until GitHub has an answer instead of + # treating "not computed yet" as "not mergeable"; otherwise the fast path can + # never merge anything and the whole train falls back to the cron. + mergeable="" + state="" + for attempt in 1 2 3 4 5 6; do + fresh=$(gh pr view "$number" --repo "$REPO" --json mergeable,mergeStateStatus) + mergeable=$(printf '%s' "$fresh" | jq -r '.mergeable') + state=$(printf '%s' "$fresh" | jq -r '.mergeStateStatus') + [ "$mergeable" != "UNKNOWN" ] && break + echo "[auto-merge] #${number} mergeability not computed yet (attempt ${attempt}) — waiting" + sleep 5 + done + + if [ "$mergeable" != "MERGEABLE" ]; then + echo "[auto-merge] #${number} skip: not mergeable (${mergeable}/${state}) — ${title}" + continue + fi + + echo "[auto-merge] #${number} green and ready — merging: ${title}" + if gh pr merge "$number" --repo "$REPO" --squash --delete-branch; then + merged_any=1 + echo "[auto-merge] #${number} merged" + # One car per sweep: let CI verify this on the base before the next couples. + break + else + # Losing a race (someone merged first, or the base moved underneath) is + # normal; the next sweep re-evaluates from fresh state. + echo "[auto-merge] #${number} merge failed — leaving for the next sweep" >&2 + fi +done + +if [ "$merged_any" -eq 1 ]; then + for wf in $REARM_WORKFLOWS; do + echo "[auto-merge] re-arming ${wf} on ${BASE_BRANCH}" + gh workflow run "$wf" --repo "$REPO" --ref "$BASE_BRANCH" \ + || echo "[auto-merge] could not dispatch ${wf} — is workflow_dispatch declared?" >&2 + done +else + echo "[auto-merge] nothing merged; no re-arm needed" +fi From 77d19f214d554bfbef2707cf58e65d08b0fe241b Mon Sep 17 00:00:00 2001 From: G <41178744+maonakamoto@users.noreply.github.com> Date: Fri, 7 Aug 2026 09:28:43 +0200 Subject: [PATCH 2/2] fix(ci): add the scopes a private repo needs before this lands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR introduces auto-merge to a private repo. The version being copied omits checks:read/statuses:read, which only matters on a private repo — which is why it works in ~20 public ones and failed on every single sweep in ivy-portal, merging nothing ever. Adding them here so this repo ships from its first green PR instead of inheriting a silent no-op. --- .github/workflows/auto-merge.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/auto-merge.yml b/.github/workflows/auto-merge.yml index 7c04dd2..a480aaf 100644 --- a/.github/workflows/auto-merge.yml +++ b/.github/workflows/auto-merge.yml @@ -38,6 +38,15 @@ permissions: contents: write # merge the PR pull-requests: write # read PR state, delete the branch actions: write # dispatch the re-arm workflows + # Required because THIS repo is private. The sweep reads statusCheckRollup to + # decide whether a PR is green; a public repo answers that with no explicit + # scope, which is why the copy of this file going around the fleet omits these + # two lines and still works in ~20 public repos. On a private repo the same + # query fails with 'Resource not accessible by integration' and the sweep + # merges nothing, ever — see ivy-portal, where that went unnoticed because a + # repo that merges nothing looks like a repo with nothing to merge. + checks: read # check-run conclusions (CI jobs) + statuses: read # commit statuses (external reporters) # Never let two sweeps merge concurrently — they would race on the same PRs. concurrency: