Repo scaffolding that pays rent
Six pieces of scaffolding, each of which has already paid for itself in a maintained repo.
Copy the shapes; the rationale is what keeps them from being cargo cult.
1. Agent context: AGENTS.md, imported by CLAUDE.md
Repo conventions (architecture, invariants, workflow rules) live in AGENTS.md — the
vendor-neutral file that multiple coding agents read. CLAUDE.md then contains exactly one
line:
@AGENTS.md
The @ import syntax matters: it inlines the whole file into the agent's context at
session start. A plain markdown link (See [AGENTS.md](AGENTS.md).) is not followed
automatically — observed side by side in two live repos: the @-import version loaded the
full conventions, the link version loaded only the one-line stub. If your conventions aren't
reaching the agent, check which form the repo uses.
2. .gitattributes: one line ends a whole failure class
* text=auto eol=lf
Prettier and eslint enforce LF. Without this line, Windows CI runners (and contributors with
core.autocrlf) check out CRLF and prettier --check fails on every line with
Delete `␍` — a wall of red unrelated to the change. Related Windows-runner trap: npm
script globs must be double-quoted ("dist/test/*.test.js") or Windows expands them.
3. Format locally, verify in CI
Two package.json scripts, one writing, one read-only:
format — prettier --write . && eslint --fix (the developer's command)
ci-lint — eslint && prettier --check . (what CI runs — read-only, so uncommitted
format drift fails the build instead of being silently "fixed")
Wire ci-lint into the PR workflow of every repo, including ones that started as pure
docs/shell — retrofitting it later means a noisy reformat commit. After bumping prettier /
eslint / typescript versions, run npm install before format: formatter output diverges
between versions and CI will reject stale-toolchain output.
4. Container repos: a weekly Trivy scan, tuned to be actionable
For any repo that publishes a container image, add a scheduled CVE scan (shape from a live
workflow):
- Weekly cron +
workflow_dispatch, not per-PR — freshly disclosed CVEs surface without
any code change, and per-PR would add an image build (often QEMU) to every PR.
- Build the amd64 image with
push: false, load: true, scan with the Trivy action, output
SARIF, upload to the repo's Security → Code scanning tab.
- Tune for signal:
ignore-unfixed: true (CVEs with no fixed version are noise you
cannot act on) and severity: HIGH,CRITICAL. Informational — it gates nothing; a finding
in a hand-pinned binary is the cue to bump that version ARG.
- Hygiene: guard the job with
if: github.repository == '<owner>/<repo>' so forks don't
burn their minutes, and persist-credentials: false on checkout when no later step needs
the token.
5. Publishing is tag-triggered CI — never a laptop
- npm packages: OIDC trusted publishing. No
NPM_TOKEN secret, no OTP in CI — the
registry trusts the workflow identity. The job needs permissions: contents: read +
id-token: write (OIDC is dead without the latter). One wrinkle: a new package needs
one manual CLI+OTP publish before a trusted publisher can be configured for it.
- Container images: GHCR via
GITHUB_TOKEN — job permissions contents: read +
packages: write — with the details that bite:
The tag glob is a coarse filter, not validation. Narrow it —
"v[0-9]*.[0-9]*.[0-9]*" beats a bare v*, which fires on vnext or vendor-fix — but
do not mistake it for a semver check. GitHub's filter syntax is glob, not regex: [0-9]
matches one digit and the following * matches anything, so each component is "a digit
then whatever". v1x.2y.3z, v1abc.2def.3ghi and v1.2.3.4 all match this "tight"
pattern. It only buys you the leading-digit-per-component shape; v1-keeper-final is
excluded, v1zzz.0zzz.0zzz is not.
So validate in the job — this step is mandatory, not belt-and-braces. The glob cannot
anchor and workflow_dispatch inputs are free text, so the only real gate is an explicit
check that fails the job before anything is published:
- name: Validate version is strict semver
run: |
VERSION="${GITHUB_REF_NAME#v}"
# MAJOR.MINOR.PATCH with optional -prerelease and +build (semver.org BNF)
if ! printf '%s' "$VERSION" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$'; then
echo "::error::Tag '$GITHUB_REF_NAME' is not strict semver - refusing to publish."
exit 1
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
Note the (0|[1-9][0-9]*) components: they reject leading zeros (v01.2.3) as semver
requires, which a naive [0-9]+ would let through.
Prereleases never move :latest: any version with a prerelease suffix (-beta.1,
-rc.2, -alpha.1, …) publishes :VERSION only — key the check on "has a prerelease
part", not on an enumerated list, or the next suffix style slips through.
Create the GitHub Release in a separate job with needs: publish, so a failed image
push can't leave a Release pointing at an image that never reached the registry. Derive
prerelease: from the tag name.
- Action pinning is a real tradeoff — pick one and apply it repo-wide. A version tag
(
actions/checkout@v6) is mutable: the tag can be repointed at a new commit, so it is a
trust decision about the publisher, not a cryptographic guarantee. Only a full commit SHA
(actions/checkout@<40-char-sha> # v6) is immutable, and Dependabot updates SHA pins in
place when that trailing version comment is kept.
This scaffold defaults to version tags for first-party (actions/*, docker/*) and
established third-party actions: readable diffs, no churn, and the same style across every
workflow in the repo. The cost is accepting the publisher's tag hygiene.
Choose SHA pins when the workflow's blast radius justifies it — anything holding
id-token: write (OIDC publish), packages: write (registry push), or actions: write,
and any less-established action — or when org policy mandates it. Both publish workflows
above are in that category, so this is a live choice, not a formality.
Whichever you pick, be consistent: a lone SHA-pinned step among tagged ones is noise, and
mixed styles make scanner findings unreadable. Treat a scanner's blanket "pin to SHA"
finding as this policy question, not a defect.
Or automate the whole ritual: release-please (verified end-to-end)
Instead of hand-cutting chore(release): X.Y.Z PRs and tags:
googleapis/release-please-action@v4 (release-type: node) on every push to the default
branch maintains a standing Release PR from the conventional commits — version bump in
package.json and the lockfile, generated changelog, compare/PR/commit links. Merging that
PR creates the tag and the GitHub Release, so releases still gate on a human merge. Verified
in production (a real version shipped through the full chain); four things bit on adoption:
- Tags pushed with
GITHUB_TOKEN never trigger your tag-based publish workflow (GitHub's
recursion guard). No PAT needed: make the release-please workflow dispatch the publish
workflow explicitly — gh workflow run publish.yml --ref "$TAG" -f tag="${TAG#v}" —
because workflow_dispatch is the documented exemption to the guard. Needs
actions: write; dispatching at the tag ref builds the tagged tree even if the default
branch has moved on. Keep the publish workflow's own Release job gated on push events so
release-please's Release stays the only one.
- The repo setting "Allow GitHub Actions to create and approve pull requests" is off by
default — the first run does all its branch work and then fails with exactly that
message. Flip it under Settings → Actions → General (or
gh api -X PUT repos/<owner>/<repo>/actions/permissions/workflow -F can_approve_pull_request_reviews=true).
- Serialize with a
concurrency group (cancel-in-progress: false) — every default-branch
push runs the workflow, and back-to-back merges race over the same Release PR (observed
immediately: three merges, three simultaneous runs).
- Permissions:
contents: write, pull-requests: write, plus issues: write — it
creates its autorelease labels through the issues API.
Taxonomy shifts to be aware of: notes come from commit types, not PR labels (the
release.yml categories below go unused for these releases); docs commits are hidden by
default; a feat of any scope drives a minor — steer an off-policy bump with an empty
commit carrying a Release-As: X.Y.Z footer. It also commits a generated CHANGELOG.md —
still nothing hand-written, but now a tracked file; configure it away if unwanted.
6. Release notes are generated, never hand-written
generate_release_notes: true on the Release step plus .github/release.yml to categorize
the merged PRs by label:
changelog:
exclude:
labels: [skip-changelog]
categories:
- title: 🚀 Features
labels: [feature, enhancement]
- title: 🐛 Fixes
labels: [bug, fix]
- title: 📦 Dependencies
labels: [dependencies]
- title: Other
labels: ["*"]
The notes are built from PR titles — which is the operational reason PR titles must
describe the change ("if someone only read the title, would they understand what this
does?"). No hand-maintained CHANGELOG file; it drifts and duplicates the Releases page.
All shapes lifted from live, maintained repos (verified 2026-08-04): the .gitattributes
line and its Windows-CI rationale, the format/ci-lint script pair, the weekly Trivy workflow
(cron, SARIF, ignore-unfixed, HIGH/CRITICAL), the dotted tag glob + semver validation +
prerelease-safe :latest + needs:-gated Release, and the label-categorized release.yml.
The @AGENTS.md import-vs-link behavior observed live in two repos side by side. The
release-please flow verified end-to-end on a production container repo (2026-08-06): Release
PR → human merge → tag + Release → dispatched publish at the tag ref → multi-arch registry
manifest with :VERSION + :latest; all four adoption gotchas above were hit and resolved
in that run, not copied from documentation.
1---2name: repo-scaffold3description: Use when creating a repository or upgrading its scaffolding — agent context files, line-ending normalization, format/lint CI, container CVE scanning, tag-triggered publishing, release automation, and generated release notes. Encodes the working set from maintained repos, CLAUDE.md importing AGENTS.md via the @-syntax (a plain link does not load), .gitattributes eol=lf (the Windows-CI "Delete ␍" fix), prettier+eslint as a read-only CI gate, a weekly Trivy scan tuned to actionable findings, tag globs as a coarse filter backed by a mandatory in-job semver check, prerelease-safe latest, the verified release-please adoption path (token-cascade dispatch, the Actions-may-create-PRs setting, concurrency, issues-write), and label-categorized release notes.4---56# Repo scaffolding that pays rent78Six pieces of scaffolding, each of which has already paid for itself in a maintained repo.9Copy the shapes; the rationale is what keeps them from being cargo cult.1011## 1. Agent context: `AGENTS.md`, imported by `CLAUDE.md`1213Repo conventions (architecture, invariants, workflow rules) live in **`AGENTS.md`** — the14vendor-neutral file that multiple coding agents read. `CLAUDE.md` then contains exactly one15line:1617```text18@AGENTS.md19```2021The `@` **import syntax matters**: it inlines the whole file into the agent's context at22session start. A plain markdown link (`See [AGENTS.md](AGENTS.md).`) is *not* followed23automatically — observed side by side in two live repos: the `@`-import version loaded the24full conventions, the link version loaded only the one-line stub. If your conventions aren't25reaching the agent, check which form the repo uses.2627## 2. `.gitattributes`: one line ends a whole failure class2829```gitattributes30* text=auto eol=lf31```3233Prettier and eslint enforce LF. Without this line, Windows CI runners (and contributors with34`core.autocrlf`) check out CRLF and `prettier --check` fails on **every line** with35``Delete `␍` `` — a wall of red unrelated to the change. Related Windows-runner trap: npm36script globs must be double-quoted (`"dist/test/*.test.js"`) or Windows expands them.3738## 3. Format locally, verify in CI3940Two package.json scripts, one writing, one read-only:4142- `format` — `prettier --write . && eslint --fix` (the developer's command)43- `ci-lint` — `eslint && prettier --check .` (what CI runs — read-only, so uncommitted44 format drift fails the build instead of being silently "fixed")4546Wire `ci-lint` into the PR workflow of every repo, including ones that started as pure47docs/shell — retrofitting it later means a noisy reformat commit. After bumping prettier /48eslint / typescript versions, run `npm install` before `format`: formatter output diverges49between versions and CI will reject stale-toolchain output.5051## 4. Container repos: a weekly Trivy scan, tuned to be actionable5253For any repo that publishes a container image, add a scheduled CVE scan (shape from a live54workflow):5556- **Weekly cron + `workflow_dispatch`, not per-PR** — freshly disclosed CVEs surface without57 any code change, and per-PR would add an image build (often QEMU) to every PR.58- Build the amd64 image with `push: false, load: true`, scan with the Trivy action, output59 SARIF, upload to the repo's Security → Code scanning tab.60- **Tune for signal**: `ignore-unfixed: true` (CVEs with no fixed version are noise you61 cannot act on) and `severity: HIGH,CRITICAL`. Informational — it gates nothing; a finding62 in a hand-pinned binary is the cue to bump that version ARG.63- Hygiene: guard the job with `if: github.repository == '<owner>/<repo>'` so forks don't64 burn their minutes, and `persist-credentials: false` on checkout when no later step needs65 the token.6667## 5. Publishing is tag-triggered CI — never a laptop6869- **npm packages: OIDC trusted publishing.** No `NPM_TOKEN` secret, no OTP in CI — the70 registry trusts the workflow identity. The job needs `permissions: contents: read` +71 `id-token: write` (OIDC is dead without the latter). One wrinkle: a *new* package needs72 one manual CLI+OTP publish before a trusted publisher can be configured for it.73- **Container images: GHCR via `GITHUB_TOKEN`** — job permissions `contents: read` +74 `packages: write` — with the details that bite:75 - **The tag glob is a coarse filter, not validation.** Narrow it —76 `"v[0-9]*.[0-9]*.[0-9]*"` beats a bare `v*`, which fires on `vnext` or `vendor-fix` — but77 do not mistake it for a semver check. GitHub's filter syntax is glob, not regex: `[0-9]`78 matches one digit and the following `*` matches *anything*, so each component is "a digit79 then whatever". `v1x.2y.3z`, `v1abc.2def.3ghi` and `v1.2.3.4` all match this "tight"80 pattern. It only buys you the leading-digit-per-component shape; `v1-keeper-final` is81 excluded, `v1zzz.0zzz.0zzz` is not.82 - **So validate in the job — this step is mandatory, not belt-and-braces.** The glob cannot83 anchor and `workflow_dispatch` inputs are free text, so the only real gate is an explicit84 check that fails the job before anything is published:8586 ```yaml87 - name: Validate version is strict semver88 run: |89 VERSION="${GITHUB_REF_NAME#v}"90 # MAJOR.MINOR.PATCH with optional -prerelease and +build (semver.org BNF)91 if ! printf '%s' "$VERSION" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$'; then92 echo "::error::Tag '$GITHUB_REF_NAME' is not strict semver - refusing to publish."93 exit 194 fi95 echo "version=$VERSION" >> "$GITHUB_OUTPUT"96 ```9798 Note the `(0|[1-9][0-9]*)` components: they reject leading zeros (`v01.2.3`) as semver99 requires, which a naive `[0-9]+` would let through.100 - **Prereleases never move `:latest`**: any version with a prerelease suffix (`-beta.1`,101 `-rc.2`, `-alpha.1`, …) publishes `:VERSION` only — key the check on "has a prerelease102 part", not on an enumerated list, or the next suffix style slips through.103 - Create the GitHub Release in a separate job with `needs: publish`, so a failed image104 push can't leave a Release pointing at an image that never reached the registry. Derive105 `prerelease:` from the tag name.106- **Action pinning is a real tradeoff — pick one and apply it repo-wide.** A version tag107 (`actions/checkout@v6`) is *mutable*: the tag can be repointed at a new commit, so it is a108 trust decision about the publisher, not a cryptographic guarantee. Only a full commit SHA109 (`actions/checkout@<40-char-sha> # v6`) is immutable, and Dependabot updates SHA pins in110 place when that trailing version comment is kept.111 This scaffold defaults to **version tags** for first-party (`actions/*`, `docker/*`) and112 established third-party actions: readable diffs, no churn, and the same style across every113 workflow in the repo. The cost is accepting the publisher's tag hygiene.114 Choose **SHA pins** when the workflow's blast radius justifies it — anything holding115 `id-token: write` (OIDC publish), `packages: write` (registry push), or `actions: write`,116 and any less-established action — or when org policy mandates it. Both publish workflows117 above are in that category, so this is a live choice, not a formality.118 Whichever you pick, be consistent: a lone SHA-pinned step among tagged ones is noise, and119 mixed styles make scanner findings unreadable. Treat a scanner's blanket "pin to SHA"120 finding as this policy question, not a defect.121122### Or automate the whole ritual: release-please (verified end-to-end)123124Instead of hand-cutting `chore(release): X.Y.Z` PRs and tags:125`googleapis/release-please-action@v4` (`release-type: node`) on every push to the default126branch maintains a **standing Release PR** from the conventional commits — version bump in127`package.json` *and* the lockfile, generated changelog, compare/PR/commit links. Merging that128PR creates the tag and the GitHub Release, so releases still gate on a human merge. Verified129in production (a real version shipped through the full chain); **four things bit on adoption**:130131- **Tags pushed with `GITHUB_TOKEN` never trigger your tag-based publish workflow** (GitHub's132 recursion guard). No PAT needed: make the release-please workflow dispatch the publish133 workflow explicitly — `gh workflow run publish.yml --ref "$TAG" -f tag="${TAG#v}"` —134 because `workflow_dispatch` is the documented exemption to the guard. Needs135 `actions: write`; dispatching *at the tag ref* builds the tagged tree even if the default136 branch has moved on. Keep the publish workflow's own Release job gated on push events so137 release-please's Release stays the only one.138- **The repo setting "Allow GitHub Actions to create and approve pull requests" is off by139 default** — the first run does all its branch work and then fails with exactly that140 message. Flip it under Settings → Actions → General (or141 `gh api -X PUT repos/<owner>/<repo>/actions/permissions/workflow -F can_approve_pull_request_reviews=true`).142- **Serialize with a `concurrency` group** (`cancel-in-progress: false`) — every default-branch143 push runs the workflow, and back-to-back merges race over the same Release PR (observed144 immediately: three merges, three simultaneous runs).145- **Permissions**: `contents: write`, `pull-requests: write`, *plus* `issues: write` — it146 creates its `autorelease` labels through the issues API.147148Taxonomy shifts to be aware of: notes come from **commit types**, not PR labels (the149`release.yml` categories below go unused for these releases); `docs` commits are hidden by150default; a `feat` of *any* scope drives a minor — steer an off-policy bump with an empty151commit carrying a `Release-As: X.Y.Z` footer. It also commits a generated `CHANGELOG.md` —152still nothing hand-written, but now a tracked file; configure it away if unwanted.153154## 6. Release notes are generated, never hand-written155156`generate_release_notes: true` on the Release step plus `.github/release.yml` to categorize157the merged PRs by label:158159```yaml160changelog:161 exclude:162 labels: [skip-changelog]163 categories:164 - title: 🚀 Features165 labels: [feature, enhancement]166 - title: 🐛 Fixes167 labels: [bug, fix]168 - title: 📦 Dependencies169 labels: [dependencies]170 - title: Other171 labels: ["*"]172```173174The notes are built from **PR titles** — which is the operational reason PR titles must175describe the change ("if someone only read the title, would they understand what this176does?"). No hand-maintained CHANGELOG file; it drifts and duplicates the Releases page.177178---179180*All shapes lifted from live, maintained repos (verified 2026-08-04): the `.gitattributes`181line and its Windows-CI rationale, the format/ci-lint script pair, the weekly Trivy workflow182(cron, SARIF, ignore-unfixed, HIGH/CRITICAL), the dotted tag glob + semver validation +183prerelease-safe `:latest` + `needs:`-gated Release, and the label-categorized `release.yml`.184The `@AGENTS.md` import-vs-link behavior observed live in two repos side by side. The185release-please flow verified end-to-end on a production container repo (2026-08-06): Release186PR → human merge → tag + Release → dispatched publish at the tag ref → multi-arch registry187manifest with `:VERSION` + `:latest`; all four adoption gotchas above were hit and resolved188in that run, not copied from documentation.*