ci is the set of reusable GitHub Actions workflows Latere's repositories
call: the per-push quality gate, the daily move of the gate's version pin,
and the tag-triggered releases of a Kubernetes service, signed images, a
command-line tool and a library. A repository keeps a short caller
workflow; the logic lives here and reaches every caller through the moving
@v1 tag.
This repository owns orchestration and ordering: which jobs run, on which
runner, in what order, with which credentials. What each quality gate
asserts lives in latere-ai/ci-gate,
the lateregate binary a repository pins in its own go.mod. What to
build, what to apply, and what "live" means stay in the consuming
repository, by convention rather than through a long list of inputs.
The pipelines are on version 1 and run the pushes and releases of most of
Latere's public repositories. A private repository starts no workflow on a
push or a tag; it runs the same steps from a workstation (see
Runners).
The releases list every
version. Callers pin @v1, which moves when a version is cut, so a change
reaches every caller at once; an input or a job can still change between
versions, and the changelog says what a caller must change.
A repository that needs to hold still pins a full version (see
Versioning). ci has had no external security audit.
| Workflow | Runs on | For | Jobs |
|---|---|---|---|
lateregate.yml |
every push and pull request | a Go repository | the plan, the gates (a job each on a hosted runner, one job on a self-hosted one), the test matrix, the wiring check |
ci-gate-bump.yml |
once a day | a public Go repository that pins latere.ai/x/ci-gate |
move the pin to the latest release when the bar passes on it, or open an issue |
service-release.yml |
a v* tag |
a Kubernetes service | build the image, deploy, smoke the live surface, publish the release |
image-build.yml, image-publish.yml, release-verify.yml |
a v* tag |
a service that publishes signed images and lets others install them | build and sign the images by digest; then, after the repository's own jobs, tag them and publish the release; then verify what was published |
image-deploy.yml |
a v* tag, between build and publish |
a core that deploys its own release | apply deploy/prod with the images pinned by digest, wait for the rollout, smoke the live service |
cli-release.yml |
a v* tag |
a command-line tool | the tagged commit's per-push run passed, tests on the extra runners in test_os, GoReleaser |
notes-release.yml |
a v* tag |
a module or library that builds nothing on a tag | publish the release |
Every release pipeline publishes the GitHub release last, with the tag's
section of CHANGELOG.md as its body; a tag without a section fails. See
A tag is a release.
examples/ holds a starter caller for each.
tools/repo-settings.sh applies the
organization's repository settings to a new repository.
You need Go 1.27 or newer, git, and the GitHub CLI signed in to an account that can create a public repository.
First make a Go module whose bar passes on your machine: follow
ci-gate's Quickstart to
its last command, go tool lateregate. Its go tool lateregate init step
wrote .github/workflows/ci.yml, which calls this repository's per-push
gate:
jobs:
gate:
uses: latere-ai/ci/.github/workflows/lateregate.yml@v1Then, from the same directory, push it to a new public repository and watch the run:
gh repo create hello --public --source . --push
sleep 10
gh run watch --exit-status "$(gh run list --workflow ci.yml --limit 1 --json databaseId -q '.[0].databaseId')"The run ends with the check gate / all gates passed, the one check to
require in branch protection. Adopting it adds the daily
pin move, a release caller and the repository settings.
flowchart LR
subgraph repo["Your repository"]
caller["caller workflows"]
pin["go.mod: the lateregate pin"]
conf[".lateregate.yaml, CHANGELOG.md, deploy/prod"]
end
subgraph ci["latere-ai/ci at @v1"]
wf["reusable workflows"]
act["actions/ scripts"]
end
gate["lateregate (latere-ai/ci-gate)"]
ghcr["GitHub Container Registry"]
k8s["Kubernetes cluster"]
rel["GitHub release"]
caller -->|uses| wf
wf --> act
wf -->|go tool lateregate| gate
gate -->|reads| pin
gate -->|reads| conf
act -->|"images by digest, signed, with SBOMs"| ghcr
act -->|"apply deploy/prod by digest, smoke"| k8s
act -->|"the tag's changelog section, last"| rel
- Your repository holds a caller per pipeline, the
lateregatepin, its gate configuration, its changelog and, for a service, the manifests it deploys. Starter callers are inexamples/. - The reusable workflows decide the jobs, their order, their runner and
their credentials. Each runs the scripts under
actions/at ci's own commit, so a caller cannot change what a step does. - lateregate, from ci-gate, is the bar: which gates apply, what each asserts, the changelog reader and the release cut. The workflows run the version the repository pins.
- The registry, the cluster and the release are where a release lands: images by digest with their signatures and SBOMs, the rollout of the pinned digests, and the release page, published last with the tag's section of the changelog.
| Adopting it | the files a new repository adds, in order |
| The per-push gate | lateregate.yml: its inputs, the plan, the jobs and the one required check |
| Moving the gate pin | ci-gate-bump.yml: the daily bump, its issues and its dispatch |
| A tag is a release | the changelog rule every release pipeline holds |
| Releasing a service | service-release.yml: what the repository provides, inputs, build modes, signatures and the deploy credential |
| Releasing signed images | image-build.yml, image-publish.yml, release-verify.yml, image-deploy.yml, and how to verify a release |
| Releasing a command-line tool | cli-release.yml and GoReleaser |
| Releasing a module or library | notes-release.yml |
| Runners, Run artifacts, Tag rules, Versioning, Supply chain | the rules every pipeline shares |
| Repository settings | tools/repo-settings.sh and the organization's policy |
examples/ |
a starter caller for each pipeline |
specs/ |
the design records behind the larger pipelines |
CHANGELOG.md |
what each version changed for a caller |
| ci-gate | what each gate asserts and how lateregate is configured |
For a new Go repository:
-
The per-push gate. Pin the gate binary and let it write the caller, the hooks, the
.gitignorelines and the changelog:go get -tool latere.ai/x/ci-gate/cmd/lateregate go tool lateregate init
Then declare the two decisions it cannot make, in
.lateregate.yaml:identity: role: none # a library or a tool; a service declares its own role license: spdx: MIT holder: Latere AI
go tool lateregate contractsays whether the wiring is in shape, andgo tool lateregateruns the bar locally exactly as CI will. -
The daily pin move, in a public repository. Copy
examples/ci-gate-bump.ymlto.github/workflows/ci-gate-bump.yml, give its cron a minute no other repository on the same runner uses, and keepworkflow_dispatch:among the triggers of the per-push caller, whichinitwrites; see Moving the gate pin. -
A release caller, chosen by what the repository ships: copy the matching file from
examples/to.github/workflows/release.ymland adjust its inputs. The sections below say what each pipeline needs from the repository. -
Repository settings. Run
tools/repo-settings.sh apply <repo>once when the repository is created; see Repository settings.
The per-push caller init writes is the whole CI configuration for the
gate:
name: ci
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }}
cancel-in-progress: true
permissions:
contents: read
actions: read
jobs:
gate:
uses: latere-ai/ci/.github/workflows/lateregate.yml@v1There are no per-gate inputs and no Makefile contract. The pipeline runs
go tool lateregate list -json and runs every gate the binary says
applies. Which gates apply is decided by the binary reading the tree (a
repository with no specs/ is not spec-linted), and the only way a gate
that applies does not run is a dated waiver in the repository's
.lateregate.yaml, which the plan reads and its log prints.
| Input | Default | Meaning |
|---|---|---|
go_version |
1.27 |
the Go toolchain |
test_os |
empty: the runs_on runner |
superseded by test.os in .lateregate.yaml, which the plan carries; used only when the plan has no os. The runners test runs on, as a JSON array |
runs_on |
the runner policy | the runner label for every other job |
gate_module |
empty: found by its tool line |
superseded by auto-detection (below); a value here wins. The directory of the module whose go.mod pins ci-gate, for a repository that keeps the tool out of its consumers' module graph; lateregate runs from there and checks the whole repository |
frontend_browser_package |
empty: no browser | superseded by frontend.browser in .lateregate.yaml, which the plan carries as a need; used only when the plan carries no needs. The directory of a Bun package whose tests drive Chromium; before the frontend gate, the package is installed there and Playwright installs Chromium with its system libraries |
golangci-lint's version is pinned in the binary, so there is no input for
it. A repository that cannot lint waives lint with a reason and a date
rather than turning the job off.
The plan is measured from the last green commit. The job that reads the
plan (which gates apply on a hosted runner, all gates passed on a
self-hosted one) first runs lateregate base -since green, which prints the
newest commit on the default branch with a successful push run of the
calling workflow that is an ancestor of the pushed commit. It then runs
lateregate list -json -since <that commit>, so a gate whose inputs did not
change since then is skipped with its reason in the log. The commit is in
the log and the step summary. Each gate then runs by name, one per job,
with the same -since, so a gate that holds parts of its own, such as
e2e's suites, skips the parts the change cannot reach.
When the base cannot be had, the whole tree is checked and the log says why.
That happens when the pinned lateregate has no base command, no run was
green, the API answered an error, or the caller grants no actions: read
(the workflow runs with the caller's token and asks for nothing more, since a
reusable workflow that asks for a permission its caller lacks fails at
startup; without the grant the API answers 403). A canceled run is never a
base.
The plan says what to install. Each running entry in the plan may carry
needs: node and bun install Node and Bun, and chromium:<dir> runs
bun install --frozen-lockfile and bunx playwright install --with-deps chromium in <dir>. A failed browser install does not fail its step's job:
the gate fails on the test that needs the browser. A hosted gate job installs
what its own gate needs; on a self-hosted runner all gates passed
installs what any running gate needs. With a pinned lateregate that has no
needs, enum-typescript and frontend get Node and Bun, and frontend
gets the browser from frontend_browser_package. The test entry's os
lists the systems test runs on; without it, test_os does. On the
self-hosted runner the test matrix needs the probe, which runs only when
test_os names another system, so a plan whose os names one the probe did
not run for fails all gates passed instead of dropping that system.
The module that pins the tool is found. With gate_module empty, each job
runs from the repository root when its go.mod has a tool line for
latere.ai/x/ci-gate/cmd/lateregate (alone or in a tool ( ) block),
otherwise from the one tracked go.mod that does. Several nested modules
with the line and none at the root fail the job and ask for gate_module;
none at all stays on the root, where lateregate contract reports the
missing pin.
On a hosted runner each gate gets a job:
| Job | Runs |
|---|---|
which gates apply |
lateregate list -json; its outputs are the job sets |
test (<os>) |
lateregate test on each runner in test_os, except runs_on once the plan has suite |
<gate> |
lateregate <gate>, one job per running gate; cover or suite uploads coverage.out |
wiring is in shape |
lateregate contract |
all gates passed |
after every other job, whatever they did, except in a canceled push run: passes when each one succeeded or was skipped, and fails on a failure or a cancellation |
On a self-hosted runner one job does all of it:
| Job | Runs |
|---|---|
all gates passed |
lateregate list -json, then every running gate one after another in plan order, test included when the plan has no suite, then lateregate contract; uploads coverage.out when cover or suite ran; fails when a gate failed or a job it waits for did, and is canceled with its run |
which gates apply, test (<os>) |
only when test_os names a runner other than runs_on: lateregate test on those runners, and all gates passed waits for them |
Branch protection requires one check: all gates passed. The gate jobs
are named for the gates the plan picks, so the set changes when a pin adds
or drops a gate, and a job that did not apply reports as skipped. This
job is always present under the same name, runs whatever the other jobs
did, and fails unless each of them succeeded or was skipped. A canceled
run cancels it, and it reports canceled: a push run is canceled when a
newer push supersedes it or GitHub delivers the same push twice, and on
a self-hosted runner, where the job is the gates, a canceled run of any
event stops them. A pull request run on a hosted runner is the exception:
there the job runs after a cancellation and fails. GitHub names a reusable
workflow's check after the caller's job id, so a caller whose job is
gate requires gate / all gates passed.
suite runs the test suite once with the race detector, coverage, the
temporary-directory check and the stripped PATH all on, in place of the
separate test, race, cover, tempdir and hermetic gates; with a
lateregate that predates it, those run one by one. suite runs once, on
runs_on. The other systems in test_os keep plain test, because
suite adds the race detector and coverage and a macOS minute bills at
ten times a Linux one.
On a self-hosted runner a job is a turn on one of a few runner slots, and
while every slot is busy each job a run adds is one more wait in the
queue. A probe, then the gate jobs, then the result check made three waits
a push, so there all gates passed reads the plan itself and runs every
gate in turn: each in a log group of its own, each run whether another
failed, each a row in the step summary with its result and time, and each
named in an annotation when it fails. A failed TypeScript setup fails
enum-typescript without running it and leaves the other gates to run. A
hosted runner keeps one job per gate, since its parallelism costs nothing.
Node and Bun are set up only when enum-typescript or frontend runs.
On a self-hosted label every job builds with GOFLAGS=-trimpath. Such a
runner keeps one Go build and test cache for all of its runner processes,
and each process checks out under a work directory of its own; without the
flag the cache keys a package on that directory and no process replays a
result another recorded. For the same reason only the lint gate gets the
runner process's own TMPDIR, which golangci-lint's machine-wide lock
needs: a cached test result is keyed on the TMPDIR the test read. A test
that finds repository files through runtime.Caller gets a
module-relative path under -trimpath; go test runs each package in its
own directory, so a path relative to it works on every runner.
The optional enum-go and enum-typescript gates run when the repository
declares enum domains in .lateregate.yaml. For enum-typescript the
workflow sets up Node 26 and Bun 1.4.4 and runs go tool lateregate enum-typescript-prepare before checking, which installs each configured
project's dependencies from its committed npm or Bun lockfile. Go gates
install no JavaScript tooling. Domain configuration is documented in
ci-gate's gate reference.
A repository pins latere.ai/x/ci-gate by exact version in its go.mod
tool directive, so the hooks on a laptop and CI run the same lateregate.
The pin moves only in a commit, and this pipeline makes that commit once a
day in a public repository. A private repository runs no GitHub Actions,
so its pin moves from a workstation after each ci-gate release, through
ci-gate's scripts/bump-private.sh (its
releasing guide):
# .github/workflows/ci-gate-bump.yml
name: ci-gate bump
on:
schedule:
- cron: "17 3 * * *"
workflow_dispatch:
permissions:
contents: write
issues: write
actions: write
jobs:
bump:
uses: latere-ai/ci/.github/workflows/ci-gate-bump.yml@v1The permissions block is mandatory: the pipeline pushes the pin, opens
issues, and dispatches the per-push gate, and the organization's token is
read-only by default.
| Job | Runs |
|---|---|
is the pin current |
asks the module proxy for the newest release above the pin, and closes the issues this pipeline opened for versions the pin has reached. When the pin is current, the run ends here: no gate runs. |
bump to vX.Y.Z |
go get -tool latere.ai/x/ci-gate/cmd/lateregate@vX.Y.Z and go mod tidy, then the whole bar on the result: every gate go tool lateregate runs, one after another, and lateregate contract. |
A pin kept in a nested module, such as internal/gate, is found on its
own, the way the per-push gate finds it: the one go.mod in the repository
with the lateregate tool line, the root's first. Every go command then
runs in that module and every gate checks the whole repository. Set
gate_module to the module's directory to name it instead, which wins over
what is found; two nested modules that pin the tool fail the run until it is
set.
When the bar passes, the job commits the gate module's go.mod and go.sum as
gate: ci-gate vX.Y.Z and pushes to the branch the run is on, which for
the schedule is the default branch. A branch that moved while the bar ran
is rebased onto, up to three attempts; when the new commits touched
go.mod, the pin is moved again on top of them. A push made with the
Actions token starts no workflow, so the job then dispatches the per-push
caller, the one workflow that calls lateregate.yml, on the new commit.
That is why the per-push caller needs workflow_dispatch: among its
triggers: without a run on the commit, lateregate release has no CI to
read. A repository that ships from a workstation (ship: in
.lateregate.yaml) and has no workflow calling lateregate.yml gets no
dispatch, since the bump has already run the whole bar on the new pin.
When the bar fails, nothing is pushed. The job opens one issue per version,
titled ci-gate vX.Y.Z does not pass the gate, with each failing gate's
output, and updates its body on every later run that still fails; one a
person closed stays closed. The run itself
stays green with a warning that links the issue: lateregate release
refuses to cut while the latest run of any workflow on the branch is red,
and a release the repository does not yet pass should not hold back
releases the current pin verifies. Fix the tree, with or without the pin in
the same commit; the next run moves the pin once the bar passes, and the
issue closes when the pin reaches the version. A release that would raise
the repository's go or toolchain line is reported the same way, since
which Go a repository builds with is decided by hand.
| Input | Default | Meaning |
|---|---|---|
go_version |
1.27 |
the Go toolchain; keep it the one the per-push gate runs, since vuln judges the standard library of the toolchain that runs it |
runs_on |
the runner policy | the runner label for both jobs |
gate_module |
empty: found by its tool line |
the directory of the module whose go.mod pins ci-gate, as above |
The bar runs as the per-push gate's jobs do: no service containers, a test
that needs Docker reaches the runner's daemon, lint gets the runner
process's own TMPDIR, and on a self-hosted label every gate builds with
GOFLAGS=-trimpath. The TypeScript enum gate gets Node and Bun as in
lateregate.yml. No credential stays in the checkout while the new release
and the repository's tests run; only the push step hands git the token.
Every release pipeline here reads the tag's section from the consuming
repository's CHANGELOG.md and publishes it as the release body. No
section fails the publish step before anything is created or edited. The
pipelines do not fall back to GitHub's generated notes: those are built
from pull request titles, and a repository that commits to main directly
would get a compare link and nothing else.
A level-two heading whose second word is the tag opens a section that runs
to the next level-two heading, so ## v1.2.3 - 2026-09-06 and ## v1.2.3
both name v1.2.3. ## Unreleased holds what the next tag will say;
write under it as work lands. The section says what changed for whoever
uses the release, not what was committed.
The reader is lateregate release-notes from latere-ai/ci-gate, run at
the version actions/lib/versions.sh pins, or
the one a caller passes as lateregate_version.
Pinning it here rather than reading the consumer's tool pin makes the
reader the same in every repository, including ones that pin no Go tool.
In a Go repository the same binary runs in the pre-push hook and refuses
the tag before it leaves the laptop, and go tool lateregate release vX.Y.Z moves the notes under Unreleased into the tag's section,
commits, tags and pushes. A repository with no Go keeps a root go.mod
that holds only the tool pin and runs the same command, as this one does.
major-tag.yml still moves v1 when a v1.MINOR.PATCH tag lands.
service-release.yml checks the section before it builds anything, so a
tag without one deploys nothing. Publishing is still the last step: a
release announces what is live.
# .github/workflows/release.yml
name: Release
on:
push:
tags: ['v*']
permissions:
contents: write
packages: write
actions: read
id-token: write
attestations: write
jobs:
release:
uses: latere-ai/ci/.github/workflows/service-release.yml@v1
with:
service: exampled
image: ghcr.io/latere-ai/exampled
namespace: example
url: https://example.latere.ai
title: Example
main_package: ./cmd/exampled
spa_embed_dir: internal/web/spa/dist
secrets: inheritThe permissions block is mandatory: a reusable workflow's token cannot
exceed the calling job's, and the organization defaults to read-only, so
without it the run fails at startup, with no logs, when the pipeline
pushes the image or creates the release. id-token: write lets the
pipeline sign the image with the run's OIDC identity, and
attestations: write lets it attest the image in a public repository; a
caller that grants neither fails at startup the same way.
The run is one job, so on a busy self-hosted runner a release waits for
a slot once rather than before every stage. Its steps run in order, and a
failed step skips the rest. The first is the shared verified-commit
step: the tagged commit's push runs of the per-push gate must have
passed, read and waited for exactly as in
image-build.yml,
so a tag on a commit whose CI failed, was canceled or never ran builds
and deploys nothing. The build pushes the image; in split mode it
first builds the frontend (with a typecheck) and the Go binary (after
go vet) on the runner. The pushed image is then signed and its SBOM
recorded; see Signatures and SBOMs. The deploy
applies deploy/prod/, rolls the
Deployment to the new image, runs after_roll when it is set, and drops
the kubeconfig. The smoke runs the repository's smoke script against the
live URL. When build_cli is set, command-line binaries build next. The
last step publishes the GitHub release with the changelog section, the
smoke evidence and the two SBOMs. The job runs in the production environment, so an
approval that environment requires comes before the build. The pipeline
does not run the test suite: the per-push gate on the tagged commit is the
verification, and lateregate release refuses to cut a tag while CI is
red.
Before anything is built, the shared release-preflight step refuses a
tag that carries build metadata (the image is tagged with the git tag, and
an image tag cannot hold +), a tag whose commit is not on the
repository's default branch (a tag pushed from a side branch, or one whose
branch push was rejected while the tag went out), and a tag with no
CHANGELOG.md section. Then rollback-guard refuses a tag once a newer
vX.Y.Z tag exists. A re-run repeats the build and the deploy, so
re-running an older release would push it as latest and roll production
back to it; the newer release carries its changes, and its notes can be
published by hand with gh release create.
| Convention | Purpose |
|---|---|
CHANGELOG.md |
one level-two section per tag; the section is the release body |
deploy/prod/ |
rollable manifests. With a kustomization.yaml the pipeline runs kubectl apply -k deploy/prod/, otherwise kubectl apply -f deploy/prod/. Everything here must be safe to re-apply on every release. |
deploy/bootstrap/ |
one-time or immutable manifests (the namespace, the rollout identity, a storage class). The pipeline never reads this directory. |
tools/smoke/release.sh |
the post-deploy smoke (smoke_script moves it). It reads BASE_URL, EXPECTED_ASSET, OUTPUT_MD and SERVICE_TOKEN, exits non-zero when the live surface is wrong, and writes a Markdown evidence block to OUTPUT_MD. |
| probes | the four paths of latere.ai/x/pkg/health, served by its handler: /livez (liveness; never depends on a dependency), /readyz (readiness; the body names the failing check), /version (version, commit, build_time), and /metrics where the service has any. Manifests probe /livez and /readyz; the smoke reads all three. |
Dockerfile.ci |
in split mode, packages the prebuilt binary out/<service> into the runtime image; the build step compiles it (and embeds the frontend) first |
frontend/ |
for a service with a UI: built with bun run build into frontend/dist/ |
The Deployment and its main container are both named <service> in
<namespace> unless deployment and container say otherwise, so the
pipeline can kubectl set image without reading the manifests.
| Input | Default | Meaning |
|---|---|---|
service |
required | the Deployment, container and binary name |
image |
required | the image path without a tag |
namespace |
required | the Kubernetes namespace |
url |
required | the public production URL; the smoke's BASE_URL |
title |
required | the release title prefix: Lux gives Lux v1.2.3 |
platforms |
linux/amd64 |
the platform the image is built for, which is the production cluster's, whatever the runner is; split mode takes exactly one linux/<arch> entry, since the binary is compiled on the runner for it |
build_mode |
split |
split or dockerfile; see below |
has_frontend |
true |
split only: build the frontend and pin the served asset hash |
frontend_dir |
frontend |
the frontend directory |
spa_embed_dir |
empty | where the built frontend is copied before the Go build embeds it; empty skips the embed |
main_package |
. |
the Go package built into the service binary |
dockerfile |
Dockerfile.ci |
the Dockerfile that builds the image |
deployment, container |
service |
the Deployment, and the container in it, that get the new image |
rollout_timeout |
180s |
kubectl rollout status timeout |
preflight |
empty | a shell command run after the kubeconfig is written and before the apply |
after_roll |
empty | a shell command run once the Deployment has rolled, with the same kubeconfig, before the smoke. Use it for a second tree whose workloads call this service and so must roll after it. A failure fails the job, and neither the smoke nor the release runs |
smoke_script |
tools/smoke/release.sh |
the post-deploy smoke |
smoke_scope |
empty | an OAuth scope requested with the smoke token |
build_cli, cli_build_cmd |
false, empty |
also build command-line binaries (the command writes them under dist/) and attach them to the release |
verify_workflow |
empty: the workflow that calls lateregate.yml |
the per-push workflow whose run on the tagged commit must have succeeded |
wait_minutes |
empty: 60 on a GitHub-hosted runner, 0 on a self-hosted one |
how long the release waits for that run while it is still going; "0" refuses at once and prints the command that re-runs the release |
skip_paths |
specs/*, *.md |
paths that change no code; a commit that changes only these is read through to its parent. A repository whose gate ignores more paths lists them here too |
go_version, bun_version |
1.27, 1.4.4 |
toolchains |
kubectl_version |
1.35.1 |
fetched, checksum-verified, when the runner has none |
gh_version |
empty: versions.sh |
fetched, checksum-verified, when the runner has none |
cosign_version, syft_version |
empty: versions.sh |
the image signer and the SBOM generator |
lateregate_version |
empty: versions.sh |
the ci-gate release whose release-notes reads the changelog |
runs_on |
the runner policy | the runner label for the job |
| Secret | Meaning |
|---|---|
DEPLOY_KUBECONFIG |
the kubeconfig the deploy applies with; see Deploy credential. Without it the image still builds and publishes, and the deploy fails naming the secret. |
SMOKE_BEARER |
a pre-minted token the smoke presents |
SMOKE_CLIENT_ID, SMOKE_CLIENT_SECRET |
a client the smoke mints a short-lived client_credentials token with, at https://auth.latere.ai/token, with the service input as the audience |
With neither smoke credential the smoke runs without a token. A repository
whose secret names differ passes them explicitly instead of inherit,
mapping them onto these names, and then passes DEPLOY_KUBECONFIG by hand
too, since inherit cannot be mixed with explicit secrets.
split(default): CI builds the frontend and the Go binary, thenDockerfile.cicopies the prebuiltout/<service>into the runtime image. The build records the hash of the Vite asset it produced, and the smoke checks that the live service serves that exact bundle.dockerfile: oneDockerfilebuilds everything; setdockerfile: Dockerfile. Less wiring per repository, but the frontend builds inside the image, so there is no asset-hash pin: the smoke proves the service responds and serves a single-page app, not that this bundle is live.
Prefer split for a service with a frontend when the asset pin matters.
Both modes build, publish and smoke the image in one job, so no artifact
storage is needed; the asset hash reaches the smoke as a step output.
Live means this exact build is serving, not merely that something returns
200. The smoke compares the served asset hash (EXPECTED_ASSET) with the
bundle CI just built, and reads /version to compare the served
version with the tag. The release is published only after that smoke
passes.
The image is signed by digest right after the push, before anything deploys. Signing is keyless: cosign trades the run's GitHub OIDC token for a short-lived certificate that names this workflow and the caller's repository, tag and commit, and stores the signature in the registry beside the image. The certificate and the signature are also recorded in the public Sigstore transparency log, so a private repository's name and its release tags become public there; its code and its dependency list do not. To check an image:
cosign verify ghcr.io/latere-ai/exampled@sha256:<digest> \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity-regexp '^https://github\.com/latere-ai/ci/\.github/workflows/service-release\.yml@' \
--certificate-github-workflow-repository latere-ai/exampleEvery release carries two CycloneDX 1.6 SBOMs as assets.
sbom-source.cdx.json lists what the lockfiles in the checkout pin,
development packages included, since they run in the job that holds the
deploy credential. sbom-<image>.cdx.json (sbom-exampled.cdx.json for
ghcr.io/latere-ai/exampled) lists what the image holds: the Go modules
compiled into the binary and the base image's packages. The signing, the
image SBOM and the attestations are the shared sign-image step, the same
one image-build.yml runs.
A public caller also gets GitHub artifact attestations of the image's
provenance and SBOM, pushed to the registry and checked with
gh attestation verify oci://<image>@<digest> -R latere-ai/<repo>. A
private caller does not: GitHub offers artifact attestations in private
repositories only on Enterprise Cloud, and an SBOM attested through the
public log would publish the dependency list.
secrets: inherit passes the repository secret DEPLOY_KUBECONFIG, the
kubeconfig the deploy applies the release with. It belongs to the
service's own rollout identity: a ServiceAccount in the service's
namespace, bound to a Role over the kinds deploy/prod applies, kept in
the repository at deploy/bootstrap/rollout-identity.yaml and applied by
an operator. The job holds no cloud-provider credential, so a leaked
kubeconfig reaches one namespace's workloads rather than the account.
The identity is a ServiceAccount, a Role and RoleBinding in each namespace
deploy/prod writes to, and a kubernetes.io/service-account-token
Secret bound to the account; latere-ai/origo's
deploy/bootstrap/rollout-identity.yaml is the reference. The Role lists
get, list, create, update, patch on each kind deploy/prod contains and
watch on Deployments, which is what apply, set image and rollout status use: rollout status reads the rollout's state from the
Deployment's status and reads no ReplicaSet or Pod. It holds nothing on
Secrets, Namespaces, or RBAC, and no delete. A kind that is not
listed fails the apply with Forbidden, which is the intended failure: add the
kind to the Role and re-apply it. An after_roll command runs as the same
identity, so the kinds it applies belong in the Role too.
RBAC objects and cluster-scoped objects do not belong in deploy/prod. A
subject can write a Role or ClusterRole only with the grants it already
holds, so a pipeline able to write one could give itself anything; they
live in deploy/bootstrap beside the identity.
The kubeconfig is built from the token Secret, set on the repository, and not kept anywhere else:
kubectl apply -f deploy/bootstrap/rollout-identity.yaml
NS=<namespace> SA=<service>-rollout
kubeconfig=$(mktemp) && chmod 600 "$kubeconfig"
KUBECONFIG="$kubeconfig" kubectl config set-cluster production \
--server="$(kubectl config view --minify -o jsonpath='{.clusters[0].cluster.server}')"
KUBECONFIG="$kubeconfig" kubectl config set clusters.production.certificate-authority-data \
"$(kubectl -n "$NS" get secret "$SA-token" -o jsonpath='{.data.ca\.crt}')"
KUBECONFIG="$kubeconfig" kubectl config set-credentials "$SA" \
--token="$(kubectl -n "$NS" get secret "$SA-token" -o jsonpath='{.data.token}' | base64 -d)"
KUBECONFIG="$kubeconfig" kubectl config set-context "$SA" \
--cluster=production --user="$SA" --namespace="$NS"
KUBECONFIG="$kubeconfig" kubectl config use-context "$SA"
KUBECONFIG="$kubeconfig" kubectl apply -k deploy/prod --dry-run=server # or -f
gh secret set DEPLOY_KUBECONFIG --repo <owner>/<repo> < "$kubeconfig"
rm "$kubeconfig"The server-side dry run authorizes every object the release applies without changing any, so a Role that misses a kind fails here rather than in a release. Rotating the credential is deleting the token Secret, re-applying the identity file, and running the same steps.
Three stages a repository calls with its own jobs between them, and a fourth,
image-deploy.yml, for a core that also
deploys its own release, for a
service that publishes images other people pull and verify: up to three
images per release, built for linux/amd64 and linux/arm64, signed, with a
bill of materials each, and archives beside them when the repository builds
any. examples/image-release.yml is the
whole caller.
jobs:
build:
uses: latere-ai/ci/.github/workflows/image-build.yml@v1
permissions: { contents: read, actions: read, packages: write, id-token: write, attestations: write }
with:
verify_workflow: ci.yml # the per-push caller `lateregate init` writes
image_1: '{"name": "exampled", "version_args": "version"}'
conformance: # the repository's own, against the digests
needs: build
...
deploy: # only where the repository deploys its own release
needs: [build, conformance]
if: ${{ vars.EXAMPLE_RELEASE_DEPLOY != '' }}
uses: latere-ai/ci/.github/workflows/image-deploy.yml@v1
permissions: { contents: read }
with: { images: "${{ needs.build.outputs.images }}", deployments: exampled, url: "https://example.latere.ai" }
secrets: { DEPLOY_KUBECONFIG: "${{ secrets.EXAMPLE_KUBECONFIG }}" }
publish:
needs: [build, conformance, deploy]
if: ${{ always() && needs.build.result == 'success' && needs.conformance.result == 'success' && needs.deploy.result != 'failure' && needs.deploy.result != 'cancelled' }}
uses: latere-ai/ci/.github/workflows/image-publish.yml@v1
permissions: { contents: write, packages: write, id-token: write }
with:
images: ${{ needs.build.outputs.images }}
evidence: ${{ needs.deploy.result == 'success' && '*-evidence' || '' }}
verify:
needs: publish
if: ${{ always() && needs.publish.result == 'success' }}
uses: latere-ai/ci/.github/workflows/release-verify.yml@v1
permissions: { contents: read, packages: read, attestations: read }
with:
images: exampledimage-build.yml, one job, so a release queues on a self-hosted
runner once:
- The tagged commit's push runs of
verify_workflowpassed: one succeeded and none failed, on whichever branch the commit was pushed to, and a run that failed and was re-run to a pass counts as passed. Left empty,verify_workflowtakes the one workflow in.github/workflowsthat callslateregate.yml, such as theci.ymlthatlateregate initwrites; a repository with several names it. A run still going is waited for up towait_minutes: by default 60 minutes on a GitHub-hosted runner, while on a self-hosted runner, where waiting would hold a slot the awaited run needs, the release refuses at once and prints the command that re-runs it once the run is green. A canceled run refuses, since the commit was never verified. A commit that changes onlyskip_paths(specs/*and*.md; a file renamed out of a code path is a code change) carries its parent's code, so where its own run is missing, still going or canceled, its parent is read instead. That covers the commitlateregate releasecuts, which changesCHANGELOG.mdalone and is pushed with its tag, so its own run has only just started. A run that concluded on the commit itself still decides. Where the push runs are missing, still going or canceled, a successfulworkflow_dispatchrun of the same workflow on the default branch for that exact commit verifies it; that is the run the ci-gate bump starts for its pin commit, which a push made with the job's token does not run. A failed push run still refuses whatever a dispatched run concluded. Every other workflow the same branch push ran must have passed too, such as one holding a check the shared gate cannot host yet: one still running is waited for the same way, and a failed or canceled one refuses. A workflow whose run skipped every job, or that did not run on the commit, has no say. - The tag carries no build metadata, is on the default branch, and has a
CHANGELOG.mdsection. The namespace isnamespace, else the repository variableRELEASE_IMAGE_NAMESPACE, else the repository owner, lowered, so a fork's tag publishes under the fork. archives_command, when set, writes the files the release attaches intodist/.- Each of
image_1toimage_3is built from its JSON (name, and optionallydockerfile,context,platforms,build_args,version_args,version_expect,sbom_format) withVERSION,COMMITandDATEas build arguments, pushed by digest under no tag, asked for its version (version_args; it must printversion_expect, default{name} {tag}), signed, and given an SPDX bill of materials indist/. A public repository also gets GitHub's SBOM and provenance attestations; a private one does not, since GitHub offers them in private repositories only on Enterprise Cloud. after_images_command, when set, runs withIMAGE_1toIMAGE_3(<ref>@<digest>), for a deploy archive that pins the digests.
Outputs: owner, ref_1 to ref_3, digest_1 to digest_3, and
images, every <ref>@<digest> one per line. The calling job grants
contents: read, actions: read (the verify runs), packages: write,
id-token: write and attestations: write; one that grants less fails
before it starts.
image-publish.yml tags each digest with the release tag, writes
checksums.txt over every file in dist/ (and the run artifacts
artifacts names) and signs it, and publishes the release: the body is the
tag's CHANGELOG.md section with the Markdown files of the evidence
artifacts appended, created as a draft, its files attached, then published.
A re-run edits the release rather than failing on it.
release-verify.yml checks the published release with no checkout:
checksums.txt is signed for this repository and the same check naming
another repository is refused, every asset matches it, each image's tag
resolves to a signed digest (and attested, in a public repository), and the
body opens with the CHANGELOG.md section.
The stage a core calls between image-build.yml and image-publish.yml to
apply its production manifests and roll it, for a core that deploys from its
own release. It is the deploy job arca and origo carried, for any number of
images and Deployments.
deploy:
needs: [build, conformance]
# The caller decides whether to deploy at all. The stage reads no
# repository variable, so a fork that sets nothing skips the job.
if: ${{ vars.ARCA_RELEASE_DEPLOY != '' }}
uses: latere-ai/ci/.github/workflows/image-deploy.yml@v1
permissions:
contents: read
with:
images: ${{ needs.build.outputs.images }}
deployments: |
arcad
arcad-reaper
url: https://api.latere.ai
secrets:
DEPLOY_KUBECONFIG: ${{ secrets.ARCA_KUBECONFIG }}
publish:
needs: [build, conformance, deploy]
# deploy is skipped wherever the variable is unset, and a job that
# leans on the implicit success() is skipped whenever a job above it is.
if: ${{ always() && needs.build.result == 'success' && needs.conformance.result == 'success' && needs.deploy.result != 'failure' && needs.deploy.result != 'cancelled' }}
uses: latere-ai/ci/.github/workflows/image-publish.yml@v1
...
with:
images: ${{ needs.build.outputs.images }}
evidence: '*-evidence'One job, in the production environment with url as its URL, so the
rollout is a GitHub deployment and the environment's required reviewers
hold the run before anything is applied. In order:
- No newer
vX.Y.Zis tagged (the rollback guard), so a re-run of an older tag cannot roll production back. This is before the kubeconfig is written. - kubectl and kustomize are on
PATHat the pinned versions, fetched with their published checksums where the runner has none (kubectl) or has another version (kustomize). DEPLOY_KUBECONFIGis written to a mode 600 file underRUNNER_TEMP.- Each line of
imagesis set indeploy_dir'skustomization.yamlas<ref>@<digest>withkustomize edit set image, which replaces the committed tag. The overlay is rendered and must run every digest, so an overlay that names no such image fails here rather than rolling an old one. kubectl apply -k deploy_dir.kubectl rollout statusfor each line ofdeployments, in order, each withinrollout_timeout.after_roll, when set.- The kubeconfig is removed, in a step that runs whatever happened.
- The smoke script, repeated until it passes or
smoke_timeoutends. - The smoke evidence is uploaded as the run artifact
smoke-evidence.
| Input | Default | |
|---|---|---|
images |
required | <ref>@<digest> per line: image-build.yml's images output, or its ref_N@digest_N for a subset. A line <name>=<ref>@<digest> sets the image the overlay names <name>, for a ref that differs from the committed name (a fork's packages). One to three images in practice; cella's several and lectio's lectiod and lectio-convert are lines. |
deployments |
required | The Deployments to wait for, one per line, with or without a deployment/ prefix. lectio lists four, arca two. |
url |
required | The environment's URL and the smoke's BASE_URL. |
deploy_dir |
deploy/prod |
The overlay: a directory with a kustomization.yaml, holding only manifests that can be applied again. |
namespace |
empty | Empty takes the one namespace the rendered objects name, and fails when there are two. |
rollout_timeout |
600s |
Per Deployment, as kubectl spells it. |
after_roll |
empty | A shell command run after the rollout, as the same identity, under set -euo pipefail, with DEPLOY_NAMESPACE set. A failure fails the job before the smoke. |
smoke_script |
tools/smoke/release.sh |
Run from the checkout. |
smoke_timeout, smoke_interval |
120, 5 |
Seconds. See the smoke contract. |
evidence_name |
smoke-evidence |
The artifact's name. |
runs_on |
the runner policy | As on every stage (Runners). |
kubectl_version, kustomize_version |
empty | Empty takes the pin in actions/lib/versions.sh. |
Output: evidence, the smoke's Markdown.
The secret. The stage's secret is DEPLOY_KUBECONFIG, the name
service-release.yml uses, built as in Deploy credential.
A called workflow sees the secrets its caller passes: one mapped to a name
it declares, or under secrets: inherit every secret the caller has. A
caller whose secret has another name maps it, as above:
DEPLOY_KUBECONFIG: ${{ secrets.ARCA_KUBECONFIG }} for arca,
${{ secrets.ORIGO_KUBECONFIG }} for origo, with no secret renamed. A caller
whose secret is already named DEPLOY_KUBECONFIG still maps it, since
secrets: inherit would hand the stage every other secret too.
The secret is required: a call that maps nothing fails when it starts,
naming it.
The smoke contract. smoke_script is an executable in the checkout. It
is run with BASE_URL (url), TAG (the release tag), COMMIT (the
tagged commit), DEPLOY_URL (this run) and OUTPUT_MD set, and writes its
Markdown evidence to OUTPUT_MD. It exits non-zero until the service serves
TAG, and it is safe to run again, because the stage runs it again every
smoke_interval seconds until it passes or smoke_timeout seconds have
passed; 0 runs it once. An attempt still running when smoke_timeout
is up is stopped. The last run's OUTPUT_MD is the evidence, and it is
appended to the step summary, after a failed smoke too.
Recreate. A core on the Recreate strategy (limen) stops its only Pod
before the new one is ready, so the rollout can return before the ingress
answers with the new version, and requests in between fail or get the old
version. The retry covers that gap: raise smoke_timeout if the Pod takes
longer than two minutes to start.
The evidence reaches the release through the artifact: the caller's
image-publish.yml takes evidence: '*-evidence' and appends every .md
file in the matching artifacts to the release body, so the smoke follows a
conformance artifact that matches the same pattern. A pattern that matches
no file fails the publish, so a caller whose deploy can be skipped and
whose other jobs upload no evidence passes the pattern only when the deploy
ran: evidence: ${{ needs.deploy.result == 'success' && '*-evidence' || '' }}. A failure to store the
artifact fails the job, since nothing else carries the evidence.
What the rollout identity needs. The kubeconfig's ServiceAccount holds
get, list, create, update, patch on each kind deploy_dir contains and
watch on Deployments (see Deploy credential).
This stage does not run kubectl set image: the digest is in the applied
manifest, so the Deployment's patch is the apply's alone.
The signatures are keyless and name the stage that made them, as
service-release.yml's do. Any repository can call a public workflow, so
the identity names the pipeline, and the repository check binds the
source:
cosign verify ghcr.io/<owner>/<image>@sha256:<digest> \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity-regexp '^https://github\.com/latere-ai/ci/\.github/workflows/image-build\.yml@' \
--certificate-github-workflow-repository <owner>/<repository>
cosign verify-blob --bundle checksums.txt.sigstore.json \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity-regexp '^https://github\.com/latere-ai/ci/\.github/workflows/image-publish\.yml@' \
--certificate-github-workflow-repository <owner>/<repository> checksums.txt
sha256sum -c checksums.txt
gh attestation verify oci://ghcr.io/<owner>/<image>@sha256:<digest> \
-R <owner>/<repository> --signer-workflow latere-ai/ci/.github/workflows/image-build.ymlEach stage checks out this repository at its own commit
(job.workflow_sha) into .latere-ci/ beside the caller's checkout and
runs the composite actions under actions/ from there, so a
prerelease tag of ci runs its own steps, not v1's. The directory is
hidden from git (.git/info/exclude), so git describe, Go's VCS stamp
and a dirty-tree check see the caller's tree alone. It is part of the
docker build context; a Dockerfile that copies the whole context can list
.latere-ci in .dockerignore.
A repository whose release the stages do not fit calls the actions
directly, as latere-ai/ci/actions/<name>@v1, inside jobs of its own; its
signatures then name its own workflow. Tool versions (cosign, syft, gh,
the note reader, kubectl, kustomize) are defined once, in
actions/lib/versions.sh. A stage takes an
input to override each tool it fetches, except gh in image-build.yml and
cli-release.yml, which always runs the pinned version.
jobs:
release:
uses: latere-ai/ci/.github/workflows/cli-release.yml@v1
secrets: inheritverified checks that the tagged commit passed its per-push gate, with the shared verified-commit step, then test runs on
each extra runner in test_os (macOS, for a darwin binary), then
goreleaser builds the binaries from the repository's own
.goreleaser.yaml and publishes the release with the changelog section as
its body; a tag without a section fails before GoReleaser runs. Lint and
the Linux tests ran in lateregate.yml on the push, so the release reads
that run rather than repeating it. The per-push gate is the workflow that
calls lateregate.yml, found in .github/workflows, or the file named in
verify_workflow.
Inputs: go_version, goreleaser_version (default v2.19.0),
lateregate_version, run_tests, test_os (empty: no extra runners),
verify_workflow, wait_minutes (default: 60 on a GitHub-hosted
runner, 0 on a self-hosted one) and runs_on.
run_lint and golangci_version are accepted and ignored. The caller
grants contents: write and actions: read.
| Secret | Meaning |
|---|---|
RELEASE_SIGNING_KEY |
the private key a signs entry in the repository's .goreleaser.yaml signs the release with, set in GoReleaser's environment for the GoReleaser step alone. Optional: without it the variable is empty, so a repository that signs nothing is unaffected, and one whose signer requires the key fails before anything is published. |
name: Release
on:
push:
tags: ['v*']
permissions:
contents: write
actions: read
jobs:
release:
uses: latere-ai/ci/.github/workflows/notes-release.yml@v1
secrets: inheritNothing is built. Before anything is published, the shared
verified-commit step checks that the tagged commit's per-push gate run
succeeded, as in service-release.yml, with the same verify_workflow,
wait_minutes and skip_paths inputs; that read is why the caller grants
actions: read. The release body is the changelog section, the title is
the tag (or title: Name for Name vX.Y.Z), and a hyphen in the version
core marks a prerelease. A re-run edits the body rather than failing on an
existing release. The publish is the shared publish-release step, the
same one service-release.yml and image-publish.yml run.
Every pipeline here runs on GitHub-hosted runners. Where a job runs is the
organization's policy, and every pipeline applies it as the default of its
runs_on input:
| Repository | Runs on | What runs there |
|---|---|---|
| private | ubuntu-latest |
scheduled and hand-started jobs only, such as CodeQL and drift checks |
| public | ubuntu-latest |
every pipeline |
A private repository starts nothing on a push or a tag. Its bar, its image,
its release, its deploy and the move of its ci-gate pin run on a
workstation through lateregate build, release, push and deploy and
ci-gate's scripts/bump-private.sh, which ci-gate's
releasing guide
describes. Its hosted minutes bill against the organization's Actions
budget, and a spent budget fails every hosted job at zero steps with no
log, in every private repository at once, until the month ends; keeping
push and tag runs off hosted runners is what keeps the scheduled jobs
inside the budget. A public repository pays nothing for hosted runners.
A caller that passes nothing gets the policy. A label passed as runs_on
overrides it. test_os in lateregate.yml defaults to the runner
runs_on resolves to; the suite already runs there, so a repository that
adds macOS passes test_os: '["macos-latest"]'. In cli-release.yml it
lists only the extra runners the release tests on.
The policy is one expression, and a repository's own jobs use it too:
runs-on: ${{ (github.event.repository.private || contains(fromJSON('["push","schedule","workflow_dispatch","release"]'), github.event_name)) && (vars.CI_RUNNER_PRIVATE || vars.CI_RUNNER_PUBLIC) || 'ubuntu-latest' }}It reads two organization variables:
| Variable | Visible to | Value |
|---|---|---|
CI_RUNNER_PRIVATE |
private repositories | ubuntu-latest |
CI_RUNNER_PUBLIC |
every repository | ubuntu-latest |
A public repository reads CI_RUNNER_PRIVATE as empty, so the
variable's visibility is what tells the two kinds of repository apart, on
every event: a scheduled run's payload names no repository. With neither
variable set, every job runs on ubuntu-latest.
A variable may name a self-hosted label, though the organization runs no
self-hosted runner. A job aimed at a label no runner serves waits in the
queue, and GitHub drops it after a day unclaimed. A public repository's
job follows the variables only on push, schedule, workflow_dispatch
and release, events only the repository itself can start. A pull
request, from a fork or not, stays on ubuntu-latest, so a fork's code
never runs on a machine the organization owns.
actions/setup-go restores the module cache only on a hosted runner
(runner.environment == 'github-hosted'), because a self-hosted runner keeps its own between jobs and restoring a
tarball over it fails on every file that already exists.
Everything these pipelines upload to a run expires after 7 days: coverage
profiles, release evidence, command-line binaries, and the
*.dockerbuild build record docker/build-push-action
uploads beside each image. The figure is fixed, not an input. A
repository's artifact store has a fixed quota and GitHub's default
retention is 90 days, so a busy repository would fill it and fail the
upload step of its next release. What is worth keeping longer is kept
elsewhere: binaries on the GitHub release and smoke evidence in the
release body.
The image tag is the git tag, byte for byte. kubectl set image uses the
tag you pushed, with no normalization in between.
That costs one restriction: a release tag cannot contain +. Docker
tags are limited to [a-zA-Z0-9._-], so a SemVer build-metadata tag such
as v1.0.0+exp-sha.5114f85 would push as v1.0.0-exp-sha.5114f85 while
everything downstream asked for the + form. The service pipeline
refuses such a tag in its first job, before anything is built, pushed or
deployed.
Prereleases work normally: v1.2.3-rc1 publishes as a GitHub prerelease
and is not tagged latest in the registry. A hyphen marks a prerelease
only in the SemVer version core.
Callers pin @v1, a tag that moves. Pushing a v1.MINOR.PATCH tag to
this repository moves v1 to it (major-tag.yml), so a change reaches
callers as part of cutting a version, once the tagged commit passed this
repository's own checks. v1 only moves forward: a backport tag cut after
a newer minor leaves it on the newer release. Landing on main does not move
v1, and neither does a prerelease tag such as v1.2.3-rc1, which is how a
change is tried on one repository before every repository runs it. A
repository that needs to hold still pins a full version, @v1.17.0.
Every uses: in these workflows and in the actions under actions/ is
pinned to a full commit SHA, with the version it corresponds to in a
trailing comment:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1These workflows run in your repository with contents: write,
packages: write and whatever secrets you pass. A @v2-style tag is
mutable, so whoever controls the upstream repository could repoint it and
run new code with that access in every caller at once. A SHA cannot be
repointed. For the same reason no step fetches a tool at latest: Bun,
GoReleaser, kubectl, gh, cosign, syft and the changelog reader are
explicit inputs with pinned defaults, and the TypeScript enum gate pins Bun in
lateregate.yml. The one exception is the ci-gate release
ci-gate-bump.yml exists to try, the repository's own gate: it is resolved to an exact
version, verified against the Go checksum database, run with no token in
its environment or in the checkout, and lands only as an exact pin in a
commit.
Repository settings are per-repository state that no pipeline touches, so
a new repository starts on GitHub's defaults: merge commits and rebase
merges enabled, wiki and projects on, no auto-merge.
tools/repo-settings.json holds the organization's settings:
| Setting | Value | Why |
|---|---|---|
allow_squash_merge |
true |
the only merge method: one commit per pull request |
allow_merge_commit |
false |
no merge commits in main's history |
allow_rebase_merge |
false |
a pull request never lands as several commits |
allow_auto_merge |
true |
a pull request can be queued to merge once required checks pass |
squash_merge_commit_title |
PR_TITLE |
the squash commit subject is the pull request title |
squash_merge_commit_message |
PR_BODY |
the body is the pull request description |
delete_branch_on_merge |
true |
head branches are removed once merged |
has_wiki |
false |
documentation lives in the repository |
has_projects |
false |
planning lives in specs and issues |
Apply them when a repository is created, and check them whenever you want to know:
tools/repo-settings.sh apply my-new-repo # one repository, or a list
tools/repo-settings.sh apply lux arca latere-ai/topos
tools/repo-settings.sh check --all # exits 1 and names each drifted field
tools/repo-settings.sh apply --allA bare name resolves against the latere-ai organization; an
owner/repo slug is used as given. REPO_SETTINGS_ORG overrides the
organization and REPO_SETTINGS_POLICY the policy file. Archived
repositories are read-only on the GitHub API, so they are reported and
skipped. GitHub refuses to disable projects on a repository that still
owns classic projects: that repository is retried without has_projects,
reported as PARTIAL, and every other setting still lands; the summary
counts partial repositories apart, and they leave the exit status 0. Any
other refused write, a repository listing that fails partway, or a policy
file or API answer that is not JSON fails the command. Both commands
run on your own gh credentials, so no token is stored anywhere.
CONTRIBUTING.md covers the local checks, how the
workflows are tested, and how a version is cut and reaches callers.
Report a vulnerability as SECURITY.md says, never in a
public issue.
MIT. See LICENSE.