Everything about this repository's structure, tooling, and workflow that's
identical across every instance of this template — as opposed to the root
README.md's short, instance-owned preface. See ../CLAUDE.md's "Keeping
this file current" for where a new convention belongs.
template-base ships the generic "devcontainer + CI + AI-assisted
workflow" scaffold only — no application runtime, no backing services, no
language assumed. It carries no src//tests/ of its own. An instance
(a Python/Node.js/Rust/Go library, an infra-only repo, or a full
application) adds its own language runtime, backing services, and release
artifact on top of the shape described here.
.devcontainer/— the devcontainer setup; see itsREADME.md. An instance that needs backing services adds astack/directory here, following the "Devcontainer stack pattern" convention this template's own instances (e.g.template-fastapi) already establish..github/— CI and release workflows; see itsCONTENTS.md..vscode/— editor settings, tasks; see itsREADME.md..claude/— Claude Code CLI project config; see itsREADME.md..mcp.json— project-scope MCP servers not covered by a.claude/settings.jsonplugin; see.claude/README.md.docs/— knowledge about what the instance does; this file is the one exception, documenting the template itself rather than product/domain knowledge.adrs/,frs/,nfrs/,plans/ship only their ownREADME.md+template.mdscaffolding — no numbered content, which is entirely instance-owned..secrets/— local secret files, never committed; see itsREADME.md..devcontainer/Dockerfile— a singledevelopstage (the devcontainer image), with its build-context excludes in.devcontainer/Dockerfile.dockerignore. The repo root has noDockerfile: that one name is reserved for an instance's own runtime image (see "Instance extension points"), so a repo never has more than one top-level Dockerfile.scripts/develop.sh,scripts/post-create.sh— thedevelopstage's setup script and the devcontainer'spostCreateCommand, each running an instance hook directory (develop.d/,post-create.d/); seescripts/README.mdand "Instance extension points" below.Makefile— the release contractrelease.ymldrives (build/sbom/release-assets/publish/publish-packages), each a documented no-op or implementation here — see "Release: a Makefile contract" below..pre-commit-config.yaml— git hooks, run byprekorpre-commit; language-agnostic hooks only.CLAUDE.md— the AI-assisted coding workflow Claude Code follows in this repository; general conventions live in this file and each directory's ownREADME.mdinstead.
Base-owned files stay replace tier (a sync overwrites them) and expose
hooks an instance fills with files it owns, so an instance never edits a
base-owned file directly. Don't edit .devcontainer/Dockerfile, scripts/develop.sh
or compose.yml in an instance; use the hooks below.
| Hook | Base side (template-owned) | Instance side (instance-owned) |
|---|---|---|
| Extra devcontainer tooling | .devcontainer/Dockerfile develop stage runs every scripts/develop.d/*.sh after develop.sh |
scripts/develop.d/NN-name.sh |
| Extra post-create steps | scripts/post-create.sh, run by devcontainer.json's postCreateCommand, runs every scripts/post-create.d/*.sh |
scripts/post-create.d/NN-name.sh |
| Extra compose services / dev-service settings | devcontainer.json "dockerComposeFile": ["compose.yml", "compose.instance.yml"] |
.devcontainer/compose.instance.yml (base ships a stub, ignore tier) |
| Instance skills and agents | base's manifest lists each of its own skills (.claude/skills/<name>/) and agents (.claude/agents/<name>.md) one by one under replace, not as a directory glob; a deleted base skill or agent keeps its line as a tombstone so sync removes it from instances. An instance's own name must not reuse a base or tombstoned one, since sync would overwrite or remove it |
.claude/skills/<name>/, .claude/agents/<name>.md, listed in .github/template-sync-manifest.local.yml's ignore: |
| Runtime image | nothing (the devcontainer image lives in .devcontainer/) |
top-level Dockerfile + .dockerignore, listed in .github/template-sync-manifest.local.yml's ignore: |
Files with no include mechanism of their own (JSON configs,
.pre-commit-config.yaml — prek has no include) are merge tier, so an
instance's additions survive a sync. The runtime image is the
top-level Dockerfile with a plain .dockerignore, both instance-owned:
base keeps the devcontainer image and its excludes under
.devcontainer/, where BuildKit picks up
.devcontainer/Dockerfile.dockerignore for that build automatically.
One-time upgrade (instances synced from before this layout). A sync
adds .devcontainer/Dockerfile but can't delete the old top-level
Dockerfile/.dockerignore: from this release on, base's manifest no
longer classifies those paths, and a merge-tier file is never deleted
automatically. In the sync PR, by hand:
- With a runtime image: rename
app.Dockerfile→Dockerfileandapp.Dockerfile.dockerignore→.dockerignore, overwriting the old base files. Update every reference (-f app.Dockerfilein theMakefile,dockerfile:in compose files, CI, docs) and list both paths underignore:in.github/template-sync-manifest.local.yml. - Without one: delete the old top-level
Dockerfileand.dockerignore.
- Open this folder in a devcontainer (VS Code: "Reopen in Container" —
.vscode/extensions.jsonrecommends the extension that offers this — or any tool that reads.devcontainer/devcontainer.json). This builds thedevelopstage and installs the git hooks viapostCreateCommand(scripts/post-create.sh). - There's nothing to run yet —
template-baseships no application. An instance documents its own "run the app" / "run the CLI" step here.
Without a devcontainer: install prek and run
prek install once to enable the git hooks; install whatever else your
instance's own toolchain needs.
.pre-commit-config.yaml defines whitespace/EOF fixers, YAML/TOML/JSON
checks, conventional-pre-commit (Conventional Commits, enforced at the
commit-msg stage), the template-sync-manifest completeness check, and
the devcontainer-lock lockfile-consistency check, ruff-check/ruff-format
(astral-sh/ruff-pre-commit, scoped to .github/scripts/*.py so base can't
ship scripts that fail a Python instance's own ruff hook; keep its rev equal
to the ruff the Python templates pin), and three security scanners (below). An instance layers
its own language-specific hooks on top (lint,
format, type check, test suite, dependency-vulnerability scan, ...),
matching the shape template-fastapi's own .pre-commit-config.yaml
already establishes for ruff/mypy/pytest/pip-audit.
check-merge-conflict runs with --assume-in-merge, so it flags committed
conflict markers (a line starting <<<<<<< , ======= or >>>>>>> ) in any
file, not just during an in-progress git merge; a template sync PR that
commits a merge-tier conflict is then caught in CI.
| Hook | Scans | Stage | Network | OWASP (2025) |
|---|---|---|---|---|
zizmor |
.github/workflows/*.yml: template injection, excessive permissions, unpinned or tag-pinned Actions, persisted credentials |
pre-commit, manual |
offline locally; online in CI | A01, A03, A08 |
gitleaks |
staged changes on commit; gitleaks-history (manual stage) scans the full git history |
pre-commit; manual |
none | A04 |
osv-scanner |
supported lockfiles (package-lock.json, pnpm-lock.yaml, yarn.lock, bun.lock, uv.lock, poetry.lock, pdm.lock, Pipfile.lock, pylock.toml, Cargo.lock, requirements*.txt) against osv.dev |
manual |
online | A03 |
-
Online/offline. Locally
zizmorhas no token and runs its offline audits only.checks.ymlpassesGH_TOKEN: ${{ github.token }}into the devcontainer (the job'scontents: readis all zizmor's docs ask for), which enables the online audits (for example, checking an Action's pinned SHA against the upstream repository).osv-scanneralways needs the network. -
No lockfile, no run.
osv-scannercarriesalways_run: falseand afiles:regex of the lockfiles above, so a repo without one (this template) skips it instead of failing with exit 128 ("no packages found"). An instance that adds a lockfile gets the scan with no further change; one using another lockfile format extends the regex in its.pre-commit-config.yaml. -
Pinning. Every
uses:in.github/workflows/is pinned to a full commit SHA with a# vX.Y.Zcomment; Renovate (helpers:pinGitHubActionDigests) keeps both current. Hookrev:s stay tags: Renovate'spre-commitmanager updates them, but cannot turn a tag into a frozen SHA (the update fails). -
gitleaks allowlist. Config is
.gitleaks.toml([extend] useDefault = true). To allow a reviewed false positive, add a narrowly scoped entry (apathsand/orregexesmatch, never a whole rule or directory) with adescriptionsaying why it is safe:[[allowlists]] description = "Dummy token in the parser's test fixture" paths = ['^tests/fixtures/sample_token\.txt$'] regexes = ['ghp_EXAMPLEEXAMPLEEXAMPLE'] condition = "AND"
A real secret found in history is rotated first; allowlisting it is never the fix.
-
Ignoring a vulnerability. Create
osv-scanner.tomlnext to the lockfile (or at the repo root) with a reason and an expiry, so an ignore can't outlive its justification:[[IgnoredVulns]] id = "GHSA-xxxx-xxxx-xxxx" ignoreUntil = 2027-01-31 reason = "Not reachable: we never call the affected parser"
-
zizmor findings are fixed at the root (move
${{ }}expressions intoenv:, scopepermissions:per job, setpersist-credentials: false, replaceissue_comment/workflow_runtriggers with a label trigger and in-workflow jobs). Don't suppress an audit to get green.
Findings from the OWASP review (A03/A08), and what each now does:
- Release runs only from the default branch.
release.yml's first step fails unlessgithub.refisrefs/heads/<default branch>, andbuild,releaseandpublish-packages(the jobs holdingcontents: write,packages: write, the OIDC token or registry secrets) repeat the check as a job-levelif:. This stops an accidental dispatch from another branch, but it is not a security boundary: GitHub runs the workflow file from the dispatched ref, so a writer can delete the checks on their own branch. The server-side rule is thereleaseenvironment (next bullet). The tag and release targetgithub.sha, which for a dispatch on the default branch is its tip. Instances get this through template sync. GitHub's events documentation definesGITHUB_REFfor a dispatch as the "branch or tag that received dispatch", so the check has to be explicit; the secure-use guide asks for the least privilegeGITHUB_TOKENand warns that anyone with write access can read repository secrets. - Release environment (setup step for each instance).
release.yml'sbuild,releaseandpublish-packagesjobs declareenvironment: release. An admin must configure it once per repository, before the first release that carries this workflow: GitHub creates a referenced-but-missing environment with no rules and no secrets, so an unconfigured one protects nothing. Settings → Environments →release: Deployment branches and tags → Selected branches and tags → add the default branch (e.g.main) as a branch rule; or with the API,PUT repos/<owner>/<repo>/environments/releasewithdeployment_branch_policy: {protected_branches: false, custom_branch_policies: true}, thenPOST .../environments/release/deployment-branch-policieswithname=<default branch>andtype=branch. Then move the release secrets (OCI_REGISTRY_USERNAME,OCI_REGISTRY_PASSWORD,NPM_TOKEN,CARGO_REGISTRY_TOKEN) from repository secrets into the environment (gh secret set NAME --env release, thengh secret delete NAME). An organization secret of the same name stays readable by any workflow in a repository it is shared with: delete it and recreate it as an environment secret instead. A run from any other ref is then refused the privileged jobs and cannot read them, even if the in-workflow default-branch check was deleted on that branch. Thejobs.<id>.environmentsecrets are read assecrets.NAMEexactly like repository secrets, so no step changes (an environment secret overrides a repository one of the same name); repository variables (PYPI_PUBLISH,OCI_REGISTRY, ...) stay as they are.release-check,verify-publishedandversionhold no write permission or secret and do not use the environment. Syncing the workflow into an instance does not create or configure the environment; until the steps above are done, nothing fails and nothing is protected. Thebuildjob is a matrix, so each arch leg,releaseandpublish-packagesis its own deployment: use the branch rule only. Required reviewers or a wait timer would ask for approval once per job and leg. If the repository is private it needs a plan that supports environments. Trusted publishers (npm, PyPI, crates.io) have an optional environment field, and it must be set torelease: that field is what binds publishing to the environment, and without it a branch workflow withid-token: writecan still publish by OIDC. Set it oncerelease.ymlwith the environment is on the default branch. The registries only report a bad config when you publish. Residual: a writer's branch workflow can requestpackages: writeitself, so the environment does not gate GHCR pushes; mitigate with the package's GHCR Actions-access setting, pinning consumed images and features by digest, and an org ruleset requiring review for.github/workflows/**. See the GitHub documentation and npm's. - Renovate waits 3 days before automerging. The "patch and minor
updates" group sets
minimumReleaseAge: "3 days"(the age Renovate's ownsecurity:minimumReleaseAgeNpmpreset uses) with a top-levelinternalChecksFilter: "strict"(the documented recommendation alongside it), so a version still inside the window is skipped, not merged. Which updates automerge is unchanged, except one: Renovate 42 holds an update without a release timestamp forever (minimumReleaseAgeBehaviourdefaults totimestamp-required), and thepython-versionandgithub-runnersdatasources have none, so Python patches and runner image bumps are raised as their own PRs, without the age check, and are not automerged. Digest-only updates (including the Dockerfile's base image) were never in the automerged group. - Installers are verified, not run blind.
scripts/develop.shno longer runs vendor install scripts.uvandprekare installed from their release tarballs, checked against the.sha256file published beside each asset; Claude Code's binary is checked against itsmanifest.json, whose GPG signature is verified against the pinned release-signing-key fingerprint (the vendor's documented verification). The versions stay in the Dockerfile ARGs, so Renovate needs no new manager. A checksum fetched from the same release as the file it covers guards against a corrupted or swapped download, and against a mirror or CDN serving something else; it does not help if the vendor's release itself is compromised (the signed Claude Code manifest does cover that). That is the same assurance node,snipandghalready had. Bumping the signing key is a manual edit ofclaude_signing_fprwhen Anthropic rotates it. - The devcontainer base image and Dockerfile frontend are pinned by
digest.
.devcontainer/DockerfilehasFROM mcr.microsoft.com/devcontainers/base:trixie@sha256:...and a pinned# syntax=line, both written literally (the formerDEBIAN_VERSIONARG is gone), so Renovate'sdockerfilemanager can keep tag and digest together. To move to another Debian release, change the tag; Renovate re-pins the digest. - Accepted (Low): the MCP server's transitive dependencies.
.mcp.jsonpins@waldzellai/clear-thought-onepointfiveto an exact version, butnpxhas no lockfile support, the package ships nonpm-shrinkwrap.json, and its own dependencies are ranges (zod,marked,@modelcontextprotocol/sdk, ...). Those resolve at each session start in the credentialed devcontainer. Mitigations are the exact top-level pin, Renovate bumping it, and the 3-day age gate on that bump; the transitive set is not pinned. An instance that wants it pinned vendors the server (a lockfile andnpm ci) instead.
Run everything at once with:
prek run --all-files --hook-stage manualEvery hook except the commit-message check carries the manual stage,
so the command above runs everything else regardless of which git hook
would normally trigger it. Commit messages can only be checked by
actually committing (see the comment in .pre-commit-config.yaml).
CI (.github/workflows/checks.yml) runs the same --hook-stage manual
command, inside the devcontainer itself, on every push and pull request
— as an amd64/arm64 matrix, both legs native (no QEMU).
.github/workflows/release.yml is triggered manually to cut an
alpha/beta/rc/full release — see "Release: a Makefile contract" below and
.github/workflows/README.md.
release.yml computes the next SemVer tag (compute_next_version.py,
unchanged across every instance), then drives these targets:
| Target | Runs | Where | Env available |
|---|---|---|---|
release-arches |
once, first | host runner | none; must print a JSON array on stdout, e.g. ["amd64"] |
build, sbom, release-assets, publish |
once per arch, in that order | host runner for that arch | RELEASE_VERSION, RELEASE_TAG, RELEASE_ARCH, OCI_REGISTRY, OCI_IMAGE_NAME, OCI_REGISTRY_USERNAME, OCI_REGISTRY_PASSWORD, all GITHUB_* (GITHUB_TOKEN has packages: write); the registry credentials come from the release environment; no package-registry variables or secrets, and no OIDC token |
release-check |
once, after all arches; gates the GitHub release and publish-packages (not the per-arch image publish, which already ran in build) |
inside the devcontainer (devcontainers/ci), stack up |
RELEASE_VERSION, RELEASE_TAG, GITHUB_REPOSITORY; not RELEASE_ARCH (it runs once, not per arch) and no ARCHES: to check each arch's files in dist/, iterate over make -s release-arches |
finalize |
once, after release-check; then the GitHub release is created |
host runner | same as per-arch minus RELEASE_ARCH (GITHUB_TOKEN also has packages: write) |
publish-packages |
once, after the GitHub release exists, only if PYPI_PUBLISH, NPM_PUBLISH or CARGO_PUBLISH is set |
host runner (own job, with id-token: write, in the release environment) |
RELEASE_VERSION, RELEASE_TAG, PYPI_PUBLISH, NPM_PUBLISH, CARGO_PUBLISH, all GITHUB_*, the OIDC token, NPM_TOKEN and CARGO_REGISTRY_TOKEN (bootstrap secrets; empty when unset, so treat empty as absent); dist/ holds every arch leg's files |
verify-published |
once, after publish-packages succeeded, only if a *_PUBLISH variable is set |
host runner (own job, no secrets, no OIDC token) | RELEASE_VERSION, RELEASE_TAG, PYPI_PUBLISH, NPM_PUBLISH, CARGO_PUBLISH; dist/ as for publish-packages |
What each target is for: build produces the release artifact(s) (an
image, cargo build --release, npm pack, helm package, ...); sbom
writes an SBOM for what build produced; release-assets populates
dist/ (gitignored) with every file to attach to the GitHub release;
publish pushes the per-arch image to its registry, skipping cleanly when
OCI_REGISTRY isn't set; publish-packages uploads packages (PyPI, npm,
crates.io) once per release, never once per arch, and runs in a job that
exists only for instances that set one of the *_PUBLISH variables, so
an image-only instance never has id-token: write or a package secret
(base's default fails if any *_PUBLISH variable is set, so an instance
can't silently skip it; with none set it is a no-op; it runs on a fresh runner with a checkout and the
downloaded dist/ only, so it uploads from dist/ and sets up its own
tooling); verify-published checks the release from the registry (e.g.
installs the just-published package) and is a no-op in base's default, since
there is nothing to verify unless an instance implements it;
release-check runs checks that need the backing services (e.g. tests
producing a coverage report) and the local artifact; finalize
does cross-arch work such as combining per-arch registry tags into one
multi-arch manifest list. RELEASE_VERSION (e.g. 1.2.3 or
1.2.3-alpha.1) and RELEASE_TAG (the same, v-prefixed) are for
embedding or tagging with the version. A single-arch no-op is the
default: release-arches prints ["amd64"].
Registry credentials for publish and finalize. The per-arch job's
and the release job's GITHUB_TOKEN have packages: write, so an
instance's publish and finalize (e.g. a multi-arch manifest list) may
push to the repository's own GHCR packages (ghcr.io/<owner>/<repo>...) without
OCI_REGISTRY_USERNAME/OCI_REGISTRY_PASSWORD: log in with username
GITHUB_ACTOR and password GITHUB_TOKEN. A package that the workflow
creates inherits the repository's permissions. A package that already
exists and isn't linked to the repository first needs the repository added
under the package's "Manage Actions access" settings, with write access.
The explicit secrets remain the way to reach any other registry, or a
package outside that repository's reach, and an instance that sets them
should prefer them. Base's own publish and finalize are no-ops and log
in nowhere, so each instance implements this fallback in its own
Makefile. Trade-off: the token is in the job env, so every per-arch
target (build, sbom, release-assets), not only publish, can write
those packages.
Upgrading a package instance. Before this contract, publish also
uploaded packages, once per arch. Now publish is image-only and gets no
*_PUBLISH variables, secrets or OIDC token. An instance that publishes
packages must move that upload from publish into a new
publish-packages target (reading artifacts from dist/, setting up its
own tooling). Until it does, base's default publish-packages (if the
instance kept it) fails with a message naming the variable that is set;
an instance whose Makefile has no such target fails with make: No rule to make target. Either way nothing is uploaded, and because the GitHub
release is created first (see "Job order"), the release already exists
when the publish-packages job fails: it is out without its packages.
Fix the Makefile and re-run the failed jobs to publish them; do not cut
another release for it.
Job order. build (per arch) → release-check → release
(finalize, then the GitHub release) → publish-packages →
verify-published. release-check gates both the release and package publishing. It does
not gate the per-arch image publish, which runs inside build, so a pushed
per-arch image can exist for a release that release-check then fails.
The GitHub release exists before any registry upload, so a partial
registry failure leaves the release in place and a re-run of the failed
job finishes publishing. So an instance's publish-packages must be
safe to run again: it skips versions already on the registry (for
example uv publish --check-url, or an npm view guard) instead of failing
on them or double-publishing.
The release job is re-runnable too: if the release already exists it
skips finalize and the release creation (a leftover draft is deleted and
recreated), so re-running failed jobs after a later job failed does not
fail on the existing release. It first checks that the existing
release's tag points at the commit this run built, and fails (rather than
warns) if the tag can't be resolved or points elsewhere
(.github/scripts/verify_release_tag.sh). The tag lookup is tried up to six
times over about two minutes (waits of 5, 10, 20, 40 and 60 seconds, enough
for a minute-long secondary rate limit), but only for a transient failure
(HTTP 5xx, 408 or 429, a rate limit, or a network error). A tag that does not
resolve (the API answers 404, or 422 "No commit found") or any other permanent
error fails at once. The 404 message names both causes: for a deleted tag,
restore it or delete the release and run the workflow again; for a release
that was only just created (say by a concurrent run) whose tag is not visible
yet, re-run the job. An API answer without a commit sha is reported as such,
not as an unresolvable tag. If release itself failed before the release
existed, finalize runs again on the re-run, so an instance's finalize
must be safe to repeat (it must not double-apply its side effects).
Because the release is created before publish-packages runs, a GitHub
release can exist for a version whose publish-packages failed. Re-run the
failed jobs to finish publishing; do not delete the release.
verify-published runs only after publish-packages succeeded, so after a
partial publish-packages failure nothing is verified until the re-run of
the failed jobs, which then runs it.
Two runs for the same tag cannot overlap in the release job: it sits in a
concurrency group named after the tag (cancel-in-progress: false), so the
second waits and then sees the published release. If that release was cut
from a different commit than the waiting run's (two dispatches that computed
the same tag), the waiting run fails instead of publishing its own dist/
under that version (detected only here, after the per-arch images of both runs were
pushed). GitHub keeps at most one waiting run per group, and a
newer waiting run replaces it.
A release that exists on GitHub counts as complete: whatever failed after it is finished by re-running the failed jobs, never by cutting another version.
Opting into a registry. The PYPI_PUBLISH (testpypi or pypi),
NPM_PUBLISH and CARGO_PUBLISH repository variables are what opt an
instance into a registry; with none set, publish-packages and
verify-published do not run. NPM_TOKEN and CARGO_REGISTRY_TOKEN are
only for the first release of a package, before trusted publishing is set up
at the registry. See .github/workflows/README.md,
"Release variables and secrets".
Re-run only the failed jobs of a run. "Re-run all jobs" runs version
again, which sees the tag the first attempt created and computes the next
version, so it cuts another release instead of finishing the first.
In release-check, if dist/ holds files owned by another user (the
devcontainer ran as root), the job takes them over with sudo, which must
be passwordless (sudo -n); the runner images provide that. A self-hosted
runner without it fails there with a message saying so.
Package templates (npm, Python, Rust) move their
registry check ("install what was just published") from release-check to
verify-published; their publish-packages is unchanged and already skips
versions already on the registry. Base's
release-check no longer receives the *_PUBLISH variables.
Package instance checklist. An instance that publishes a package also:
sets repository, homepage and bugs URLs in its package manifest
(package.json, pyproject.toml, Cargo.toml) -- npm provenance
verifies the repository URL against the publishing repository, and the
other registries show these links to users; and implements publish-packages
(re-runnable: it skips versions already on the registry) in its own Makefile, which replaces base's default (the
DefaultPublishPackagesTest in .github/scripts skips itself once the
default message is gone). That only works where the Makefile is
instance-owned, as in base. A downstream template that makes the Makefile
replace-tier (an image template, say) gives its instances no place to
implement it, so such a template either ships the publish-packages logic
itself, driven by variables the instance sets, or is not a fit for package
instances. release-check receives
GITHUB_REPOSITORY, so an instance can run repository guards there.
It also moves any "install what was just published" check from
release-check to verify-published (see "Job order" above).
In this template (Python). publish is a no-op and publish-packages
runs uv publish --trusted-publishing always on the wheel and sdist
make build already put in dist/ (nothing is rebuilt; the SBOM stays
out of the upload). The job is a bare runner with no uv, so the
Makefile uses the pinned uv image there, with the OIDC variables
forwarded. PYPI_PUBLISH unset prints a ::warning:: and succeeds (the
job only exists when some *_PUBLISH variable is set, e.g. only
NPM_PUBLISH); any value but pypi or testpypi fails.
To upgrade a Python package instance created from this template, sync to
this release: the Makefile is template-owned (replace), so it brings
publish-packages, verify-published and scripts/release-verify-published.sh
with it, as well as base's release.yml (release first, then publish; see
"Job order" above). Then:
(1) the PyPI trusted publisher's workflow name stays release.yml, but
the job that uploads is now publish-packages; if the publisher
restricts the job or an environment, update it; (2) if the instance
overrode publish locally, move that upload into publish-packages;
(3) if the instance has its own tests/ that assert the old publish,
update them; (4) the registry install check moved from release-check to
verify-published (a Makefile that overrides either target locally needs the
same move); (5) check a release with testpypi before relying on it.
dist/ rules: each arch leg's dist/ is uploaded as artifact
release-assets-<arch> (a tarball, so it is never empty), and
release-check's job assembles all of them into one dist/, failing if an
arch's upload is missing or two legs produced the same file name, so
per-arch file names must include the arch. release-check may add
more files to dist/, and the assembled dist/ (uploaded at the end of the
release-check job, before finalize) is what release,
publish-packages and verify-published start from. finalize may add
more files, but they are attached to the GitHub release only:
publish-packages and verify-published do not see them. A path that two
legs both produce (the same file name, a file in one and a directory of
that name in another, or a symlink) fails the assembly. The assembly also
refuses to run when dist/ already exists and is not empty, so stray files
cannot end up in a release.
release.yml then runs gh release create with every file left in
dist/.
The arch legs run natively, not under QEMU: arm64 on ubuntu-24.04-arm,
amd64 on ubuntu-24.04, each overridable with the CI_RUNNER_ARM64 /
CI_RUNNER_AMD64 repository/organization variables.
This repo's own Makefile implements every target as a documented
no-op, since template-base ships no artifact — it's both a working
example of the contract's shape and what release.yml needs to run
cleanly if this repo itself is ever released. An instance's Makefile is
not template-owned (it's the whole point that it differs per artifact
type), so it's tracked like any other instance-owned file — untouched by
this template's own sync manifest.
Once instantiated, a repo created from this template can pull in later
template fixes/improvements via .github/workflows/template-sync.yml
(itself template-owned, replace-tier): on a schedule (cadence set by
the TEMPLATE_SYNC_INTERVAL repository variable, weekly or monthly)
or on demand, it diffs the instance against the template's latest tagged
release, per .github/template-sync-manifest.yml's three tiers
(replace, ignore, merge — see that file's header), and opens a PR
with the result. It never pushes directly or auto-merges; a genuine
merge-tier conflict is left with <<<<<<< markers for a human to
resolve. An instance that predates this workflow bootstraps its
.github/template-sync-state.json via the workflow's manual
initial_sync_tag/template_repo inputs first.
The workflow runs template_sync.py apply from the new tag's checkout,
with that tag's manifest. Do the same when applying a sync by hand: the
instance's own copy of the script is the old release's, and an older one
does not expand glob entries such as scripts/release-*.sh, so it skips
those files (a second run with the new script then picks them up). apply
is meant to run once per tag pair: the state file moves to the new tag
afterwards. Running it again is harmless for files already merged cleanly,
but it merges a hand-resolved conflict again and re-creates its markers.
Further sync behaviour, detailed in .github/scripts/README.md:
applyis not atomic. CI discards the checkout and does not advance the state on an error; after a failed manual run, reset the working tree with git before trying again.- A binary
merge-tier file the instance changed cannot be merged. It is kept as it is, reported as a conflict, listed asmerge.binaryin the summary and labelled in the sync PR. - The moderation
applyjobs download themoderation-resultartifact strictly, so a lost artifact fails the job instead of passing silently (see.github/workflows/README.md, "Issue moderation"). - The issue-moderation workflows (
moderate-*.yml) keep Claude read-only and do every write in a separateapplyjob, now for triage as well as fix/build (seedocs/adrs/0001-harden-the-issue-moderation-workflows.mdin template-base and.github/workflows/README.md). What an instance sees:ANTHROPIC_API_KEYis passed through the process environment only, never.secrets/claude.txt; Claude's git andghtools are narrowed, andMODERATION_EXTRA_TOOLSis the way to add more; a fix or plan that changes CI, devcontainer, hook,Makefile/*.mk,Dockerfile*,compose*.y*mlor release files ends as aneeds-humannote instead of a PR; and the PR base is the default branch. Claude's commands run in Claude Code's sandbox (the image installsbubblewrapandsocat) with a network allow-list;MODERATION_EXTRA_DOMAINSadds hosts (e.g. a package registry), and the pipeline fails closed if the sandbox cannot start. An instance whose Makefile or hook configuration the agent needs to change does that by hand. - The scripts under
.github/scripts/run on the runner's systempython3. Base ships.github/scripts/ruff.toml(replacetier), pinned to that Python'starget-version, andtest_script_syntax.pyguards it withast.parse(feature_version=...). A Python instance must not override or remove it: its ownpyproject.tomltargets a newer Python and ruff would rewrite those scripts into syntax the runner cannot parse.
An instance that isn't itself a template shouldn't edit that manifest to
list its own files (src/, say): it's merge-tier, so edits would
conflict with later syncs. It lists them under an ignore: key in the
instance-owned
.github/template-sync-manifest.local.yml instead; the
template-sync-manifest check merges that file in, but sync never reads
it, and it accepts no other tier.
A repo can itself be both an instance of template-base and its own
template for further instances (e.g. template-fastapi): its own
.github/template-sync-manifest.yml classifies its own tracked files
for its downstream instances, entirely separate from this template's
manifest (it edits the manifest directly and resolves base's additions by
hand) — see that repo's own docs for the two-hop chain this produces.
template-base is the root of a small family. Four mid-layer templates sit
under it, and each library or app is an instance of exactly one of them (the
three app templates through template-docker):
template-base
├── template-python-package ── lib-crud-py, lib-observability-py, lib-crud-sdk-py
├── template-npm-package ───── lib-crud-sdk-ts, lib-crud-views-ts
├── template-rust-crate ────── lib-crud-rs, lib-observability-rs
└── template-docker
├── template-fastapi
├── template-axum
└── template-react
Naming: a library repo is lib-<name>-<py|ts|rs> and an app repo is
template-<runtime>; the registry package is craftainer-<name> on PyPI and
crates.io and @craftainer/<name> on npm (lib-crud-sdk-ts publishes
@craftainer/crud-sdk). Libraries declare GPL-3.0-only in their package
manifest; base itself ships the GPL v3 text in LICENSE.
Each mid-layer template is an instance of base that is itself a template. It adds one kind of artifact to base's release contract and keeps everything else from base:
| Template | Adds to base | Tooling it owns (replace) |
What an instance owns (ignore) |
|---|---|---|---|
template-docker |
the container-image release: multi-arch build, SBOM, OCI labels, smoke test, Trivy scan, Dockerfile version check, and publish-packages/verify-published as no-ops |
Makefile, scripts/image-*.sh, scripts/runner-setup.sh, scripts/check-dockerfile-versions.sh, scripts/skopeo.sh |
Dockerfile, image.mk, scripts/runner-setup.d/*.sh |
template-python-package |
a pure-Python library on PyPI (trusted publishing) | Makefile, scripts/release-*.sh, scripts/post-create.d/10-uv-sync.sh, codspeed.yml |
pyproject.toml, uv.lock, src/, tests/ |
template-npm-package |
an ESM TypeScript package on npm (staged first publish, then trusted publishing) | scripts/release-*.sh, scripts/develop.d/10-corepack.sh, scripts/post-create.d/10-pnpm-install.sh |
package.json, tsconfig*.json, tsdown.config.ts, vitest.config.ts, src/, tests/, pnpm-lock.yaml, and the Makefile (a thin caller of the scripts) |
template-rust-crate |
a library crate on crates.io (trusted publishing, bootstrap-token fallback) | Makefile, scripts/release-*, rust-toolchain.toml, rustfmt.toml, the rustup and cargo fetch hooks |
Cargo.toml, Cargo.lock, src/, tests/, deny.toml |
Each template's own docs/TEMPLATE.md has the details and the exact
manifest; its docs/adrs/0001-*.md records why it exists. The three app
templates inherit the image layer from template-docker; they are still
the place for their own runtime (FastAPI, axum, React) and their own ADRs.
Sync tiers across the family. Every hop uses the same three tiers
(replace, ignore, merge; see "Template sync" and the manifest header).
A mid-layer classifies its own tree for its instances in its own
.github/template-sync-manifest.yml, which is merge tier in every
instance: base's additions are resolved by hand there.
In template-python-package, README.md is ignore tier: each package's
README is entirely its own, so a merge conflicted on every sync. The
instance owns it from creation, and later changes to the template's
README no longer reach instances by sync.
An instance lists extra paths of its own under ignore: in
.github/template-sync-manifest.local.yml. Which template an instance
follows is recorded in its .github/template-sync-state.json
(template_repo, synced_tag). An instance follows its immediate parent: a
package or image instance its mid-layer, and the mid-layer follows base.
Order of a change. A sync reads the parent's latest tagged release, so a change travels one released tag at a time:
- Fix it in
template-base, merge, and release a new tag. - Each mid-layer takes the base release through a sync PR, merges it and
releases its own tag. A base fix reaches
template-dockerand the three package templates this way; the app templates take it fromtemplate-dockerin step 3, after it has released. - Every instance takes its parent's tag through its own sync PR (the app
templates take
template-docker's; the libraries, their package template's).
A fix to a mid-layer's own files starts at step 2 and skips base. A change to the release contract itself (a new target, a changed job order) is a minor release of base, and each mid-layer then adapts its own implementation of the target before instances re-sync; the "Job order" above is the current result of two such changes. Instances that publish packages follow the registry's own ordering on top: a package that depends on another is released after it.
Every version and config value is defined in exactly one place; nothing
duplicates or re-pins it elsewhere. Everything pinned here (base image,
Actions, hook revisions, prek/Claude Code CLI/snip/gh/uv/Python/
Node.js versions) is pinned once, at its single point of use, to an exact
patch version — never a floating range or latest — so Renovate can
bump them one at a time and the diff shows exactly what changed. An
instance's own language runtime/package versions follow the same rule in
its own Dockerfile/manifest.
python3 and Node.js, both installed by scripts/develop.sh and pinned
via .devcontainer/Dockerfile's PYTHON_VERSION/NODE_VERSION ARGs, are
infrastructure tooling only — not an application runtime this template
assumes. python3 exists because prek needs an interpreter to build
the venv for any language: python hook (.pre-commit-config.yaml's
pre-commit-hooks repo) and because .github/scripts/*.py need one
directly; uv (its own UV_VERSION ARG) is only the mechanism used to
install that exact, checksum-verified CPython build rather than an
unpinned apt package, and stays on PATH afterward. Node.js exists
because npx (the clear-thought MCP server in .mcp.json) needs it.
An instance that adds Python or Node.js as its own application runtime
can reuse these directly rather than installing a second copy.
template-python-package extends template-base for a pure-Python library.
The sections above still apply; these are the package-specific additions.
- Package scaffold (instance-owned).
pyproject.toml,uv.lock,src/andtests/areignoretier: a package renames the distribution and the import package (see the rootREADME.md) and owns its dependencies, code, tests, benchmarks and import-linter contract. Tool versions are kept fresh by Renovate (pep621anduvmanagers, both on underconfig:recommended), not by sync. - Template-owned tooling (
replace). TheMakefile(release contract:uv build, Syft SBOM, trusted-publishinguv publishinpublish-packages),scripts/release-check.sh,scripts/release-verify-published.sh,scripts/post-create.d/10-uv-sync.shand.github/workflows/codspeed.yml. Don't edit them in a package; change them here and release a new tag. - Checks. The Python hooks in
.pre-commit-config.yaml(ruff,mypy --strict,import-linter,pytestwith the 100% coverage gate,uv lock --check,pip-audit) run throughuv run, soprek run --all-files --hook-stage manualis the one command, locally and inchecks.yml. Benchmarks live intests/bench/, excluded from the defaultpytestrun; run them withuv run pytest tests/bench --no-cov. - Adding backing services for integration tests. A package that needs a
service (for example
tool-crudand its Postgres) uses the compose hook, not a new compose file wired into the base one: add aninclude:of a stack fragment under.devcontainer/stack/, plus theenvironment(connection URL,UV_PROJECT_ENVIRONMENTif the project directory is a bind mount) anddepends_onfor thedevelopservice, to.devcontainer/compose.instance.yml(ignoretier, so it survives sync). Mark service-dependent tests with a pytest marker and keep them out of the default run unless the stack is up;release-checkruns inside the devcontainer with the stack available, so it's the place to run them in the release. If the services need extra CLI tooling, add it as ascripts/develop.d/NN-name.shhook and list its path underignore:in.github/template-sync-manifest.local.yml. - CodSpeed.
codspeed.ymlneeds the CodSpeed GitHub app installed on the repository and nothing else for a public repo (OIDC); see.github/workflows/README.md.
Every file gets a brief header stating what the file is for — one
line, sometimes two: a leading comment for most formats, a module
docstring where the language has one. Never describe the file's contents
in the header; that's what reading the file is for. Markdown files' own
title/opening line already serves this purpose. A format with no comment
syntax (.json) documents itself via the directory's README.md
instead.
A comment earns its place by saying something the code/config next to it can't: why it's written this way, a non-obvious consequence, or a constraint that isn't visible locally. Don't add a comment that just restates what the following line already says in code — if removing a comment loses no information, remove it. Every existing comment in this repository follows this rule; keep new ones held to the same bar.
- Read
CLAUDE.mdand theREADME.mdof every directory on the path to whatever you're changing before you change it. - Open this repo in the devcontainer rather than assembling the toolchain by hand — it's the one environment this template guarantees.
- Commit a
.envfile, or read one from application code — configuration lives in the compose files; secrets live in.secrets/. - Add a
ports:mapping or anetworks:block to any file under.devcontainer/. - Assume a language runtime, backing service, or release artifact exists in this template — every one of those is instance-owned.