Repository navigation
chat: a bare number-and-period reply draws instead of vanishing (#1072) #4372
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" ] |