Skip to content
latere-aiPublic

About

Reusable GitHub Actions workflows: each Latere repo keeps a short caller, the logic lives here.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

ci

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.

test actionlint Release

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.

Status

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.

Quickstart

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@v1

Then, 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.

How it fits

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
Loading
  • Your repository holds a caller per pipeline, the lateregate pin, its gate configuration, its changelog and, for a service, the manifests it deploys. Starter callers are in examples/.
  • 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.

Documentation

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

Adopting it

For a new Go repository:

  1. The per-push gate. Pin the gate binary and let it write the caller, the hooks, the .gitignore lines 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 contract says whether the wiring is in shape, and go tool lateregate runs the bar locally exactly as CI will.

  2. The daily pin move, in a public repository. Copy examples/ci-gate-bump.yml to .github/workflows/ci-gate-bump.yml, give its cron a minute no other repository on the same runner uses, and keep workflow_dispatch: among the triggers of the per-push caller, which init writes; see Moving the gate pin.

  3. A release caller, chosen by what the repository ships: copy the matching file from examples/ to .github/workflows/release.yml and adjust its inputs. The sections below say what each pipeline needs from the repository.

  4. 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@v1

The per-push gate: lateregate.yml

There 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.

Moving the gate pin: ci-gate-bump.yml

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@v1

The 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.

A tag is a release, and a release has notes

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.

Releasing a service: service-release.yml

# .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: inherit

The 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.

What the repository provides

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.

Inputs

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.

Build modes

  • split (default): CI builds the frontend and the Go binary, then Dockerfile.ci copies the prebuilt out/<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: one Dockerfile builds everything; set dockerfile: 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.

The "actually live" check

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.

Signatures and SBOMs

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/example

Every 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.

Deploy credential

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.

Releasing signed images: image-build.yml, image-publish.yml, release-verify.yml

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: exampled

image-build.yml, one job, so a release queues on a self-hosted runner once:

  1. The tagged commit's push runs of verify_workflow passed: 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_workflow takes the one workflow in .github/workflows that calls lateregate.yml, such as the ci.yml that lateregate init writes; a repository with several names it. A run still going is waited for up to wait_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 only skip_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 commit lateregate release cuts, which changes CHANGELOG.md alone 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 successful workflow_dispatch run 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.
  2. The tag carries no build metadata, is on the default branch, and has a CHANGELOG.md section. The namespace is namespace, else the repository variable RELEASE_IMAGE_NAMESPACE, else the repository owner, lowered, so a fork's tag publishes under the fork.
  3. archives_command, when set, writes the files the release attaches into dist/.
  4. Each of image_1 to image_3 is built from its JSON (name, and optionally dockerfile, context, platforms, build_args, version_args, version_expect, sbom_format) with VERSION, COMMIT and DATE as build arguments, pushed by digest under no tag, asked for its version (version_args; it must print version_expect, default {name} {tag}), signed, and given an SPDX bill of materials in dist/. 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.
  5. after_images_command, when set, runs with IMAGE_1 to IMAGE_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.

Deploying a core: image-deploy.yml

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:

  1. No newer vX.Y.Z is tagged (the rollback guard), so a re-run of an older tag cannot roll production back. This is before the kubeconfig is written.
  2. kubectl and kustomize are on PATH at the pinned versions, fetched with their published checksums where the runner has none (kubectl) or has another version (kustomize).
  3. DEPLOY_KUBECONFIG is written to a mode 600 file under RUNNER_TEMP.
  4. Each line of images is set in deploy_dir's kustomization.yaml as <ref>@<digest> with kustomize 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.
  5. kubectl apply -k deploy_dir.
  6. kubectl rollout status for each line of deployments, in order, each within rollout_timeout.
  7. after_roll, when set.
  8. The kubeconfig is removed, in a step that runs whatever happened.
  9. The smoke script, repeated until it passes or smoke_timeout ends.
  10. 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.

Verifying a release

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.yml

The shared steps

Each 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.

Releasing a command-line tool: cli-release.yml

jobs:
  release:
    uses: latere-ai/ci/.github/workflows/cli-release.yml@v1
    secrets: inherit

verified 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.

Releasing a module or library: notes-release.yml

name: Release
on:
  push:
    tags: ['v*']
permissions:
  contents: write
  actions: read
jobs:
  release:
    uses: latere-ai/ci/.github/workflows/notes-release.yml@v1
    secrets: inherit

Nothing 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.

Runners

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.

Run artifacts

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.

Tag rules

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.

Versioning

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.

Supply chain

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.1

These 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

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 --all

A 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

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.

License

MIT. See LICENSE.

About

Reusable GitHub Actions workflows: each Latere repo keeps a short caller, the logic lives here.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages