Reusable GitHub Actions pipelines for Latere repositories: the per-push
quality gate, the daily move of the gate's version pin, and the
tag-triggered releases for a Kubernetes service, 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.
| Workflow | Runs on | For | Jobs |
|---|---|---|---|
lateregate.yml |
every push and pull request | a Go repository | the plan, one job per gate, the test matrix, the wiring check |
ci-gate-bump.yml |
once a day | a 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 |
go-verify.yml |
every push and pull request | a Go repository that has not moved to lateregate.yml |
Makefile targets, probed |
service-release.yml |
a v* tag |
a Kubernetes service | build the image, deploy, smoke the live surface, publish the release |
cli-release.yml |
a v* tag |
a command-line tool | lint, test, 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.
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. 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 addworkflow_dispatch:to the triggers of the per-push caller; 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:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }}
cancel-in-progress: true
permissions:
contents: 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 builds one job per 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 the probe log prints.
| Input | Default | Meaning |
|---|---|---|
go_version |
1.27 |
the Go toolchain |
test_os |
["ubuntu-latest"] |
the runners test runs on, as a JSON array |
runs_on |
ubuntu-latest |
the runner label for every other job |
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.
| Job | Runs |
|---|---|
which gates apply |
lateregate list -json; its outputs are the job sets |
test on <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 on a hosted runner, and only the suite gates on a self-hosted one; cover or suite uploads coverage.out |
static gates and wiring |
self-hosted only: every other running gate, one after another, then lateregate contract |
wiring is in shape |
hosted only: lateregate contract |
all gates passed |
after every other job, whatever they did, except in a cancelled push run: passes when each one succeeded or was skipped, and fails on a failure or a cancellation |
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 cancelled
push run skips it: that run was superseded by a newer push or was a
duplicate delivery of the same one, its conclusion already says cancelled,
and branch protection reads the pull request run. 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
one job per gate would make the queue out of job setup rather than checks.
There the gates that take seconds share the static gates and wiring job,
each in a log group of its own, each run whether another failed, and each
named in an annotation when it fails. A hosted runner keeps one job per
gate, since its parallelism costs nothing.
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 24 and Bun 1.3.14 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:
# .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@v1
with:
runs_on: linux-vm # only where the per-push gate runs there tooThe 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. |
When the bar passes, the job commits 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.
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 |
ubuntu-latest |
the runner label for both jobs |
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.
go-verify.yml probes the repository's Makefile with make -np and
runs the targets it finds. fmt-check, test and lint-modernize are
required; test-hermetic, test-race, cover, spec-lint, dist,
validate, lint-config and license run when present. A missing
optional target skips its job, which is the gap lateregate.yml closes.
It takes go_version, test_os, runs_on, golangci_version (default
v2.13.1) and run_lint (default true). It stays until the last
repository that calls it has moved; see
examples/go-verify.yml for the caller.
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 release job 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 each release pipeline pins in its lateregate_version input.
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 without the tool writes the
section by hand and pushes the tag.
In service-release.yml the release job is the last one, so when it fails
on a missing section production is already serving the tag. That is the
intended order, a release announces what is live, and the fix is to add
the section and re-run the job rather than re-tag.
# .github/workflows/release.yml
name: Release
on:
push:
tags: ['v*']
permissions:
contents: write
packages: write
actions: read
jobs:
release:
uses: latere-ai/ci/.github/workflows/service-release.yml@v1
with:
service: platformd
image: ghcr.io/latere-ai/platformd
namespace: latere
url: https://platform.latere.ai
title: Latere Platform
main_package: ./cmd/platformd
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.
The run is five jobs in order. build-image builds and pushes the image;
in split mode it first builds the frontend (with a typecheck) and the Go
binary (after go vet) on the runner. deploy applies deploy/prod/,
rolls the Deployment to the new image, then runs after_roll when it is
set. smoke runs the repository's smoke script against the live URL.
cli builds command-line binaries when build_cli is set. release
publishes the GitHub release with the changelog section and the smoke
evidence. 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.
| 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 job 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 |
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 deploy 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 |
go_version, bun_version |
1.27, 1.3.14 |
toolchains |
kubectl_version, gh_version |
1.35.1, 2.100.0 |
fetched, checksum-verified, when the runner has none |
lateregate_version |
v0.29.0 |
the ci-gate release whose release-notes reads the changelog |
runs_on |
ubuntu-latest |
the runner label for all five jobs |
| Secret | Meaning |
|---|---|
DEPLOY_KUBECONFIG |
the kubeconfig the deploy job applies with; see Deploy credential. Without it the image still builds and publishes, and the deploy job 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 publish the image from one job, so no artifact storage is
needed between jobs; the asset hash reaches the smoke as a job 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.
secrets: inherit passes the repository secret DEPLOY_KUBECONFIG, the
kubeconfig the deploy job 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=latere SA=<service>-rollout
kubeconfig=$(mktemp) && chmod 600 "$kubeconfig"
KUBECONFIG="$kubeconfig" kubectl config set-cluster latere-k8s \
--server="$(kubectl config view --minify -o jsonpath='{.clusters[0].cluster.server}')"
KUBECONFIG="$kubeconfig" kubectl config set clusters.latere-k8s.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=latere-k8s --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.
jobs:
release:
uses: latere-ai/ci/.github/workflows/cli-release.yml@v1
secrets: inheritlint, then test on each runner in test_os, then goreleaser, which
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. Inputs: go_version,
golangci_version, goreleaser_version (default v2.17.1),
lateregate_version, run_lint, run_tests, test_os and runs_on.
The caller grants contents: write.
name: Release
on:
push:
tags: ['v*']
permissions:
contents: write
jobs:
release:
uses: latere-ai/ci/.github/workflows/notes-release.yml@v1
secrets: inheritNothing is built. 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.
Every pipeline takes runs_on (default ubuntu-latest), the runner label
for its jobs. A repository moves to the self-hosted runner with one line
on the caller, and back by removing it:
jobs:
release:
uses: latere-ai/ci/.github/workflows/service-release.yml@v1
with:
runs_on: linux-vmThe test matrix is the exception. lateregate.yml, go-verify.yml and
cli-release.yml run test on the runners test_os names, so moving
those means setting both:
with:
runs_on: linux-vm
test_os: '["linux-vm"]'- The self-hosted runners are shared by every repository. Two
releases that both ask for
linux-vmqueue behind each other, and a job that fans out wider than the runner's slots serializes. A repository that releases often, or fans out wide, is better off on hosted runners. - The runner must already have what the pipeline expects. The machine
ships docker, git, jq and curl.
service-release.ymlfetches kubectl and gh atkubectl_versionandgh_version, checksum-verified, when they are missing, and sets up Node beside Bun. - The secrets still have to reach it.
runs_onchanges where a job runs, not what it can read: the caller keepssecrets: inherit(or its explicit mapping), and the runner needs network reach to the cluster and to the registry.
actions/setup-go restores the module cache only on a hosted label,
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. 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 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 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 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 auth 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. 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.