Shared CI definitions for the cshuttle homelab estate: reusable GitHub
Actions workflows (.github/workflows/) and the shared Lefthook git-hook
config (lefthook/).
This repo is intentionally public so the private GitOps repos can consume it
— cross-repo reusable workflows between private repos require a paid GitHub
plan, but a public host is callable by any repo on any plan, and Lefthook
remotes: likewise pull from here. Only generic CI recipes live here; no
secrets, manifests, or hostnames.
Consumers pin an exact release tag (@v1.0.0), never @main and never a
floating major. What this repo publishes is consumed by ~30 repos, so a commit
to the default branch used to change the whole estate's CI the moment it merged
— no pull request anywhere, no record of which repo ran which version. That is
the same reason every third-party action here is pinned by SHA.
Three rules follow from that:
- Released tags are immutable and are never moved. A broken workflow is superseded by a new patch release. Moving a tag would silently change every consumer that already pinned it — the exact failure the pins remove.
- There is no floating
v1. Convenient, and the GitHub-ecosystem norm, but it re-creates the silent-change problem one level up. - A major bump means consumers must edit their
uses:line — an input removed or renamed, or behaviour a caller has to react to. Minor is a new capability, patch is everything else.
Bumping consumers after a release, by surface:
| Surface | Who moves it |
|---|---|
.github/workflows/ggshield.yml (~30 repos) |
REUSABLE_REF in Monitoring/scripts/reconcile-ggshield-gate.sh, then a --sync-workflow sweep — one PR per repo. Renovate is deliberately disabled on this generated file so the two cannot rubber-band. |
| The hand-written callers (kustomize-validate, mirror-image, komodo-deploy) | Renovate |
Lefthook remotes: refs |
By hand — Renovate has no manager for them |
The usage examples below pin v1.0.0; check the
releases for the current tag.
Renders every kustomization.yaml root in the caller's checkout with
kustomize build --enable-helm and schema-validates the output with
kubeconform. Catches a commit that breaks a render before ArgoCD pulls it.
# .github/workflows/validate.yml in a GitOps content repo
name: validate
on:
push:
pull_request:
jobs:
kustomize:
uses: cshuttle/workflows/.github/workflows/kustomize-validate.yml@v1.0.0Optional input paths (space-separated roots to scan; default .).
Runs a GitGuardian ggshield secret
scan over the caller's pushed/PR commit range — a pre-merge gate, unlike the
GitGuardian GitHub App which only flags leaks retroactively. Findings also appear
in the shared GitGuardian dashboard (where policy and false-positives — e.g. bws
UUIDs — are managed; don't obfuscate them in code).
# .github/workflows/ggshield.yml in any repo
name: ggshield
on:
push:
pull_request:
jobs:
ggshield:
uses: cshuttle/workflows/.github/workflows/ggshield-scan.yml@v1.0.0
secrets: inheritRequires the org Actions secret GITGUARDIAN_API_KEY (scope scan; source
of truth in bws Infrastructure). secrets: inherit passes it through — no
per-repo secret needed.
Triggers a Komodo stack deploy from CI — for self-building stacks (repos
whose CI builds the ghcr image the stack runs). Git-push webhooks race the
async image build and the auto_update digest poll lags by minutes; this
workflow fires after the image push succeeds, POSTing a push-shaped,
HMAC-signed payload to the stack's existing Komodo deploy listener. No Komodo
API key involved — it uses the same shared webhook secret a GitHub push hook
would.
# final job in the repo's build workflow
deploy:
needs: build # gate on the image push having succeeded
if: github.ref == 'refs/heads/main'
uses: cshuttle/workflows/.github/workflows/komodo-deploy.yml@v1.0.0
with:
stack-id: <24-hex komodo stack id>
listener-base: https://<komodo webhook listener host>
runner: arc-<repo>
secrets:
KOMODO_WEBHOOK_SECRET: ${{ secrets.KOMODO_WEBHOOK_SECRET }}Requires the org Actions secret KOMODO_WEBHOOK_SECRET (Komodo Core's
shared webhook HMAC secret; source of truth in bws "Komodo GitHub Webhook
Secret") granted to the caller repo. listener-base is required by design —
this repo is public and carries no estate hostnames. Fire-and-forget: the
listener 200s and processes async, so keep the stack's auto_update = true
as the backstop. Background: cshuttle/Topology#23 (this fallback) and
cshuttle/Komodo#120 (the estate-wide registry_package router it stands in
for).
Copies an upstream container tag into a ghcr.io repo you control, so CI pulls from a well-peered registry instead of a badly-peered one.
Registry throughput varies enormously by peering, and a badly peered one is not merely slow — it fails. Measured from one homelab site against the same 926MB image: ghcr.io 192 MB/s, docker.io 86, quay.io 79, registry.k8s.io 20, mcr.microsoft.com 2.2. That one registry was 391s of a ~640s CI job, and its sibling CDN blew a 30s client timeout under load and failed builds outright. Mirroring took the job to ~80s.
# .github/workflows/mirror-<image>.yml in the consuming repo
on:
workflow_dispatch:
schedule:
- cron: "17 4 * * 1" # weekly re-sync; a no-op unless upstream moved
jobs:
mirror:
uses: cshuttle/workflows/.github/workflows/mirror-image.yml@v1.0.0
permissions:
contents: read
packages: write
with:
source: mcr.microsoft.com/playwright
destination: ghcr.io/${{ github.repository_owner }}/playwright
tag: v1.61.1-noble
runner: arc-<repo>Measure before adopting it. A mirror only helps if the pull is genuinely the bottleneck — compare the pull's download phase against its extract phase first (docker logs both; extraction was 8s of that 400s). Raising runner concurrency against a starved path makes things worse, not better.
Notes:
- Skips the copy when the mirror is already current, comparing layer digests. The push adds an OCI source label, so the mirror's manifest and config digests never equal upstream's even when content is identical — a manifest comparison would re-copy on every run.
- Stages to disk rather than
crane copy. A streamed copy holds the upload open for the whole download, and against a slow source the destination cancels it (stream ID 5; CANCEL; received from peer). - Defaults to
linux/amd64;cranedefaults toall, and mirroring an unused architecture doubles the bytes over exactly the leg being avoided. - Destination must be ghcr.io — the push uses the caller's
GITHUB_TOKEN. The pushed package is private and linked to the calling repo; making it public is simpler for a mirror of an already-public image and removes the need forcredentials:on the consumer'scontainer:. - The consumer must keep its own pin (image tag, and any client library
version that must match it) in step — this workflow mirrors, it does not
reconcile. See
cshuttle/nmon.github/workflows/lockstep.ymlfor one way to enforce that.
Cuts a release for a repo whose artifact is one or more container images. It does not build: the image was built and tested when the commit merged, so this promotes that existing digest to a version tag, creates an annotated git tag, and publishes a GitHub Release.
# .github/workflows/release.yml in the app repo
name: release
on:
workflow_dispatch:
inputs:
version:
description: "Version to release, e.g. v1.2.0"
required: true
summary:
description: "Optional paragraph shown above the generated notes"
required: false
jobs:
release:
uses: cshuttle/workflows/.github/workflows/release-image.yml@v1.1.0
permissions:
contents: write # push the tag, create the release
packages: write # add the version tag to the ghcr package
with:
version: ${{ inputs.version }}
summary: ${{ inputs.summary }}
images: ghcr.io/cshuttle/topology
runner: arc-<repo>
secrets:
# Only for a package GITHUB_TOKEN cannot read — see below. Omit otherwise.
ghcr-token: ${{ secrets.GHCR_WRITE_TOKEN }}- Promotes, never rebuilds. A rebuild on the tag produces a second digest
from the same source — a different artifact from the one CI tested, for twice
the build minutes.
imagestakes several images that version together (an app and its sidecar are one release, not two). - Refuses to release a mismatched build. Unless
verify-source-commitis false, each image's digest must be reachable under a commit-sha tag somewhere in the running ref's recent history, so a build still in flight — or one that failed after the merge, or came from another branch — cannot be released by accident. Repos tag per-commit differently, so<sha>, the 7-char short sha andsha-<short>are all tried. - The tag lands on the ref, and provenance is recorded rather than implied.
Tagging each image's own build commit reads better, but a
GITHUB_TOKENcannot create a ref pointing at a commit whose.github/workflowsdiffer from the default branch's — the API refuses it exactly as a push does. Any CI change after the last build triggers that, which is when you are most likely to be releasing. Rather than require a PAT with theworkflowscope in every repo, the tag marks the release point and the tag message and release body name the build commit of every image. - Refuses to reuse a tag, checked against the remote rather than the local clone. Released tags are immutable; supersede with a patch instead.
- A dispatch button, because the version is a human decision — a major means the deploy needs a human step, which no commit message reliably encodes.
tag-prefixapplies to the git tag only (chrome-exporter/v1.0.0), never the image tag:/is not legal in a docker tag.- Notes are always GitHub-generated;
summaryis pre-pended when supplied, and the promoted digests are listed under it. require-same-commit: falsefor independently-built components. By default every image must resolve to one build commit, which catches a half-updated pair shipping under one version. That is wrong for a repo whose images come from separate path-filtered workflows — those change independently and almost never share a commit, so the release legitimately means "SPA built at X plus config built at Y". Each image is still verified against the branch's history; the tag lands on the newest resolved commit.ghcr-tokenwhen the package is user-owned. On a personal account a package bootstrapped by a manual push is owned by the user, not the repo, andGITHUB_TOKENgets 403 on it — including on reads, which the registry reports as a plain "not found". A repo that pushes with a classic PAT must pass the same PAT here. The alternative is granting the repo access under the package's Manage Actions access settings, after which the secret can be dropped.
Shared advisory pre-commit hooks (shellcheck, gitleaks, ggshield, yamllint,
whitespace / merge-conflict). Consume from any repo with a tiny lefthook.yml:
remotes:
- git_url: https://github.com/cshuttle/workflows
ref: main
configs:
- lefthook/base.ymlThen lefthook install per clone. Tools expected on PATH: lefthook,
shellcheck, gitleaks, ggshield, yamllint. ggshield also needs a
GitGuardian token (ggshield auth login or GITGUARDIAN_API_KEY); without one
its hook self-skips (advisory) — gitleaks still runs offline.
The canonical engineering standards for the estate — change flow, lint/format
(Trunk is normative), commit/PR conventions, secrets rules, ADR practice, docs
conventions, and the per-repo AGENTS.md contract. Every repo's root
AGENTS.md links here and carries only repo-specific deltas.
Shared pragmatic markdownlint profile (defaults on; noisy prose/structural
rules off). Copy into a repo as .trunk/configs/.markdownlint.yaml and remove
markdownlint from lint.disabled in .trunk/trunk.yaml. Strict adoption is
tracked in #7.