Skip to content

chat: a bare number-and-period reply draws instead of vanishing (#1072) #4372

chat: a bare number-and-period reply draws instead of vanishing (#1072)

chat: a bare number-and-period reply draws instead of vanishing (#1072) #4372

Workflow file for this run

name: PR gate
# THE LIGHT GATE, AND IT IS DELIBERATELY LIGHT. Work moves into `dev` many
# times a day, much of it written by agents, and a gate that takes fifteen
# minutes is a gate people learn to route around. So this one asks only the
# questions whose answer is always the same on every machine and whose failure
# always means somebody broke something: does the tree compile, is it
# formatted, does vet find a real bug, do the laws hold, and does the manual
# still know about the feature that just landed.
#
# THE LAWS ARE HERE BECAUSE THEY WERE NOT. Until 2026-09-02 this gate's one
# test step was `go test -run 'Manual'` over two packages — a filter nobody
# remembered — and the endings ratchet in internal/session went red on dev
# through two merged pull requests while every check here was green (#372).
# A structural test decides in under a second and the same on every machine;
# it belongs on the light gate by the light gate's own definition, and now
# `make test-laws` finds every one of them without anybody keeping a list.
#
# The whole suite is not here. The packages a change touched run in full in the
# concurrent touched legs below, on every pull request, and `check` — the one name a person
# and a ruleset look at — is green only when both are; everything else runs on
# the way into `staging`, and nightly against `dev` — see ci-full.yml.
# docs/rules/ci.md says why the line is drawn in that place.
#
# EVERY LINE WORK LANDS ON IS GATED, NOT ONLY THE TRUNK. `santos/dev2` is a
# line feature work merges into many times a day, and until 2026-09-21 no
# workflow named it: this gate listed `dev` alone and ci-full.yml lists
# `staging` and `main`, so a pull request based on `santos/dev2` was answered by
# the licence check and nothing else. Whether that line was green was a question
# only a person on a cluster could answer, and for a while the answer was no.
# That is the same failure the note above records for #372, one level up: not a
# filter nobody remembered, but a branch nobody listed.
on:
pull_request:
branches:
- dev
- santos/dev2
# Also on the push, because a merge of two individually-green branches can
# still be red, and the merge is exactly the moment nobody is watching.
push:
branches:
- dev
- santos/dev2
workflow_dispatch:
permissions:
contents: read
# A pull request's newer push cancels its older run; a push to `dev` never
# cancels another, because the group is the commit. Keyed on the ref, the
# second merge inside twenty-five minutes cancelled the first merge's
# `touched packages` run, and a cancelled run leaves no red — on exactly the
# push the trigger above exists for.
concurrency:
group: pr-gate-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: true
jobs:
# THE LIGHT HALF: the questions that answer in a few minutes. It is not the
# required name — `check`, at the bottom, is — so that the light answer can
# arrive early while the touched packages are still running beside it.
light:
name: light gate
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
with:
# The changelog check compares against the base of the pull request,
# which a shallow clone does not contain.
fetch-depth: 0
- uses: actions/setup-go@v5
id: go
with:
go-version-file: go.mod
cache: false
# UTC-day keys refresh each namespace on its first dev push of the day.
# PRs only restore; the undated prefix finds the newest compatible entry.
- name: Cache paths
id: cache-paths
run: |
echo "build=$(go env GOCACHE)" >> "$GITHUB_OUTPUT"
echo "modules=$(go env GOMODCACHE)" >> "$GITHUB_OUTPUT"
echo "today=$(date -u +%Y-%m-%d)" >> "$GITHUB_OUTPUT"
- uses: actions/cache/restore@v4
id: build-cache
with:
path: ${{ steps.cache-paths.outputs.build }}
key: codeaf-go-v1-light-build-${{ runner.os }}-${{ runner.arch }}-${{ steps.go.outputs.go-version }}-${{ steps.cache-paths.outputs.today }}
restore-keys: |
codeaf-go-v1-light-build-${{ runner.os }}-${{ runner.arch }}-${{ steps.go.outputs.go-version }}-
- uses: actions/cache/restore@v4
id: module-cache
with:
path: ${{ steps.cache-paths.outputs.modules }}
key: codeaf-go-v1-modules-${{ runner.os }}-${{ runner.arch }}-${{ steps.go.outputs.go-version }}-${{ hashFiles('go.sum') }}
restore-keys: |
codeaf-go-v1-modules-${{ runner.os }}-${{ runner.arch }}-${{ steps.go.outputs.go-version }}-
# Several sessions work this tree at once and a half-finished file breaks
# the build for everyone. This is the cheapest way to find that out, and
# the one failure that blocks every other kind of work.
- name: Build
run: go build ./...
- name: Vet
run: go vet ./...
# A file gofmt would rewrite is a file the next editor's save rewrites,
# and that diff lands in somebody else's pull request. Two files had
# drifted by the time anybody looked (#372).
- name: gofmt
run: make fmt-check
# THE PACKED CORPORA ARE GENERATED FROM THE FOLDERS. The manuals are
# ignored build products because committing one shared binary made every
# manual-bearing branch conflict; exercise that exact shipped source mode.
# The remaining generated corpora are tracked, so a diff still means real
# drift between their folders and archives.
- name: The packed corpora build from their folders
run: |
set -euo pipefail
go generate ./internal/manual
go test -tags=codeaf_packed_manual ./internal/manual
if ! git diff --exit-code; then
echo
echo 'A tracked packed corpus disagrees with its source folder.'
echo 'Run `make embed` and commit the regenerated archive.'
exit 1
fi
# THE CHANGELOG CARRIES WHAT A DIFF CANNOT — which of the things somebody
# believes about this repository stopped being true. It is checked here
# because it is worth nothing written later: the only moment the author
# knows they contradicted a page is the moment they went and edited it.
#
# Two seconds, and the cost of complying is one file with three fields.
# `kind: internal` with just a title is a legitimate entry; the
# `no-changelog` label is the way out for a typo in a comment.
# docs/rules/changelog.md is the rule.
- name: The change is written down
if: github.event_name == 'pull_request' && !contains(github.event.pull_request.labels.*.name, 'no-changelog')
env:
BASE: ${{ github.event.pull_request.base.sha }}
PRNUM: ${{ github.event.pull_request.number }}
run: |
set -euo pipefail
go run ./cmd/codeaf-changes check
if git diff --name-only --diff-filter=A "$BASE" HEAD \
-- 'docs/changes/unreleased/*.md' | grep -q .; then
echo "This pull request adds a change entry."
exit 0
fi
cat <<'MSG'
This pull request adds no entry in docs/changes/unreleased/.
The changelog here is written for whoever reads the repository next,
and increasingly that is a model with a fortnight-old memory of it. Its
job is to say what somebody now believes WRONGLY — the branch that
stopped existing, the default that moved, the refusal that became a
capability. No tool can derive that from a diff, which is why it is
asked for here.
MSG
# The number is spelled out so the command pastes unedited — a message
# that can be pasted unedited is a message that gets pasted.
echo " make changelog-new PR=${PRNUM} KIND=changed SLUG=<a-short-slug>"
cat <<'MSG'
Kinds: added changed renamed fixed removed internal. A one-line
`kind: internal` entry is perfectly legitimate and takes ten seconds.
If this really is a typo in a comment, add the `no-changelog` label.
docs/rules/changelog.md says what belongs in the fields.
MSG
exit 1
# THE MANUAL LAW, ENFORCED HERE BECAUSE IT IS THE LAW AGENTS BREAK MOST.
# A feature that ships without its page is a feature the running chat will
# deny having. These are the three gates CLAUDE.md names — every slash
# command and alias, every tool on the belt, every probe question still
# reaching the page that answers it — and they cost six seconds.
- name: The manual knows about the features
run: |
set -euo pipefail
go test ./internal/manual/
go test -run 'Manual' ./internal/tui3/ ./internal/session/
# THE LAWS HOLD. Every test that reads the tree itself — the endings
# ratchet, the guard, the taxonomy, the words the e2e suite waits for —
# found by what it does (scripts/laws.sh) and run as one target, about
# twenty seconds after the link. (The known-red ratchet was one of these
# until the ledger burned to zero in #1012.) This is the step that
# would have stopped #279 at the door.
#
# TMPDIR IS SET ON THIS STEP AND NOT ON THE JOB. A test that binds a unix
# socket under t.TempDir() overflows sun_path on the runner's default
# path, so the tests get a short one; but set on the job it reaches
# setup-go's own `go env`, which runs before any step has created the
# directory, and the go command fails making its work dir. Two runs of
# this very pull request died that way.
- name: The laws hold
env:
TMPDIR: /tmp/codeaf-ci
run: |
mkdir -p "$TMPDIR"
make test-laws
# THE CACHE BUDGET IS DAILY, NOT PER COMMIT. At most five build
# namespaces a day plus one module entry per go.sum cost about 2 GB a day
# at the measured ~385 MB. GitHub's least-recently-used eviction at the
# 10 GB repository limit removes old days without a pruning script.
# Only dev pushes that miss the exact key save; PRs never save.
- uses: actions/cache/save@v4
if: always() && github.event_name == 'push' && github.ref == 'refs/heads/dev' && steps.cache-paths.outcome == 'success' && steps.build-cache.outputs.cache-hit != 'true'
with:
path: ${{ steps.cache-paths.outputs.build }}
key: ${{ steps.build-cache.outputs.cache-primary-key }}
- uses: actions/cache/save@v4
if: always() && github.event_name == 'push' && github.ref == 'refs/heads/dev' && steps.cache-paths.outcome == 'success' && steps.module-cache.outputs.cache-hit != 'true'
with:
path: ${{ steps.cache-paths.outputs.modules }}
key: ${{ steps.module-cache.outputs.cache-primary-key }}
# The selector does no compilation unless module files changed. A docs-only
# change creates no test runner; the light gate and the aggregate still run.
select:
name: select touched packages
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.select.outputs.matrix }}
has-tests: ${{ steps.select.outputs.has-tests }}
base: ${{ steps.select.outputs.base }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: false
- name: Select and partition packages
id: select
env:
BASE: ${{ github.event.pull_request.base.sha || github.event.before }}
run: |
set -euo pipefail
case "${BASE:-}" in ''|0000000000000000000000000000000000000000) BASE="$(git rev-parse HEAD~1)";; esac
export BASE
echo "base=$BASE" >> "$GITHUB_OUTPUT"
./scripts/touched-packages.sh | python3 scripts/touched-matrix.py >> "$GITHUB_OUTPUT"
# THE LEGS START TOGETHER ON SEPARATE RUNNERS. A failure never cancels another
# leg's evidence. Five named failures at most receive one focused retry each
# and then a base probe; unnamed failures fail immediately. No test is skipped.
touched-leg:
name: touched ${{ matrix.leg }}
needs: select
if: needs.select.outputs.has-tests == 'true'
runs-on: ubuntu-latest
timeout-minutes: 60
strategy:
fail-fast: false
matrix: ${{ fromJSON(needs.select.outputs.matrix) }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
id: go
with:
go-version-file: go.mod
cache: false
# UTC-day keys refresh each namespace on its first dev push of the day.
# PRs only restore; the undated prefix finds the newest compatible entry.
- name: Cache paths
id: cache-paths
run: |
echo "build=$(go env GOCACHE)" >> "$GITHUB_OUTPUT"
echo "modules=$(go env GOMODCACHE)" >> "$GITHUB_OUTPUT"
echo "today=$(date -u +%Y-%m-%d)" >> "$GITHUB_OUTPUT"
- uses: actions/cache/restore@v4
id: build-cache
with:
path: ${{ steps.cache-paths.outputs.build }}
key: codeaf-go-v1-${{ matrix.leg }}-build-${{ runner.os }}-${{ runner.arch }}-${{ steps.go.outputs.go-version }}-${{ steps.cache-paths.outputs.today }}
restore-keys: |
codeaf-go-v1-${{ matrix.leg }}-build-${{ runner.os }}-${{ runner.arch }}-${{ steps.go.outputs.go-version }}-
codeaf-go-v1-light-build-${{ runner.os }}-${{ runner.arch }}-${{ steps.go.outputs.go-version }}-
- uses: actions/cache/restore@v4
id: module-cache
with:
path: ${{ steps.cache-paths.outputs.modules }}
key: codeaf-go-v1-modules-${{ runner.os }}-${{ runner.arch }}-${{ steps.go.outputs.go-version }}-${{ hashFiles('go.sum') }}
restore-keys: |
codeaf-go-v1-modules-${{ runner.os }}-${{ runner.arch }}-${{ steps.go.outputs.go-version }}-
- name: Runner capacity
run: |
nproc
free -g
# Offline module-listing tests need the whole module cache; each leg compiles only part of the tree.
- name: Download modules
run: go mod download
- name: Test and attribute failures
env:
BASE: ${{ needs.select.outputs.base }}
PACKAGES: ${{ matrix.packages }}
LEG: ${{ matrix.leg }}
TMPDIR: /tmp/codeaf-ci
run: |
set -euo pipefail
mkdir -p "$TMPDIR"
# -p 2 permits concurrent compilation/testing in rest on the public
# 4-vCPU / 16-GB runner, leaving headroom for the linker. tui3 and
# session use four shards; codeaf runs its suite without sharding.
./scripts/touched-verdict.sh run --base "$BASE" --shards 4 --report "$TMPDIR/$LEG.json" $PACKAGES
- uses: actions/upload-artifact@v4
if: always()
with:
name: touched-verdict-${{ matrix.leg }}
path: /tmp/codeaf-ci/${{ matrix.leg }}.json
retention-days: 7
- uses: actions/cache/save@v4
if: always() && github.event_name == 'push' && github.ref == 'refs/heads/dev' && steps.cache-paths.outcome == 'success' && steps.build-cache.outputs.cache-hit != 'true'
with:
path: ${{ steps.cache-paths.outputs.build }}
key: ${{ steps.build-cache.outputs.cache-primary-key }}
# KEEP THIS NAME. Consumers of `touched packages` see the aggregate even when
# no leg was needed; selector failures and failed/cancelled legs remain red.
touched:
name: touched packages
needs: [select, touched-leg]
if: always()
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
if: needs.select.outputs.has-tests == 'true'
continue-on-error: true
with:
pattern: touched-verdict-*
merge-multiple: true
path: /tmp/codeaf-verdicts
- name: Record flaky and inherited failures
if: always()
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
TOUCHED_REPORT_ISSUE: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
run: ./scripts/touched-verdict.sh report-issues /tmp/codeaf-verdicts
- name: Every needed leg passed
if: always()
env:
SELECT: ${{ needs.select.result }}
HAS_TESTS: ${{ needs.select.outputs.has-tests }}
LEGS: ${{ needs.touched-leg.result }}
run: |
set -euo pipefail
echo "selection: $SELECT; needed: $HAS_TESTS; touched legs: $LEGS"
[ "$SELECT" = success ]
if [ "$HAS_TESTS" = true ]; then [ "$LEGS" = success ]; else [ "$LEGS" = skipped ]; fi
# The single required name stays check. Rulesets and landing scripts need
# only this result, which requires both the light gate and the aggregate.
check:
name: check
needs: [light, touched]
if: always()
runs-on: ubuntu-latest
steps:
- name: Both halves passed
run: |
set -euo pipefail
echo "light gate: ${{ needs.light.result }}; touched packages: ${{ needs.touched.result }}"
[ "${{ needs.light.result }}" = "success" ] && [ "${{ needs.touched.result }}" = "success" ]