From c73b1da3e253a2b026ddbb49b013469c3a715057 Mon Sep 17 00:00:00 2001 From: Arena Agent Date: Sun, 25 Oct 2026 08:46:07 +0000 Subject: [PATCH 1/3] ci(hygiene): measure branch drift and enforce the two pull-request rules main's remote held fifteen heads on 2026-10-25: 'main', the orphan data branch the badge publisher force-pushes, and thirteen branches cut from snapshots between 5 and 119 commits old. Every one of the thirteen had an open pull request, so a reviewer saw thirteen blocked pull requests and no way to tell which was alive, and each started a full CI run that could not go green. CONTRIBUTING.md already required a branch to start at the tip of main (rule 1) and to carry no merge commits from it (rule 2). Nothing enforced either sentence. This adds the enforcement and the measurement, and nothing that deletes anything: - scripts/maintenance/check-pr-freshness.sh answers rules 1 and 2 from the merge base and the commit graph, annotating the failure for the checks API; pr-freshness.yml runs it on every pull request. - scripts/maintenance/triage-branches.sh classes every remote head as base, unrelated, contained, fresh, salvage or stale, so cleanup is decided on measurement. branch-hygiene.yml runs it weekly into ONE rolling issue that closes itself when no drifted branch remains. - Both refuse a shallow clone instead of reporting its graft point as the merge base, which would pass every branch. - Both are tested against fixture repositories: merged, drifted with nothing new, drifted with a file the base no longer has, orphan, unrelated history and shallow, plus the Actions annotation path. - docs/maintenance/repository-hygiene.md records the classes, the cleanup order and the measurement; CONTRIBUTING.md points at it. The work already done with these scripts is on the remote rather than in this diff: the thirteen superseded pull requests are closed and their branches deleted, each with its own measured rationale, and each recoverable from refs/pull//head. governance-badges is untouched: it is an output, not a spare branch. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/branch-hygiene.yml | 101 ++++++++++ .github/workflows/hypatia-scan.yml | 4 + .github/workflows/pr-freshness.yml | 53 ++++++ .gitignore | 4 +- CONTRIBUTING.md | 29 +++ docs/maintenance/repository-hygiene.md | 190 +++++++++++++++++++ scripts/maintenance/check-pr-freshness.sh | 125 +++++++++++++ scripts/maintenance/triage-branches.sh | 215 ++++++++++++++++++++++ scripts/tests/test-check-pr-freshness.sh | 106 +++++++++++ scripts/tests/test-triage-branches.sh | 126 +++++++++++++ 10 files changed, 952 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/branch-hygiene.yml create mode 100644 .github/workflows/pr-freshness.yml create mode 100644 docs/maintenance/repository-hygiene.md create mode 100755 scripts/maintenance/check-pr-freshness.sh create mode 100755 scripts/maintenance/triage-branches.sh create mode 100755 scripts/tests/test-check-pr-freshness.sh create mode 100755 scripts/tests/test-triage-branches.sh diff --git a/.github/workflows/branch-hygiene.yml b/.github/workflows/branch-hygiene.yml new file mode 100644 index 00000000..58bf292d --- /dev/null +++ b/.github/workflows/branch-hygiene.yml @@ -0,0 +1,101 @@ +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# Weekly measurement of branch drift, reported in ONE rolling issue. +# +# On 2026-10-25 the remote held fifteen heads: `main`, a data branch the badge +# publisher force-pushes (`governance-badges`), and thirteen others that were +# either already contained in `main` or cut from a snapshot between 5 and 119 +# commits old. Every one of those thirteen had an open pull request, and every +# pull request started a full CI run. The cost was not the storage; it was that +# a genuinely fresh change could not be told apart from a fortnight-old one +# without running this comparison by hand. +# +# The reporting shape is deliberate: one issue, updated in place, closed when +# the branches are gone. A scheduled job that opens an issue every week is the +# mechanism that produced the pile it is meant to clear. +name: Branch hygiene + +on: + schedule: + # Mondays, after the Scorecard and secret-scanner slots. + - cron: '17 6 * * 1' + workflow_dispatch: + +permissions: + contents: read + issues: write + +concurrency: + group: branch-hygiene + cancel-in-progress: false + +jobs: + triage: + name: Triage remote branches + runs-on: ubuntu-24.04 + timeout-minutes: 15 + env: + ROLLING_TITLE: 'Branch hygiene: drifted and superseded branches' + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - name: Check out the full history + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Triage every remote head against main + id: triage + run: | + set -euo pipefail + bash scripts/maintenance/triage-branches.sh \ + --base main --remote origin --format markdown > "$RUNNER_TEMP/branches.md" + cat "$RUNNER_TEMP/branches.md" >> "$GITHUB_STEP_SUMMARY" + # The script's own summary lines are the count; grep the classes + # rather than re-deriving them, so there is one definition. + stale="$(grep -c '^| .* | stale |' "$RUNNER_TEMP/branches.md" || true)" + salvage="$(grep -c '^| .* | salvage |' "$RUNNER_TEMP/branches.md" || true)" + { + printf 'stale=%s\n' "${stale:-0}" + printf 'salvage=%s\n' "${salvage:-0}" + } >> "$GITHUB_OUTPUT" + + - name: Upsert the rolling issue + env: + STALE: ${{ steps.triage.outputs.stale }} + SALVAGE: ${{ steps.triage.outputs.salvage }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + REPO_URL: ${{ github.server_url }}/${{ github.repository }} + run: | + set -euo pipefail + marker='' + existing="$(gh issue list --state open --limit 100 --json number,title \ + --jq ".[] | select(.title == \"$ROLLING_TITLE\") | .number" | head -1)" + + if [ "${STALE:-0}" -eq 0 ] && [ "${SALVAGE:-0}" -eq 0 ]; then + if [ -n "$existing" ]; then + gh issue comment "$existing" --body "No drifted or superseded branch was found on $(date -u +%Y-%m-%d). Closing." + gh issue close "$existing" --reason completed + fi + echo 'No drifted branches; nothing to report.' + exit 0 + fi + + { + printf '%s\n' "$marker" + printf 'Branches on `origin` measured against `main` on %s.\n\n' "$(date -u +%Y-%m-%d)" + printf 'Classes are defined in [docs/maintenance/repository-hygiene.md](%s/blob/main/docs/maintenance/repository-hygiene.md) and produced by `scripts/maintenance/triage-branches.sh`.\n\n' "$REPO_URL" + cat "$RUNNER_TEMP/branches.md" + printf '\nGenerated by [branch hygiene run](%s). This issue is updated in place and closes itself when the branches are gone.\n' "$RUN_URL" + } > "$RUNNER_TEMP/issue.md" + + gh label create hygiene --force \ + --description 'Repository maintenance work' --color ededed >/dev/null 2>&1 || true + + if [ -n "$existing" ]; then + gh issue edit "$existing" --body-file "$RUNNER_TEMP/issue.md" + echo "Updated issue #$existing" + else + gh issue create --title "$ROLLING_TITLE" --body-file "$RUNNER_TEMP/issue.md" --label hygiene + fi diff --git a/.github/workflows/hypatia-scan.yml b/.github/workflows/hypatia-scan.yml index 5e841ce8..b307aa35 100644 --- a/.github/workflows/hypatia-scan.yml +++ b/.github/workflows/hypatia-scan.yml @@ -45,6 +45,10 @@ jobs: bash scripts/tests/test-ci-logging.sh bash scripts/tests/test-k9-log-copy.sh bash scripts/tests/test-scanner-boundaries.sh + # Branch triage and the pull-request freshness rule: the scripts the + # hygiene workflows run, tested where every other shell test runs. + bash scripts/tests/test-triage-branches.sh + bash scripts/tests/test-check-pr-freshness.sh deed: name: DEED syntax diff --git a/.github/workflows/pr-freshness.yml b/.github/workflows/pr-freshness.yml new file mode 100644 index 00000000..0f80fd62 --- /dev/null +++ b/.github/workflows/pr-freshness.yml @@ -0,0 +1,53 @@ +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# CONTRIBUTING.md states two rules a pull request is checked against before +# its diff is read: the branch starts at the tip of `main`, and it carries no +# merge commits from `main`. Nothing enforced them, so drifted branches +# accumulated — ten of them by 2026-10-25, cut from snapshots up to 119 commits +# old, each conflicting, each holding a pull request, each starting a full CI +# run that could never go green. +# +# This check turns those two sentences into a result that arrives in a minute, +# on the pull request, from a script that also runs at the desk: +# +# scripts/maintenance/check-pr-freshness.sh +# +# It is intentionally not a fixer. Rebasing is the author's decision, because +# only the author knows which commits on a drifted branch still apply. +name: PR freshness + +on: + pull_request: + # `opened` for the first push, `synchronize` for each new push, and + # `reopened` because a reopened branch may have drifted while it was + # closed. `edited` is absent: editing a description cannot change history. + types: [opened, synchronize, reopened, ready_for_review] + +permissions: + contents: read + +concurrency: + # One result per pull request; a superseded run tells nobody anything. + group: pr-freshness-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + freshness: + name: Branch freshness + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Check out the pull request's history + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # Full history: the check needs the real merge base, and a shallow + # clone would report its graft point as the base and pass everything. + fetch-depth: 0 + persist-credentials: false + + - name: Check the branch starts at the base tip and replays linearly + env: + # The pull request's base SHA is immutable; a branch name is not. + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: bash scripts/maintenance/check-pr-freshness.sh --base "$BASE_SHA" --head HEAD diff --git a/.gitignore b/.gitignore index 7df2318b..1794badb 100644 --- a/.gitignore +++ b/.gitignore @@ -247,11 +247,13 @@ vignettes/*.pdf # R Environment Variables .Renviron -## Documentation kept local except published release, cladistics, format, statistics, and schema notes +## Documentation kept local except published release, cladistics, format, maintenance, +## statistics, and schema notes docs/* !docs/release-notes/ !docs/cladistics/ !docs/format-migration.md +!docs/maintenance/ !docs/statistics/ !docs/schemas/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a21e6413..66389b31 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,6 +48,35 @@ Each requirement is checked before the code is read. 7. **Once review has started, add commits,** and rebase only when asked to. Squashing happens on merge. +## Branches and retention + +The two branch rules above are checked, not merely stated: +`scripts/maintenance/check-pr-freshness.sh` runs on every pull request through +`.github/workflows/pr-freshness.yml` and reports how far behind the branch is, +or which merge commit breaks rule 2. Run it yourself before pushing: + +``` +git fetch origin +bash scripts/maintenance/check-pr-freshness.sh --base origin/main --head HEAD +``` + +What happens to a branch once it is no longer needed: + +- **Merged into `main`** — delete it. The commits are in `main`; a kept branch + is a second answer to the same question. +- **Closed unmerged** — the pull request keeps the head reachable, so the branch + is deleted and the content is not lost: `git fetch origin pull//head`. +- **`archive/`** — a deliberate snapshot that is not proposed for merging. It + carries no pull request; opening one asks `main` to become older. +- **`governance-badges`** — not a working branch. The `Publish badges` job + force-pushes shields.io endpoint JSON to it on every push to `main`, and + `README.md` reads it through `img.shields.io/endpoint`. Never delete it. + +Drifted and superseded branches are measured weekly by +`.github/workflows/branch-hygiene.yml`, which updates one rolling issue; the +classes it reports, what each one means and how to recover closed content are in +[docs/maintenance/repository-hygiene.md](docs/maintenance/repository-hygiene.md). + ## Licence and copyright Source code is licensed under the AGPL-3.0 (`LICENSE`). `README.md` is licensed diff --git a/docs/maintenance/repository-hygiene.md b/docs/maintenance/repository-hygiene.md new file mode 100644 index 00000000..cb0c1666 --- /dev/null +++ b/docs/maintenance/repository-hygiene.md @@ -0,0 +1,190 @@ + + +# Repository hygiene: branches, pull requests and CI cost + +This document records how branches are classed, what happens to each class, and +the measurement taken on 2026-10-25 with the actions that followed. It exists +because the repository reached a state where the cost of a stale branch was not +its storage but its silence: thirteen branches shared one list, each with an +open pull request, and none of them could be told apart from a fresh change +without doing the comparison by hand. + +The rule the repository already had is in [CONTRIBUTING.md](../../CONTRIBUTING.md): +a branch starts at the tip of `main` (rule 1) and carries no merge commits from +it (rule 2). Nothing enforced either sentence. This document describes the +enforcement added on 2026-10-25 and the cleanup it made possible. + +## Classes + +`scripts/maintenance/triage-branches.sh` measures every remote head against the +base branch and prints one class per branch. It never writes to the remote: the +reading is cheap and repeatable, the decision is not automatic. + +| Class | Meaning | What happens to it | +| --- | --- | --- | +| `base` | The base branch itself. | Nothing. | +| `unrelated` | No common ancestor with the base. In this repository that is `governance-badges` only, the orphan branch the `Publish badges` job force-pushes on every push to `main`. | Never delete. It holds shields.io endpoint JSON read through `img.shields.io/endpoint`; it has no pull request and never will. | +| `contained` | Every commit is already an ancestor of the base (`ahead` is 0). | The pull request is closed and the branch deleted. There is nothing to merge; the displayed diff is the base moving ahead. Any file it still "has" that the base does not was deliberately deleted from the base later. | +| `fresh` | Merge base is the base tip. It satisfies rule 1. | Eligible for review. Rule 2 is checked separately by `check-pr-freshness.sh`, which fails a `fresh` branch that carries a merge commit. | +| `salvage` | Behind the base, but it carries at least one file the base does not have. | Do **not** merge it: it would revert later work on every file it also touches. Its unique files are re-applied on the current tip, or the branch is deliberately abandoned. Closing the pull request preserves the content at `refs/pull//head`; see [Recovery](#recovery). | +| `stale` | Behind the base, and every file it touches also exists in the base. | Closed and deleted. Merging it would revert the commits that superseded it. | + +A shallow clone cannot answer any of this: it reports its graft point as the +merge base, which makes every branch look `fresh`. Both scripts refuse to run in +a shallow clone (`git fetch --unshallow`, or `fetch-depth: 0` in CI) rather than +print a number they cannot stand behind. + +## Enforced, not just written down + +| Where | What it does | +| --- | --- | +| `.github/workflows/pr-freshness.yml` | Runs `scripts/maintenance/check-pr-freshness.sh` on every pull request open, push, reopen and ready-for-review. Fails with a check annotation naming how far behind the branch is, or which merge commit breaks rule 2. | +| `.github/workflows/branch-hygiene.yml` | Weekly, and on demand: runs the triage and updates **one** rolling issue, closing it when no drifted branch remains. One issue updated in place, because a job that opens an issue every week is the mechanism that produced the pile it is meant to clear. | +| `scripts/tests/test-triage-branches.sh`, `scripts/tests/test-check-pr-freshness.sh` | Both scripts are tested against fixture repositories: merged, drifted-with-nothing-new, drifted-with-a-dropped-file, orphan, unrelated-history and shallow cases. They run with the other shell tests in the `Advisory scan contract` job. | + +The pull-request check is advisory by configuration, not by intent: GitHub only +blocks a merge on a check named in a ruleset. Adding **Branch freshness** to the +`Branch-Floor` ruleset is an owner action (Settings → Rules → Branch-Floor), and +it can only be done *after* this workflow is on `main` — a required check that no +workflow produces blocks every pull request, including the one that would add it. + +## Recovery + +Deleting a branch does not delete its pull request, and a closed pull request +keeps its head reachable: + +``` +git fetch origin pull//head:review/pr- +git log --oneline main..review/pr- +``` + +That is the difference between cleaning up and losing work, and it is why the +2026-10-25 cleanup closed pull requests before deleting the branches instead of +deleting the branches and hoping. + +## Measurement, 2026-10-25 + +`main` at `f3816e5a`. Fifteen remote heads, thirteen of them with an open pull +request. Every one of the thirteen violated CONTRIBUTING rule 1; one also +violated rule 2; one was a second copy of a fix already on `main`. + +| Branch | Class | Ahead | Behind | Files only here | Pull request | Action | +| --- | --- | ---: | ---: | ---: | --- | --- | +| `arena/00d742e3-metamanifold-webui` | contained | 0 | 35 | 0 | #283 | closed, deleted | +| `archive/pre-sync-2026-10-17` | contained | 0 | 70 | 13 | #281 | closed, deleted | +| `work/glmgampoi-r-shrinkage` | stale | 41 | 119 | 0 | #284 | closed, deleted | +| `arena/b38170ca-metamanifold-webui` | stale | 43 | 119 | 0 | #280 | closed, deleted | +| `arena/bb2c7f54-metamanifold-webui` | stale | 43 | 119 | 0 | #279 | closed, deleted | +| `arena/1cf75265-metamanifold-webui` | stale | 4 | 7 | 0 | #278 | closed, deleted | +| `arena/57ce87e1-metamanifold-webui` | stale | 9 | 5 | 0 | #274 | closed, deleted | +| `arena/2fe2daad-metamanifold-webui` | fresh, 1 merge commit | 2 | 0 | 0 | #277 | closed, deleted | +| `arena/3bcf0318-metamanifold-webui` | salvage | 2 | 45 | 11 | #276 | closed, deleted | +| `prepare/lwr-63-lean` | salvage | 43 | 119 | 2 | #272 | closed, deleted | +| `upstream/placement-evidence` | salvage | 44 | 119 | 2 | #270 | closed, deleted | +| `wip/pr62-theta` | salvage | 45 | 119 | 1 | #269 | closed, deleted | +| `review/evidence-27-contract` | salvage | 42 | 119 | 2 | #271 | closed, deleted | +| `governance-badges` | unrelated | – | – | 5 | none | kept, by design | +| `main` | base | – | – | – | – | – | + +### What the four `salvage` branches actually hold + +Nothing on them is in `main`, and nothing on them is worth a merge either — the +branches are built on `257bcdec`, 119 commits before the current tip, so any +merge would revert the files that changed in between. The content is preserved +at its pull-request ref and worth a second look only if its feature is wanted: + +- `arena/3bcf0318` — the Full Evidence Mode surface (`frontend/src/evidence/*`, + `EvidenceModeToggle.tsx`, 11 files). Issue #7 was closed as parked: the + absorption register excludes `frontend/src/**` from the offer set until the + replacement work is ready. Parked, not lost: `git fetch origin pull/276/head`. +- `prepare/lwr-63-lean`, `upstream/placement-evidence` — `ComputingNote.tsx` and + `placementLwr.test.ts`, a component and a threshold test for the placement-LWR + display whose feature is already in `main`. If the test is wanted, it needs + re-writing against the current `frontend/src/tree/model.ts`, not cherry-picking. +- `wip/pr62-theta` — `ComputingNote.tsx` alone; its theta reporting is in + `main`. +- `review/evidence-27-contract` — `docs/reviews/evidence-27-contract.md` and a + probe script, reviewing a state of the code that has since been replaced. + +## The CI cost this created + +Thirteen open pull requests were not only untidy, they were expensive, and the +expense is what made the situation stable rather than self-correcting: + +- Every pull request triggers the full `CI` suite: `Julia tests` (about 30–45 + minutes), `Statistics standards and evidence` (up to 60), `Agda proofs`, + `Occupancy typed peak`, `Occupancy disagreement test`, plus `Governance (fork)`, + `DOI`, `Format readiness evidence` and `Secret Scanner`. `Code relevance` + skips the expensive jobs only for a diff that touches nothing the suite + exercises; all thirteen branches touched `src/`. +- Six of those jobs are required contexts in the `Branch-Floor` ruleset. A + branch that cannot pass them reports `BLOCKED` indefinitely, so a reviewer + sees thirteen blocked pull requests and no signal about which of them is + alive. +- On `main`, `concurrency` is deliberately *not* cancel-in-progress for pushes, + so that "each merge gets a test result". Two pushes forty seconds apart + therefore serialise: at the time of this measurement the run for `a3a4a8e` was + still in progress and the run for `f3816e5a` had not started, 25 minutes later. + The queue is correct by design; the pile-up it produces is the design's cost, + and it is worth a decision (see below). + +Two red checks on those pull requests were not caused by their content: + +- **SonarCloud Code Analysis** failed on most of them with `C Security Rating on + New Code`. It is not a required context; it is documented in + [docs/sonar-quality-gate.md](../sonar-quality-gate.md) and reproduced locally + by `scripts/lint-sonar-reliability.sh`. It analyses "new code" as the changed + lines, and a branch that appears to change 40,000 lines gets a large surface. +- **Hypatia** failed with "9 new alerts including 5 errors" on the largest ones, + for the same reason: an enormous, entirely stale diff is a large scan. + +Both are symptoms of the same root cause and cleared with the branches. + +## Order of work, and why that order + +The remediation was done in this order because each step depended on the one +before it: + +1. **Measure.** `triage-branches.sh` first, on a full clone. Every later + decision is a table row, not an impression. +2. **Enforce the rule that already existed.** A check that never existed is a + wish. `pr-freshness.yml` + `check-pr-freshness.sh` land first so that no new + drifted branch can be created while the old ones are being closed. +3. **Close the pull requests, then delete the branches.** In that order, because + the pull request is what keeps the head reachable. +4. **Leave the data branch alone.** `governance-badges` is not a spare branch; + it is an output. A triage that cannot tell the two apart deletes a badge + pipeline. +5. **Make the measurement recurring, in one issue.** `branch-hygiene.yml` + updates a single rolling issue and closes it when clean. +6. **Only then touch the ruleset.** Making **Branch freshness** required is + safe once the workflow is on `main`, and it cannot be done before then. + +## Decisions for the owner + +These are not cleanup, they are choices with trade-offs; the reasoning is +recorded here so the choice does not have to be rediscovered. + +1. **Make `Branch freshness` a required context.** Add it to the `Branch-Floor` + ruleset after this lands. Until then the check reports but does not block. +2. **Push-run concurrency on `main`.** `cancel-in-progress: false` for pushes + gives every merge its own result and serialises the queue. Setting it to + true would let the newest commit cancel an older validation; the newest tree + contains the older one, so the loss is a per-commit result rather than + coverage. Either answer is defensible; the current one is not wrong, only + slow. +3. **`governance-badges` never gets a pull request.** It is an orphan branch of + generated JSON, force-pushed by the `Publish badges` job. Excluding it from + branch listings (a `--exclude`-style pattern in whatever lists branches) or + publishing the JSON as a release asset would remove it from every human's + branch list without touching the pipeline. Not done here because it changes + where the badges are read from, and the `img.shields.io/endpoint` URLs in + `README.md` point at that branch. +4. **`docs/milestones/02-deferred-issues.md` is referenced by issue #4 and does + not exist on `main`.** The pre-sync archive carried it; the sync dropped it. + Either restore it or edit the issue's reference. Not done here because the + content overlaps `docs/statistics/method-catalogue-v1.md` and the owner may + prefer the issue to point at that instead. diff --git a/scripts/maintenance/check-pr-freshness.sh b/scripts/maintenance/check-pr-freshness.sh new file mode 100755 index 00000000..b885fff7 --- /dev/null +++ b/scripts/maintenance/check-pr-freshness.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-only +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# Enforce the first two pull-request rules in CONTRIBUTING.md, before a +# reviewer spends time on the diff: +# +# 1. The merge base of the branch and the base branch is the base tip +# (`behind_by` is 0). A branch cut from an older snapshot is rebased onto +# the current base, not merged forward. +# 2. The branch carries no merge commit from the base branch and no +# conflict-resolution merge. Squashing happens on merge. +# +# Both rules exist because a stacked or drifted branch does not get reviewed +# twice; it gets merged by accident or closed after weeks of conflicts. Each +# violating branch also costs a full CI run to discover, so the check is cheap, +# local and identical in CI and at the desk: +# +# scripts/maintenance/check-pr-freshness.sh # HEAD vs origin/main +# scripts/maintenance/check-pr-freshness.sh --base origin/main --head HEAD +# +# Under GitHub Actions it reads the pull request's base SHA from BASE_SHA and +# writes annotations, so a failure is readable from the checks API rather than +# only from the log. +# +# Exit status: 0 clean, 1 rule violation, 2 the question cannot be answered +# (shallow clone, unknown ref) - never a silent pass. +set -euo pipefail + +base_ref=origin/main +head_ref=HEAD + +usage() { + sed -n '5,28p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' +} + +while [ $# -gt 0 ]; do + case "$1" in + --base) base_ref="${2:?--base needs a value}"; shift 2 ;; + --head) head_ref="${2:?--head needs a value}"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) printf 'check-pr-freshness: unknown argument: %s\n' "$1" >&2; exit 2 ;; + esac +done + +in_actions=false +if [ "${GITHUB_ACTIONS:-}" = "true" ]; then + in_actions=true +fi + +# The base SHA of a pull request is immutable and is what the merge-base test +# has to be measured against; a branch name moves under a running job. +if [ -n "${BASE_SHA:-}" ]; then + base_ref="$BASE_SHA" +fi +if [ -n "${HEAD_SHA:-}" ]; then + head_ref="$HEAD_SHA" +fi + +annotate() { # level message + if [ "$in_actions" = true ]; then + printf '::%s title=Branch freshness::%s\n' "$1" "$2" + fi +} + +die_cannot_check() { + printf 'check-pr-freshness: %s\n' "$1" >&2 + annotate error "cannot evaluate branch freshness: $1" + exit 2 +} + +git rev-parse --git-dir >/dev/null 2>&1 || die_cannot_check "not inside a git repository" + +# A shallow clone has no merge base to speak of: it would report the graft +# point as the base and pass every branch. Refuse instead of passing. +if [ "$(git rev-parse --is-shallow-repository)" = true ]; then + die_cannot_check "shallow clone; check out with fetch-depth: 0 (or run git fetch --unshallow)" +fi + +git rev-parse --verify --quiet "$base_ref^{commit}" >/dev/null \ + || die_cannot_check "base ref $base_ref does not exist" +git rev-parse --verify --quiet "$head_ref^{commit}" >/dev/null \ + || die_cannot_check "head ref $head_ref does not exist" + +base_tip="$(git rev-parse "$base_ref^{commit}")" +head_tip="$(git rev-parse "$head_ref^{commit}")" +short_base="${base_tip:0:8}" +short_head="${head_tip:0:8}" + +merge_base="$(git merge-base "$base_tip" "$head_tip" || true)" +if [ -z "$merge_base" ]; then + printf 'check-pr-freshness: %s shares no history with %s\n' "$short_head" "$short_base" >&2 + annotate error "the branch shares no history with the base; it cannot be merged without replaying its commits" + exit 1 +fi + +failures=0 +behind="$(git rev-list --count "$head_tip..$base_tip")" + +if [ "$merge_base" != "$base_tip" ]; then + failures=$((failures + 1)) + printf 'check-pr-freshness: branch is %s commit(s) behind %s (merge base %s)\n' \ + "$behind" "$short_base" "${merge_base:0:8}" >&2 + annotate error "branch is $behind commit(s) behind the base ($short_base); rebase onto the base instead of merging it forward (CONTRIBUTING.md rule 1)" +else + printf 'ok - branch starts at the base tip %s\n' "$short_base" +fi + +merge_commits="$(git rev-list --merges --count "$merge_base..$head_tip" || true)" +if [ "${merge_commits:-0}" -gt 0 ]; then + failures=$((failures + 1)) + printf 'check-pr-freshness: %s merge commit(s) in the branch:\n' "$merge_commits" >&2 + git rev-list --merges --format=' %h %s' "$merge_base..$head_tip" | grep -v '^commit ' >&2 || true + annotate error "$merge_commits merge commit(s) in the branch; rebase so the branch replays linearly (CONTRIBUTING.md rule 2)" +else + printf 'ok - no merge commits in the branch\n' +fi + +if [ "$failures" -gt 0 ]; then + printf 'check-pr-freshness: %d rule violation(s)\n' "$failures" >&2 + exit 1 +fi + +printf 'check-pr-freshness: OK\n' +exit 0 diff --git a/scripts/maintenance/triage-branches.sh b/scripts/maintenance/triage-branches.sh new file mode 100755 index 00000000..e714c25a --- /dev/null +++ b/scripts/maintenance/triage-branches.sh @@ -0,0 +1,215 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-only +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# Read-only triage of the remote's branches against the base branch. +# +# The repository's rule is narrow: a pull request's branch must start at the +# tip of `main` (CONTRIBUTING.md, "Branch from the current `main`"), and its +# commits must contain no merge from `main`. When that rule is not enforced, a +# cron job or an agent can leave dozens of branches cut from an old snapshot; +# each one then conflicts, each one opens a pull request, and each pull request +# starts a full CI run. This script is the measurement, not the enforcement: +# it says of every branch which of five things it is, so cleanup is a decision +# made on evidence rather than on a guess about the branch name. +# +# Classes, and what each one means for cleanup: +# +# base the base branch itself. +# unrelated no common ancestor with the base. Expected for the orphan +# data branch `governance-badges`, which the "Publish badges" +# job force-pushes on every push to `main`; it holds shields.io +# endpoint JSON and is never merged. Do not delete it. +# contained every commit is already in the base (`ahead == 0`). The branch +# has nothing to merge and its pull request can be closed. +# fresh merge base is the base tip, so it satisfies CONTRIBUTING rule 1. +# salvage behind the base, but it carries files the base does not have. +# Closing it would lose work, so it has to be re-applied on top of +# the current base or deliberately abandoned first. +# stale behind the base and every file it carries also exists in the +# base. The content was superseded by later commits; merging it +# would revert them. +# +# A shallow clone cannot answer any of this: `--is-shallow-repository` is +# checked and the script refuses to print numbers it cannot stand behind. +# +# Usage: +# scripts/maintenance/triage-branches.sh # table on stdout +# scripts/maintenance/triage-branches.sh --format markdown # for an issue body +# scripts/maintenance/triage-branches.sh --format json +# scripts/maintenance/triage-branches.sh --fail-on stale +# +# Exit status: 0 on success, 2 if triage cannot be computed (shallow clone, +# missing refs), 3 if --fail-on matched at least one branch. +set -euo pipefail + +base=main +remote=origin +format=table +fail_on= + +usage() { + sed -n '5,45p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' +} + +while [ $# -gt 0 ]; do + case "$1" in + --base) base="${2:?--base needs a value}"; shift 2 ;; + --remote) remote="${2:?--remote needs a value}"; shift 2 ;; + --format) format="${2:?--format needs a value}"; shift 2 ;; + --fail-on) fail_on="${2:?--fail-on needs a value}"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) printf 'triage-branches: unknown argument: %s\n' "$1" >&2; exit 2 ;; + esac +done + +case "$format" in + table|markdown|json) ;; + *) printf 'triage-branches: --format must be table, markdown or json\n' >&2; exit 2 ;; +esac +case "$fail_on" in + ''|stale|salvage|any) ;; + *) printf 'triage-branches: --fail-on must be stale, salvage or any\n' >&2; exit 2 ;; +esac + +if ! git rev-parse --git-dir >/dev/null 2>&1; then + printf 'triage-branches: not inside a git repository\n' >&2 + exit 2 +fi + +# A shallow clone reports `ahead`/`behind` against an arbitrary graft point, +# so the numbers would be plausible and wrong. Refusing is the only honest +# answer; `git fetch --unshallow` (or fetch-depth: 0 in CI) fixes it. +if [ "$(git rev-parse --is-shallow-repository)" = true ]; then + printf 'triage-branches: this is a shallow clone; run "git fetch --unshallow" first\n' >&2 + exit 2 +fi + +base_ref="refs/remotes/$remote/$base" +if ! git rev-parse --verify --quiet "$base_ref^{commit}" >/dev/null; then + printf 'triage-branches: %s does not exist; fetch %s first\n' "$base_ref" "$remote" >&2 + exit 2 +fi + +# Refuse to report on a stale view: the base tip is what "behind" is measured +# against, and a triage run against last week's cache is worse than none. +base_tip="$(git rev-parse "$base_ref^{commit}")" + +rows=() +count_base=0 +count_unrelated=0 +count_contained=0 +count_fresh=0 +count_salvage=0 +count_stale=0 +matched_fail_on=0 + +while IFS= read -r ref; do + [ -n "$ref" ] || continue + branch="${ref#"refs/remotes/$remote/"}" + [ "$branch" = "HEAD" ] && continue + git rev-parse --verify --quiet "$ref^{commit}" >/dev/null || continue + + class= + ahead=0 + behind=0 + merges=0 + unique=0 + merge_base= + + if [ "$branch" = "$base" ]; then + class=base + count_base=$((count_base + 1)) + elif ! merge_base="$(git merge-base "$base_ref" "$ref" 2>/dev/null)"; then + class=unrelated + count_unrelated=$((count_unrelated + 1)) + else + ahead="$(git rev-list --count "$base_ref..$ref")" + behind="$(git rev-list --count "$ref..$base_ref")" + merges="$(git rev-list --merges --count "$base_ref..$ref")" + unique="$(git diff --diff-filter=A --name-only "$base_ref" "$ref" | wc -l | tr -d ' ')" + if [ "$ahead" -eq 0 ]; then + class=contained + count_contained=$((count_contained + 1)) + elif [ "$merge_base" = "$base_tip" ]; then + class=fresh + count_fresh=$((count_fresh + 1)) + elif [ "$unique" -gt 0 ]; then + class=salvage + count_salvage=$((count_salvage + 1)) + else + class=stale + count_stale=$((count_stale + 1)) + fi + fi + + case "$fail_on" in + '' ) ;; + any) matched_fail_on=$((matched_fail_on + 1)) ;; + stale) [ "$class" = stale ] && matched_fail_on=$((matched_fail_on + 1)) ;; + salvage) [ "$class" = salvage ] && matched_fail_on=$((matched_fail_on + 1)) ;; + esac + + tip_date="$(git log -1 --format=%cs "$ref")" + tip_subject="$(git log -1 --format=%s "$ref" | cut -c1-60)" + rows+=("$branch|$class|$ahead|$behind|$unique|$merges|$tip_date|$tip_subject") +done < <(git for-each-ref --format='%(refname)' "refs/remotes/$remote" | LC_ALL=C sort) + +json_escape() { + local s="$1" + s="${s//\\/\\\\}" + s="${s//\"/\\\"}" + printf '%s' "$s" +} + +case "$format" in + json) + printf '{"base":"%s","base_tip":"%s","remote":"%s","branches":[' \ + "$(json_escape "$base")" "$base_tip" "$(json_escape "$remote")" + first=1 + for row in "${rows[@]}"; do + IFS='|' read -r branch class ahead behind unique merges tip_date tip_subject <<<"$row" + [ "$first" -eq 1 ] || printf ',' + first=0 + printf '{"branch":"%s","class":"%s","ahead":%s,"behind":%s,"unique_files":%s,"merge_commits":%s,"tip_date":"%s","tip_subject":"%s"}' \ + "$(json_escape "$branch")" "$class" "$ahead" "$behind" "$unique" "$merges" \ + "$tip_date" "$(json_escape "$tip_subject")" + done + printf ']}\n' + ;; + markdown) + printf '| Branch | Class | Ahead | Behind | Files only here | Merge commits | Tip |\n' + printf '| --- | --- | ---: | ---: | ---: | ---: | --- |\n' + for row in "${rows[@]}"; do + IFS='|' read -r branch class ahead behind unique merges tip_date tip_subject <<<"$row" + printf '| `%s` | %s | %s | %s | %s | %s | %s %s |\n' \ + "$branch" "$class" "$ahead" "$behind" "$unique" "$merges" "$tip_date" "$tip_subject" + done + printf '\n' + printf '%s\n' \ + "- base: $count_base" \ + "- unrelated (never merged, expected for the badge data branch): $count_unrelated" \ + "- contained (nothing to merge): $count_contained" \ + "- fresh (satisfies CONTRIBUTING rule 1): $count_fresh" \ + "- salvage (holds files the base lacks): $count_salvage" \ + "- stale (superseded by later commits): $count_stale" + ;; + table) + printf '%-46s %-10s %6s %7s %6s %6s %s\n' \ + BRANCH CLASS AHEAD BEHIND ONLY-HERE MERGES TIP + for row in "${rows[@]}"; do + IFS='|' read -r branch class ahead behind unique merges tip_date tip_subject <<<"$row" + printf '%-46s %-10s %6s %7s %6s %6s %s %s\n' \ + "$branch" "$class" "$ahead" "$behind" "$unique" "$merges" "$tip_date" "$tip_subject" + done + printf '\n%d branch(es): %d contained, %d stale, %d salvage, %d fresh, %d unrelated\n' \ + "$((count_base + count_contained + count_stale + count_salvage + count_fresh + count_unrelated))" \ + "$count_contained" "$count_stale" "$count_salvage" "$count_fresh" "$count_unrelated" + ;; +esac + +if [ -n "$fail_on" ] && [ "$matched_fail_on" -gt 0 ]; then + printf 'triage-branches: %d branch(es) matched --fail-on %s\n' "$matched_fail_on" "$fail_on" >&2 + exit 3 +fi +exit 0 diff --git a/scripts/tests/test-check-pr-freshness.sh b/scripts/tests/test-check-pr-freshness.sh new file mode 100755 index 00000000..84182230 --- /dev/null +++ b/scripts/tests/test-check-pr-freshness.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Tests for scripts/maintenance/check-pr-freshness.sh: the two CONTRIBUTING +# rules it enforces, plus the cases where it must refuse to answer rather than +# pass a branch it cannot judge. +set -euo pipefail +ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" +SCRIPT="$ROOT/scripts/maintenance/check-pr-freshness.sh" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +export GIT_CONFIG_NOSYSTEM=1 +export GIT_AUTHOR_NAME=test GIT_AUTHOR_EMAIL=test@example.invalid +export GIT_COMMITTER_NAME=test GIT_COMMITTER_EMAIL=test@example.invalid + +pass=0 +fail=0 +ok() { pass=$((pass + 1)); printf 'ok %d - %s\n' "$pass" "$1"; } +no() { fail=$((fail + 1)); printf 'not ok - %s\n' "$1"; } +check_contains() { # haystack needle description + if printf '%s' "$1" | grep -Fq -- "$2"; then ok "$3"; else no "$3 (missing: $2)"; fi +} +run() { # expected-status description -- args... + local want="$1" desc="$2" + shift 2 + set +e + out="$(cd "$work/repo" && bash "$SCRIPT" "$@" 2>&1)" + local status=$? + set -e + if [ "$status" -eq "$want" ]; then ok "$desc"; else no "$desc (exit $status, wanted $want): $out"; fi +} + +git init -q --bare "$work/origin.git" +git init -q "$work/repo" +cd "$work/repo" +git symbolic-ref HEAD refs/heads/main +printf 'a\n' > a.txt +git add a.txt +git commit -qm 'add a.txt' +git remote add origin "$work/origin.git" +git push -q origin main +git -C "$work/origin.git" symbolic-ref HEAD refs/heads/main +git fetch -q origin + +# clean: new branch at the base tip +git checkout -qb clean +printf 'b\n' > b.txt +git add b.txt +git commit -qm 'add b.txt' +run 0 'a branch at the base tip passes' --base origin/main + +# drifted: one commit behind the base, no merge commit +git checkout -q main +printf 'c\n' > c.txt +git add c.txt +git commit -qm 'advance main' +git push -q origin main +git fetch -q origin +run 1 'a branch behind the base fails' --base origin/main --head clean +check_contains "$out" 'commit(s) behind' 'the failure names how far behind the branch is' + +# merge commit: the branch is at the base tip but carries a merge from it, +# which is the shape CONTRIBUTING rule 2 forbids even though rule 1 is satisfied +git checkout -q -b merges main +printf 'd\n' > d.txt +git add d.txt +git commit -qm 'add d.txt' +git checkout -q main +printf 'e\n' > e.txt +git add e.txt +git commit -qm 'advance main again' +git checkout -q merges +git merge -q --no-ff -m "Merge branch 'main' into merges" main +run 1 'a merge commit in the branch fails even at the base tip' --base origin/main --head merges +check_contains "$out" 'merge commit' 'the failure names the merge commit rule' + +# unrelated history: an orphan branch cannot be merged, so it cannot pass +git checkout -q --orphan orphan +git rm -rqf . +printf 'x\n' > x.txt +git add x.txt +git commit -qm 'orphan root' +run 1 'an unrelated history fails' --base origin/main --head orphan + +# a missing ref is a question that cannot be answered, not a pass +run 2 'an unknown head ref exits 2' --base origin/main --head nosuchbranch + +# Actions annotations are emitted when GitHub asks for them +set +e +out="$(cd "$work/repo" && GITHUB_ACTIONS=true bash "$SCRIPT" --base origin/main --head clean 2>&1)" +status=$? +set -e +if [ "$status" -eq 1 ]; then ok 'the check still fails under Actions'; else no "Actions run exited $status"; fi +check_contains "$out" '::error title=Branch freshness::' 'a failure is written as a check annotation' + +# A shallow clone would report its graft point as the merge base. +git clone -q --depth 1 --branch main "file://$work/origin.git" "$work/shallow" +set +e +shallow_out="$(cd "$work/shallow" && bash "$SCRIPT" --base origin/main --head origin/main 2>&1)" +shallow_status=$? +set -e +if [ "$shallow_status" -eq 2 ]; then ok 'a shallow clone is refused with exit 2'; else no "shallow clone exited $shallow_status: $shallow_out"; fi +check_contains "$shallow_out" 'shallow clone' 'the refusal names the cause' + +printf '\n%d passed, %d failed\n' "$pass" "$fail" +[ "$fail" -eq 0 ] diff --git a/scripts/tests/test-triage-branches.sh b/scripts/tests/test-triage-branches.sh new file mode 100755 index 00000000..5f2eaae6 --- /dev/null +++ b/scripts/tests/test-triage-branches.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Tests for scripts/maintenance/triage-branches.sh, including the cases a +# real repository produces: a merged branch, a drifted branch that adds +# nothing, a drifted branch that carries a file the base dropped, an orphan +# data branch, and the shallow clone where no answer is possible. +set -euo pipefail +ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" +SCRIPT="$ROOT/scripts/maintenance/triage-branches.sh" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +export GIT_CONFIG_NOSYSTEM=1 +export GIT_AUTHOR_NAME=test GIT_AUTHOR_EMAIL=test@example.invalid +export GIT_COMMITTER_NAME=test GIT_COMMITTER_EMAIL=test@example.invalid + +pass=0 +fail=0 +ok() { pass=$((pass + 1)); printf 'ok %d - %s\n' "$pass" "$1"; } +no() { fail=$((fail + 1)); printf 'not ok - %s\n' "$1"; } +check_contains() { # haystack needle description + if printf '%s' "$1" | grep -Fq -- "$2"; then ok "$3"; else no "$3 (missing: $2)"; fi +} + +git init -q --bare "$work/origin.git" +git init -q "$work/repo" +cd "$work/repo" +git symbolic-ref HEAD refs/heads/main +printf 'a\n' > base.txt +git add base.txt +git commit -qm 'base: add base.txt' +printf 'b\n' > dropped.txt +git add dropped.txt +git commit -qm 'base: add dropped.txt' +git branch -m main + +# salvage: branched before the base moved, adds a file the base never has +git checkout -q main~1 +git checkout -qb salvage +printf 's\n' > salvage.txt +git add salvage.txt +git commit -qm 'salvage: add salvage.txt' + +# stale: branched before the base moved, only touches a file the base has +git checkout -q main~1 +git checkout -qb stale +printf 'a changed on the branch\n' > base.txt +git commit -qam 'stale: edit base.txt' + +# contained: fully merged into main, then main moves on without it +git checkout -q main +git checkout -qb merged-branch +printf 'm\n' > merged.txt +git add merged.txt +git commit -qm 'merged: add merged.txt' +git checkout -q main +git merge -q --ff-only merged-branch +git branch -m merged-branch contained +printf 'd\n' > later.txt +git add later.txt +git commit -qm 'base: add later.txt' + +# fresh: branched at the base tip, one commit ahead +git checkout -qb fresh +printf 'c\n' > fresh.txt +git add fresh.txt +git commit -qm 'fresh: add fresh.txt' + +# unrelated: an orphan data branch, as the badge publisher creates +git checkout -q --orphan governance-badges +git rm -rqf . +printf '{ "schemaVersion": 1 }\n' > deed.json +git add deed.json +git commit -qm 'badges: endpoint json' + +git checkout -q main +git remote add origin "$work/origin.git" +git push -q origin main fresh salvage stale contained governance-badges +# The bare repository's HEAD is still on an unborn `master`, so `git clone` +# would call it empty; point it at main, as a real default branch would be. +git -C "$work/origin.git" symbolic-ref HEAD refs/heads/main +git fetch -q origin + +out="$(bash "$SCRIPT" --base main --remote origin)" +rows="$(printf '%s\n' "$out" | grep -E 'origin/|^[a-z]' || true)" +check_contains "$out" 'fresh' 'a branch at the base tip appears' +if printf '%s\n' "$out" | grep -Eq '^fresh +fresh '; then ok 'a branch at the base tip is classed fresh'; else no "fresh branch class: $out"; fi +if printf '%s\n' "$out" | grep -Eq '^salvage +salvage '; then ok 'a drifted branch with an unknown file is classed salvage'; else no "salvage class: $out"; fi +if printf '%s\n' "$out" | grep -Eq '^stale +stale '; then ok 'a drifted branch that adds nothing is classed stale'; else no "stale class: $out"; fi +if printf '%s\n' "$out" | grep -Eq '^contained +contained '; then ok 'a fully merged branch is classed contained'; else no "contained class: $out"; fi +if printf '%s\n' "$out" | grep -Eq '^governance-badges +unrelated '; then ok 'an orphan branch is classed unrelated'; else no "unrelated class: $out"; fi + +markdown="$(bash "$SCRIPT" --base main --remote origin --format markdown)" +check_contains "$markdown" '| `salvage` | salvage |' 'the markdown table carries the class' +check_contains "$markdown" 'salvage (holds files the base lacks)' 'the markdown summary explains salvage' + +set +e +bash "$SCRIPT" --base main --remote origin --fail-on stale >/dev/null 2>&1 +status=$? +set -e +if [ "$status" -eq 3 ]; then ok '--fail-on stale exits 3 when a stale branch exists'; else no "--fail-on stale exited $status"; fi + +set +e +bash "$SCRIPT" --base main --remote origin --fail-on salvage >/dev/null 2>&1 +status=$? +set -e +if [ "$status" -eq 3 ]; then ok '--fail-on salvage exits 3 when a salvage branch exists'; else no "--fail-on salvage exited $status"; fi + +json="$(bash "$SCRIPT" --base main --remote origin --format json)" +if printf '%s' "$json" | python3 -c 'import json,sys; d=json.load(sys.stdin); assert d["base"]=="main"; assert {b["class"] for b in d["branches"]} >= {"stale","salvage","contained","unrelated","fresh"}' 2>/dev/null; then + ok '--format json emits the documented classes as valid JSON' +else + no "--format json output: $json" +fi + +# A shallow clone cannot answer the question, so it must not answer at all. +git clone -q --depth 1 --branch main "file://$work/origin.git" "$work/shallow" +set +e +shallow_out="$(cd "$work/shallow" && bash "$SCRIPT" --base main --remote origin 2>&1)" +status=$? +set -e +if [ "$status" -eq 2 ]; then ok 'a shallow clone is refused with exit 2'; else no "shallow clone exited $status: $shallow_out"; fi +check_contains "$shallow_out" 'shallow clone' 'the refusal names the cause' + +printf '\n%d passed, %d failed\n' "$pass" "$fail" +[ "$fail" -eq 0 ] From 1064bc062247c010a9bb04c6cab804960ad368ff Mon Sep 17 00:00:00 2001 From: Arena Agent Date: Sun, 25 Oct 2026 08:47:51 +0000 Subject: [PATCH 2/3] fix(ci): check out the pull request head, not the synthetic merge commit The first run of the new freshness check failed on its own pull request: actions/checkout checks out refs/pull//merge for a pull_request event, and that commit is a merge by construction, so rule 2 failed on every branch. Check out github.event.pull_request.head.sha instead, and have the script name the case it cannot distinguish from a real violation: the head being a merge means either the author merged the base in, or CI checked out the merge ref. The hint says how to tell, and a test covers it. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/pr-freshness.yml | 5 +++++ scripts/maintenance/check-pr-freshness.sh | 12 ++++++++++++ scripts/tests/test-check-pr-freshness.sh | 1 + 3 files changed, 18 insertions(+) diff --git a/.github/workflows/pr-freshness.yml b/.github/workflows/pr-freshness.yml index 0f80fd62..418ef045 100644 --- a/.github/workflows/pr-freshness.yml +++ b/.github/workflows/pr-freshness.yml @@ -41,6 +41,11 @@ jobs: - name: Check out the pull request's history uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + # The branch tip, not GitHub's `refs/pull//merge` commit. The + # synthetic merge has two parents by construction, so checking it out + # makes rule 2 fail on every pull request - including this workflow's + # first run, which is how the mistake was found. + ref: ${{ github.event.pull_request.head.sha }} # Full history: the check needs the real merge base, and a shallow # clone would report its graft point as the base and pass everything. fetch-depth: 0 diff --git a/scripts/maintenance/check-pr-freshness.sh b/scripts/maintenance/check-pr-freshness.sh index b885fff7..fcfe562e 100755 --- a/scripts/maintenance/check-pr-freshness.sh +++ b/scripts/maintenance/check-pr-freshness.sh @@ -112,6 +112,18 @@ if [ "${merge_commits:-0}" -gt 0 ]; then printf 'check-pr-freshness: %s merge commit(s) in the branch:\n' "$merge_commits" >&2 git rev-list --merges --format=' %h %s' "$merge_base..$head_tip" | grep -v '^commit ' >&2 || true annotate error "$merge_commits merge commit(s) in the branch; rebase so the branch replays linearly (CONTRIBUTING.md rule 2)" + # The head being a merge is the signature of two very different situations, + # and the message has to help tell them apart: a contributor who merged the + # base into their branch (a real rule 2 violation) and CI that checked out + # GitHub's synthetic `refs/pull//merge` commit (no violation at all - the + # merge exists only to test the merge result). + if [ "$merge_commits" -eq 1 ] \ + && [ "$(git rev-parse "$head_tip^{commit}")" = "$(git rev-list --merges -1 "$merge_base..$head_tip")" ] \ + && [ "$(git rev-list --parents -n 1 "$head_tip" | wc -w | tr -d ' ')" -eq 3 ]; then + printf 'note: the head commit is itself a merge; if this is GitHub refs/pull//merge,\n' >&2 + printf ' check out the pull request head commit instead of the merge commit\n' >&2 + annotate error "the head commit is itself a merge; if this is refs/pull//merge, check out github.event.pull_request.head.sha instead" + fi else printf 'ok - no merge commits in the branch\n' fi diff --git a/scripts/tests/test-check-pr-freshness.sh b/scripts/tests/test-check-pr-freshness.sh index 84182230..2d95e48c 100755 --- a/scripts/tests/test-check-pr-freshness.sh +++ b/scripts/tests/test-check-pr-freshness.sh @@ -73,6 +73,7 @@ git checkout -q merges git merge -q --no-ff -m "Merge branch 'main' into merges" main run 1 'a merge commit in the branch fails even at the base tip' --base origin/main --head merges check_contains "$out" 'merge commit' 'the failure names the merge commit rule' +check_contains "$out" 'check out the pull request head commit' 'a merge-commit head also explains the refs/pull//merge case' # unrelated history: an orphan branch cannot be merged, so it cannot pass git checkout -q --orphan orphan From b0c2f9198df0f82504ea769472e1306bd9ee4d36 Mon Sep 17 00:00:00 2001 From: Arena Agent Date: Sun, 25 Oct 2026 08:49:16 +0000 Subject: [PATCH 3/3] docs(hygiene): correct which branches made Code relevance skip nothing Not all thirteen touched src/: #274 touched bench/null_calibration and the workflow, #278 touched the workflows and scripts/. The point stands - none was a docs-only diff - but the sentence named the wrong evidence. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- docs/maintenance/repository-hygiene.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/maintenance/repository-hygiene.md b/docs/maintenance/repository-hygiene.md index cb0c1666..d822ba77 100644 --- a/docs/maintenance/repository-hygiene.md +++ b/docs/maintenance/repository-hygiene.md @@ -119,7 +119,9 @@ expense is what made the situation stable rather than self-correcting: `Occupancy typed peak`, `Occupancy disagreement test`, plus `Governance (fork)`, `DOI`, `Format readiness evidence` and `Secret Scanner`. `Code relevance` skips the expensive jobs only for a diff that touches nothing the suite - exercises; all thirteen branches touched `src/`. + exercises, and none of the thirteen was such a diff: #274 touched + `bench/null_calibration` and the workflow, #278 touched the workflows and + `scripts/`, the rest touched `src/`. - Six of those jobs are required contexts in the `Branch-Floor` ruleset. A branch that cannot pass them reports `BLOCKED` indefinitely, so a reviewer sees thirteen blocked pull requests and no signal about which of them is