Skip to content
Merged
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
101 changes: 101 additions & 0 deletions .github/workflows/branch-hygiene.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# SPDX-License-Identifier: MPL-2.0
# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
#
# 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='<!-- branch-hygiene:rolling -->'
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
4 changes: 4 additions & 0 deletions .github/workflows/hypatia-scan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
58 changes: 58 additions & 0 deletions .github/workflows/pr-freshness.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# SPDX-License-Identifier: MPL-2.0
# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
#
# 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:
# The branch tip, not GitHub's `refs/pull/<n>/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
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
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/

Expand Down
29 changes: 29 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<n>/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
Expand Down
Loading
Loading