GitHub Workflows
GitHub Workflows Documentation
This document provides an overview of all GitHub Actions workflows used in the launcher project.
Last Updated: 2026-09-30
Workflow Summary
| Workflow | File | Triggers | Purpose |
|---|---|---|---|
| CI | ci.yml | push, PR, merge_group, schedule, manual | Testing, linting, building, cross-platform binaries |
| Deploy Docs | deploy-docs.yml | push to main (docs paths), workflow_dispatch | Multi-version docs deployment |
| Release | release.yml | manual | The one manual release workflow: release, promote, or start the next version |
| Release / Publish (automatic on tag) | release-publish.yml | tag push, workflow_dispatch | GoReleaser (kurel binaries, checksums, SBOM, cosign signature), docs deploy, proxy refresh |
| Release / State | release-state.yml | workflow_dispatch (tag) | Read-only report of what a tag’s publish run did, in the job summary |
| PR Review | pr-review.yml | pull_request, merge_group | Two-pass AI code review via claude-max-proxy |
| Claude | claude.yml | issue/comment/review events mentioning @claude | @claude AI assistant |
The last five workflows are thin callers that delegate to reusable workflows in go-kure/.github. See go-kure/.github AGENTS.md for their full documentation.
CI Workflow
File: .github/workflows/ci.yml
Name: CI
Triggers
- Push to:
main,develop,release/* - Pull requests to any branch — on
opened,synchronize,reopened,labeledandunlabeled. There is no base-branch filter: a cherry-pick PR to arelease/vX.Ybranch needs the same requiredlint/test/buildchecks as a PR tomain. The label events exist because two overrides are labels:pin-impact-ack(pin-impact gate, which queries the PR’s current labels) anddocs-skip(doc-gate, which reads the label from the event payload). Without them, adding either label would not re-evaluate the failed check until an unrelated push. The cost is real: every PR, Renovate PRs included, gets its full label set at creation, and each label add or remove starts a whole-pipeline run that the concurrency group cancels in favour of the next — one superseded run per label.strip-ackremoving a stalepin-impact-ackon a new commit starts no run: it usesGITHUB_TOKEN, and a label change made with that token does not trigger workflows (onlyworkflow_dispatchandrepository_dispatchare exempt from that rule). The trigger is kept deliberately (go-kure/launcher#445). Narrowing it to those two labels would need a label-aware concurrency key and abuildjob that cannot report green on a no-op run; otherwise a skipped run cancels the real one and leaves a false green. - Merge group (merge queue’s temporary branch — required checks must report here)
- Schedule: 4am UTC daily (catch external changes)
- Manual dispatch
Concurrency
Uses github.ref to cancel superseded runs on the same branch or PR:
Job Dependency Graph
On merge_group events (merge queue), lint/test/build run against the queue’s
temporary branch — the merged result — before the PR is allowed to land. pin-impact is skipped
on a push; on a merge-queue run it checks only that the merged tree’s go-kure/.github pins agree
(see the pin-impact gate below).
Which tree a PR run builds
On pull_request events, actions/checkout checks out refs/pull/<N>/merge: the branch
merged into its base, not the branch. Yet every check run on the PR is stamped with the
branch head’s SHA. A red PR check can therefore name a commit that builds green — a
textually clean merge can still fail to compile, for example when main grew a call site to
a helper the branch removed. Checking out the SHA on the check and rebuilding it does not
reproduce that failure; rebuilding the merge ref does:
scripts/verify-merge.sh fetches refs/pull/<n>/merge, extracts it into a throwaway
directory, fetches its modules (go mod download) and runs go build ./... and
go test ./... there, leaving the working tree untouched. Run it from the PR’s branch after
pushing. Exit 0 is green, 1 means the merge ref fails to build or test, and 2 means
not computable — the PR has no merge ref (it is closed or conflicts with its base), the
ref was generated for a different head than the local HEAD (GitHub regenerates it a few
seconds after each push), go is not on PATH, or go mod download fails in the merged
tree (the local toolchain cannot run its go.mod, e.g. under GOTOOLCHAIN=local; the module
proxy or network is unreachable; a requirement cannot be fetched at all). 2 never reads as
a pass. The download runs against a copy of go.mod/go.sum, so the build still sees the
go.sum the ref carries, as CI does.
make verify-merge PR=<n> runs the same script but is pass/fail only: make exits with its own
2 on any recipe failure, so a failing merge ref and a not-computable one look alike there.
The merge ref reflects the base as of GitHub’s last computation, which is what the PR run
built; the merge queue re-tests against the current main before anything lands, so this is
a local diagnostic, not a gate that protects main. The lint job runs its fixture
self-test (make test-verify-merge).
Jobs Detail
| Job | Check Name | Timeout | Dependencies | Purpose |
|---|---|---|---|---|
changes | detect-changes | 2 min | — | Path filter: go: and docs: outputs control downstream jobs |
validate | lint | 20 min | changes | go-version, fmt, tidy, vet, lint, tool-version parity (golangci-lint pin across Makefile/ci.yml/docs), govulncheck doc parity, verify-merge self-test, check-pin-impact.sh cases (make test-pin-impact); diff-based lint on PRs |
test | test | 25 min | changes | Unit tests with race detection and coverage (-race); CGO enabled |
security | Security | 15 min | changes | govulncheck (symbol scan, allowlist-gated), outdated deps check, sensitive file scan |
action-pins | action-pins | 2 min | — | Fails if any third-party uses: ref is not pinned to a 40-char commit SHA (go-kure/.github composite action) |
issue-refs | issue-refs | 2 min | — | Self-tests then runs scripts/check-issue-refs.sh over the whole tracked tree, and on a pull request over its commit messages: rejects a bare #N or an ownerless name#N reference (go-kure/launcher#400, go-kure/launcher#732; make check-issue-refs) |
coverage-check | Coverage Check | 5 min | test | 80% threshold, Codecov upload, PR sticky comment |
build-binaries | Build kurel | 10 min | changes, test | Build kurel linux/amd64 binary; uploaded as artifact |
docs-build | docs-build | 15 min | changes | Hugo site build for docs; go + Hugo caches; runs the shared No-Downstream-References guard (check-forbidden-terms action, --full-tree) + a vendored-copy drift check + the canonical check-doc-sync/check-links actions (structure + rendered-link check) + the documentation YAML fence check (make check-doc-fences) |
pin-impact | pin-impact | 3 min | — | On a merge-queue run, only checks that every go-kure/.github reference in the merged tree pins the same commit. On a PR, renders and gates on the real impact of a go-kure/.github pin bump: resolves each referenced action’s $GITHUB_ACTION_PATH script (and the siblings those source or run), intersects against the compare diff, fails if a consumed path changed and refuses any shape it cannot resolve (go-kure/launcher#358) |
build | build | 1 min | validate, test, build-binaries, docs-build, coverage-check, action-pins, security, pin-impact, issue-refs | Aggregation gate |
cross-platform | Cross-Platform Build | 15 min | build-binaries | Matrix: linux × amd64/arm64 (main + release/* only) |
validate-manifests | validate-manifests | 10 min | changes | kurel build + flux schema validate against the default (embedded) and ecosystem (schemas.fluxoperator.dev) catalogs for a representative examples/*.yaml subset; continue-on-error: true, not in build’s gate (go-kure/launcher#292) |
analyze-changes | Analyze Changes | 5 min | — | Changed files summary, breaking change warning for pkg/ (PR only) |
Cross-Platform Matrix
Runs on main and release/* branches only (not PRs):
| OS | amd64 | arm64 |
|---|---|---|
| linux | ✅ | ✅ |
Configuration
- Go Version: read from
mise.toml(single source of truth) - yq / lychee / Flux CLI versions: read from
mise.tomlat run time, same pattern as Go — no hand-copied literal exists in any workflow to fall out of sync - Golangci-lint Version:
v2.14.0 - govulncheck Version:
v1.9.0(pinned via theGOVULNCHECK_VERSIONworkflow env) - Coverage Threshold:
80% - Test Timeout:
5mper test binary (thetestjob’sgo test -timeout);TEST_TIMEOUTin theMakefileis the local copy and matches it
Features
No-Downstream-References guard —
docs-buildruns the shared guard and byte-compares the three vendored copies (site/scripts/check-forbidden-terms.sh,docs/releasing.md,docs/shared-workflows.md), whichscripts/vendor-guard.shre-vendors at the guard’s pin; the mechanism is on the Shared Workflows page (docs/shared-workflows.md, vendored fromgo-kure/.github)Doc-sync checks —
docs-buildanddoc-gaterun the sharedcheck-doc-sync,check-linksandcheck-doc-gateactions; see the Shared Workflows pageDocumentation YAML fence check —
docs-buildrunsmake check-doc-fences(site/scripts/check-doc-fences.sh, go-kure/launcher#442), which checks every YAML fence marked with acheckattribute in its info string and reports a failing fence asfile:line.buildfences mustkurel buildagainst their namedprofile;snippetandtemplatefences must be well-formed YAML. Unmarked fences are not checked, so adding a page cannot break the build. It runs indocs-build, notlint, so a documentation-only change cannot skip it;Makefile,go.modandgo.sumare in thedocs:path filter because the check buildskurel. The target runs the gate’s self-test (site/scripts/check-doc-fences-test.sh) first — broken-fence fixtures that must fail, plus the quickstart withtraits: []re-introduced at spec level (go-kure/launcher#417) — so a gate that can no longer fail goes red as well. Marker syntax:DEVELOPMENT.md§ “Checking documentation YAML fences”Issue-reference guard —
issue-refsrunsmake check-issue-refs’s first two steps (go-kure/launcher#400): the self-test (scripts/check-issue-refs-test.sh, fixtures that must fail and must pass, each in a throwaway git repository), thenscripts/check-issue-refs.shon the tracked*.go,*.md,*.sh,*.yml,*.yaml,*.tomland*.jsonfiles. It rejects a bare#Nof two to five digits and an ownerlessname#Nsuch aslauncher#Norkure#N, because neither is a reference thatgo doc, pkg.go.dev or GitHub can resolve or link. It scans the whole tree rather than the changed lines, and has no path filter, so a PR and the merge queue get the same result. Exempt:CHANGELOG.md(generated from commit subjects), anything under atestdata/directory, and any line carryingallow-ref; within a line, Markdown link targets](...), URLs, printf verbs with the#flag and shell prefix trims${name#...}are ignored. On a pull request a third step scans the PR’s own commit messages (--commits <base.sha> --head <head.sha>, go-kure/launcher#732) with the same matcher, skipping Renovate’s commits; push and merge-queue runs skip it, because a rebase keeps each message and the PR run already checked it.make check-issue-refsruns the same scan locally againstISSUE_REFS_BASE(defaultorigin/main). Convention and known gaps:AGENTS.md§ “Issue references”Manifest schema validation —
validate-manifestsbuilds a representative subset ofexamples/*.yamlviakurel buildand validates the output against fluxcd/flux-schema’sdefault(embedded) catalog plus itsecosystemcatalog (fetched from schemas.fluxoperator.dev — network access required) (make validate-manifests, same command locally).continue-on-error: truefor its first cycle and excluded frombuild’s aggregation gate andDEVELOPMENT.md’s required-checks list — promoting it to required is a deliberate follow-up (go-kure/launcher#292)Pin-impact gate —
pin-impactrenders, on a PR, ago-kure/.githubpin bump’s real effect (whichscripts/*.sha referenced action actually runs, whether the compare touches any of them) into the job summary and fails on a match, so a bump touching consumed code cannot merge unreviewed. The old pins are read from the first parent of the checked-out test merge, the base branch as the PR would merge onto it now, not from the event payload’sbase.sha, which can still name the base from when the PR was opened: a bump that landed onmainsince then would otherwise be reported as the PR’s own.scripts/check-pin-impact.shis a vendored copy ofgo-kure/kure’s, first ported (go-kure/kure#729) after go-kure/launcher#358 turned up the identical blind spot here, and since brought level with its hardened form (go-kure/kure#731). It follows three things, and nothing else:- Pins. It reads
uses: go-kure/.github/.github/actions/<subpath>@<sha>, including nested or dotted subpaths. It also reads theref: <sha>in thewith:mapping of a block-style checkout whose samewith:mapping holdsrepository: go-kure/.github, whatever the key order. The repository name is matched case-insensitively and with or without a trailing.git(actions/checkoutcloneshttps://github.com/<repository>, the same repository either way), and the SHA may be written in either case. Keys are read as YAML reads them:"uses":,'ref':andrepository :are the plain keys, in a workflow and anaction.yml. - Action scripts. It follows each
$GITHUB_ACTION_PATH/<rel>.shor${GITHUB_ACTION_PATH}/<rel>.shin an action’s singlerun:step, written as one whole word: the path, at most a closing quote, then whitespace,;&|)<>or the line end. The word must be the command run: at a line start or after a separator, optionally behind a shell keyword (if,then,else,elif,do,while,untilor!) andexec,bash,sh,sourceor.with options. The path is resolved from the action’s own directory, counting its..hops. - Sibling scripts. It follows, transitively, a whole line
source "$SCRIPT_DIR/<name>.sh"or[exec] [bash|sh] "$SCRIPT_DIR/<name>.sh" [args]. It trusts onlySCRIPT_DIRdefined as exactly$(dirname "$0")or$(cd "$(dirname "$0")" && pwd), optionally with&>/dev/null,>/dev/null,>/dev/null 2>&1or2>/dev/nullbefore the&&andpwd -Pforpwd, and either with"${BASH_SOURCE[0]}"for"$0". The definition may sit behinddeclare -r,readonlyorexport;cd --,dirname --,1>for>and a space after&>or>are the same definition. The run-when-executed guardif [[ "${BASH_SOURCE[0]}" == "$0" ]](also!=,"${0}"and; then), alone on its line, names no directory and is accepted.
It refuses rather than guesses on:
- Pins. Inconsistent pins. Any
go-kure/.githubreference in auses:orrepository:context that yields no 40-hex pin:@main, a flow-mapping checkout, a checkout with a branch, another expression or noref:, and similar. Only two are exempt: aref:that is exactly${{ steps.<id>.outputs.<name> }}(thedocs-buildjob’s vendored-guard checkout, whose ref the “Resolve pinned guard revision” step derives from thecheck-forbidden-termspin), and a job-level reusable-workflow call at a non-SHA ref. A reusable-workflow call pinned to a SHA, or one inside a step, is refused. Ago-kure/.githubcheckout step is also refused when it has aref:other than a key at the column of thewith:mapping’s first child (underenv:or another key, in a block scalar body, at the step’s own level), a line that is no key the scan parses (a b:,a/b:, the rest of a multi-line value), a value that does not end on its line (an unterminated or escaped quote), more than oneref:in any letter case, or more than onewith:. Arepository: go-kure/.githubanywhere else in a step (underenv:, deeper underwith:, at the step’s own level) is refused too: it used to mark the step as that checkout, so another repository’sref:in the same step was read as a pin. - Workflow YAML a line scan cannot read, whatever it names. A
uses:orrepository:value that is not whole on its own line: empty, continued on the next line, a block scalar, an alias, anchor, tag or flow collection, or a quoted value with an escape (\in double quotes,''in single quotes) or no closing quote. Arepository:given as an expression (${{ github.repository_owner }}/.github). Ausesorrepositorykey that does not start its line (a flow mapping, a tagged or anchored key) or is not in lower case. A quoted key with an escape sequence, and a?complex key. Auses:expression is refused only when it namesgo-kure/.github: GitHub does not evaluate expressions inuses:. Both.github/workflows/*.ymland*.yamlare scanned. - Actions. An action that is not a composite action (JavaScript or Docker), a
usingin a flow mapping or not in lower case included. A nesteduses:, quoted, in a flow mapping or in any letter case, or more than onerun:step (flow-mapping, quoted andRUN:steps counted; the lines of arun: |body are text, not keys). Agithub.action_pathexpression. AnyGITHUB_ACTION_PATHmention that is not one whole$GITHUB_ACTION_PATH/<path>or${GITHUB_ACTION_PATH}/<path>word (reassigned, cut down with${GITHUB_ACTION_PATH%/*}, a bare trailing/, or a suffix after the path), and the runner’s_actionsdirectory by path. In an action that mentionsGITHUB_ACTION_PATH:dirname,realpath,readlink, a parameter trim (${name%...},${name#...},${name/...},${name:offset}), and a$GITHUB_ACTION_PATH/<path>word other than as the command run (assigned, or passed as an argument). A quoted key with an escape sequence, or a?complex key. A non-.shor unaccounted-for script reference. Whether the runner readsUSES:,Using:orRUN:as its key is not established here, so each is taken as that key. - Paths. A path that climbs above the repository root, or that has a
.,..or empty (//) segment where it cannot be normalised. - Scripts. Any line using
$SCRIPT_DIRin another shape, which includesif !, a wrapper command,$( ), a pipe and a non-.shsibling. AnySCRIPT_DIR=assignment other than the trusted definitions. The wordSCRIPT_DIRin any other form (SCRIPT_DIR+=,SCRIPT_DIR[0]=,read SCRIPT_DIR,for SCRIPT_DIR in,n=SCRIPT_DIR). Name indirection:${!name}(the array-keys form${!name[@]}is allowed), adeclare -n,local -nortypeset -nnameref,eval, and adeclare,typeset,local,exportorreadonlywhose variable name holds a$or a backtick (declare -g "$n+=/lib"). Any other way of computing the script’s own directory (dirname "$0",${0%/*},BASH_SOURCE,BASH_ARGV, a positional slice${@:...}or${*:...}, and$_or${_}, which holds the script’s path right after an exempted$0message). Any$0outside a message to stderr (echo "usage: $0 ..." >&2or1>&2), ased -n '<lines>p' "$0"read of the script itself or an awk record (f($0,, $0,= $0,$0 ~,$0 !~):x=$0,a=($0),printf -v,read <<<"$0", a function argument or a message to another fd would carry the directory under another name. A line naming$GITHUB_ACTION_PATHor the runner’s_actionsdirectory. Any othersourceor script invocation at a command position, and a sibling that cannot be fetched. $SCRIPT_DIRthat is not the script’s own directory. A file sourced from a script in another directory that namesSCRIPT_DIRat all, since it shares its caller’s. A script run as its own process that uses$SCRIPT_DIRbefore a trusted definition, since it reads an inherited one. A relative definition,$(dirname "$0"), in any walked script while any walked script changes the working directory (cd,pushdorpopdoutside a$(cdor(cdsubshell).- The compare. A compare that is not
ahead(a pin rollback or unrelated history), and a file count near GitHub’s ~300-file pagination cap.
It does not see the following. Its threat model is a trusted organisation’s own files: it catches shapes written by accident that would hide consumed code, not a determined adversary, and a shape built to evade a line scan can still pass.
- A sibling reached without naming
$SCRIPT_DIR,$0or the checkout: a hard-coded absolute path, a name found onPATH, or a path assembled at run time (/proc/self, a variable filled from a file). - Job-level reusable-workflow calls at a non-SHA ref
(
go-kure/.github/.github/workflows/<file>.yml@mainhere). They run at their own ref, so no pin bump changes them. - The order in which a script runs: a trusted
SCRIPT_DIRdefinition inside a function or a branch that never runs still counts as defining it for the lines below. - A
,$0outside awk, which is taken for an awk record (for p in {x,$0}). - A
$0message to stderr that is read back: the stderr exemption assumes stderr is not redirected into a file or a capture (exec 2>f,$(f 2>&1)) that the script then reads. - The script’s path taken from the call stack with
caller, which names no$0. - An assignment through a name built at run time other than by a declaration builtin:
printf -v "$n",read "$n",mapfile "$n". A consumed script assigns throughprintf -v "$destination", so refusing it would abort real runs. - A declaration builtin not written as a plain word at a command position:
\declare -g "$n+=/lib",d=declare; $d -g …, or one continued onto the next line with\. - In an action’s
run:step, the action path carried past the command and cut to its directory by a tool other thandirname,realpath,readlinkor a trim (sed,awk,cut, a Python one-liner). It can be carried by$_after the command, an array assignmentx=("$GITHUB_ACTION_PATH/…"), or an argument on a\continuation line. - The action path read through a name built at run time
(
n=GITHUB_ACTION; n+=_PATH; "${!n}/…").
It also aborts, harmlessly but falsely, on:
- another directory derived from the script’s own, such as
ROOT=$(cd "$(dirname "$0")/.." && pwd). Following it would mean tracking an arbitrary variable through every script that sources or inherits it, and real scripts reuse such names for argument-derived paths; uses:orrepository:text anywhere in a single-line workflow value (run: grep -n "repository:" ci.yml,with: { repository: foo/bar }), andref:text anywhere in ago-kure/.githubcheckout step, and a#inside a quoted value there (read as a comment, which leaves the quote open);- a second
repository: go-kure/.githuboutside thewith:mapping of a checkout that already names it there, such as underenv:; ${!prefix@}, andexport SCRIPT_DIRorreadonly SCRIPT_DIRon a line of its own;dirname,realpath,readlinkor a parameter trim anywhere in an action that mentionsGITHUB_ACTION_PATH, and a$GITHUB_ACTION_PATH/<path>command behind a wrapper (env,timeout), an environment assignment (VAR=1 "$GITHUB_ACTION_PATH/…"), a shell option (bash --noprofile), a{ …; }group or acasearm.
The refusal paths, plus the no-change, inert, affected and acknowledged outcomes and the merge-queue mode, are pinned by hermetic cases in
scripts/test/cases/— the same cases, under the same file names, asgo-kure/kure’s — which thelintjob runs on every push and merge-queue run, and on a PR whenever itsgopath filter matches (a workflow change, this script or anything underscripts/test/does) (make test-pin-impact, also part ofmise run verifyandmake precommit);scripts/test/pin-impact-lib.shstubscurland builds a throwaway git repository per case, so no case touches the network. A maintainer who has reviewed a real hit and judged it safe adds thepin-impact-acklabel to merge anyway — same convention ascheck-doc-gate’sdocs-skiplabel; there is no other override. Rerun gotcha: thestrip-ackstep only runs when the triggering event’s action wassynchronizeorreopened; re-running a stale/failed run of one of those (gh run rerun, or the Actions UI) replays that same original action and silently strips a freshly-addedpin-impact-ackagain before the gate re-checks it, even though nothing was pushed. A rerun of anopened/labeled/unlabeled-triggered run is unaffected —strip-ackskips it either way. Scoped to same-repo PRs:strip-ack’sif:also requires the PR’s head repo to equal this repo, so it never runs at all on a fork PR — but that’s moot, since the gate separately forcesPIN_IMPACT_ACK=falseunconditionally on forks; a fork PR has no acknowledgment path regardless of labels. Add (or re-add) the label rather than rerunning, on a same-repo PR; full writeup ingo-kure/.github’sdocs/standards.md§ “Pin-impact-ack”. In the merge queue the job runscheck-pin-impact.sh --consistencyinstead. It checks only that everygo-kure/.githubreference in the merged tree pins the same commit. It reads no base, fetches nothing and has no label override. Two PRs that each passed on their own can combine into mixed pins: one adds a reference at the old pin while the other bumps the rest. Onmain, that tree is every later PR’s base, and the gate refuses an inconsistent base before it reads the label, so it would refuse the repair PR too (go-kure/kure#951). The queue now ejects the PR instead; rebase it ontomainand align its pins. The impact itself is still checked only on the PR (go-kure/kure#730)- Pins. It reads
Path filtering —
dorny/paths-filterskips jobs when unrelated files changeDiff-based lint — on PRs, lint only checks new/changed lines (
--new-from-rev)CGO enabled — test job installs
build-essentialfor cgo-dependent packages, guarded bycommand -v gcc && command -v make(both, see below). The guard is not an optimisation: the unguarded form added a hard dependency on outbound apt reachability, and when the mirror stalls the step produces no output untiltimeout-minutes: 25cancels the job — indistinguishable, from the check list, from a genuinely slow test suite (go-kure/launcher#473). The guard must checkmakeas well asgcc:build-essentialpulls inmake, and thetestjob runsmake depswhile carrying nomakeguard of its own, so this step is the only thing that guaranteesmakethere — agcc-only guard would skip the install on an image with gcc but no make and breakmake depsBinary artifact —
kurellinux/amd64 binary uploaded per run (7-day retention)Cross-platform artifacts — 5 binaries uploaded per main push (30-day retention)
Runs on draft PRs — no draft gate on any job (2026-08-19, GitLab
mr-reviewparity — draft blocks merge only, via branch protection, not what CI runs)make install guard — every job that calls
makeinstalls it first (runner image lacks it)govulncheck allowlist — the
Securityjob runsgovulncheck -scan symbol -format jsonand gates the report through the sharedgovulncheck-gateaction, with this repository’sallowlistinput (exit codes on the Shared Workflows page).- Currently allowlisted:
GO-2026-5377(external-secrets controller privilege escalation). Launcher only importsexternal-secrets/apisto generate CRD manifests; reachable traces are generated deepcopy boilerplate and package init, never a reconciler. The apis module is untagged and the Go vuln DB records no fixed version (Fixed in: N/A), so no dependency bump can clear it.
- Currently allowlisted:
Deploy Docs Workflow
File: .github/workflows/deploy-docs.yml
Name: Deploy Docs
Triggers
- Push to main (paths:
site/**,docs/**,*.md,CHANGELOG.md,DEVELOPMENT.md,scripts/gen-versions-toml.sh) - Manual dispatch with inputs:
version_slot,version_label,set_latest.version_slotmust bedevor start withv(e.g.v0.1), as one path segment, because a root write replaces everything underlauncher/exceptdev/and thev*/slots.version_labelmust bedevor a release tag (v0.1.0,v0.1.0-alpha.1); withset_latest=trueit must be a release tag.set_latestmust betrueorfalse. TheDetermine version parametersstep checks all three before anything is built and fails the run on any other value. The inputs reach the shell only through environment variables, never as${{ }}expressions in arun:script, so a value is never parsed as shell syntax.
How It Works
- Determines version parameters (dev for push to main, explicit slot for manual dispatch)
- Reads Hugo, Go and yq versions from
mise.toml - Runs
scripts/gen-versions-toml.shto generate versioned Hugo config overlay - Builds the Hugo site targeting
https://www.gokure.dev/launcher/<slot>/ - If
set_latest=true, also builds athttps://www.gokure.dev/launcher/ - Checks out
go-kure/go-kure.github.ioand deploys tolauncher/<slot>/through the shareddeploy-docs-pushaction; when and how it also writes thelauncher/root is on the Shared Workflows page
Credentials. The launcher checkout sets persist-credentials: false: the repository is
public, so the action’s tag fetch needs no token, and no later step can read the job token from
.git/config. The go-kure.github.io checkout keeps DEPLOY_TOKEN persisted, because the action
pushes with the checkout’s own credential. That checkout runs after both Hugo builds, so the
deploy step is the only workflow step that follows it. This orders the steps; it does not isolate
the token, which stays on the runner for the rest of the job where a process left running by an
earlier step could still read it.
Trigger Matrix
| Event | Deploys To | BaseURL |
|---|---|---|
Push to main (docs paths) | launcher/dev/ | www.gokure.dev/launcher/dev/ |
workflow_dispatch | launcher/<slot>/ | www.gokure.dev/launcher/<slot>/ |
workflow_dispatch + set_latest=true | launcher/<slot>/ + launcher/ | both |
Concurrency, Preservation, Authentication
Per-slot concurrency group deploy-docs-<slot>; DEPLOY_TOKEN secret (a PAT with write access to
go-kure/go-kure.github.io). Root re-check, retry and what a deploy keeps are on the Shared
Workflows page.
Merge Queue
launcher merges through the merge queue. How it works, its settings and the required checks are on the Shared Workflows page.
Releasing
Release (.github/workflows/release.yml) is the one manual release workflow: pick the branch,
what to do, and whether it is a dry run. It calls go-kure/.github’s shared release.yml, whose
script makes the release commits, the tag and, when main moves to a new line, the release branch.
Release / Publish (.github/workflows/release-publish.yml) then runs by itself on the tag.
Release / State (.github/workflows/release-state.yml) is dispatched with a tag and reports
what that tag’s publish run did; it calls the shared release-state.yml and sets the
Release state <tag> run title the guide relies on. How to release, what each option does, release branches and the recovery procedure for a failed
publish are on the Releasing page (docs/releasing.md), which is the same text in every go-kure
repository: it is vendored from go-kure/.github, and CI’s docs-build job byte-compares it at
the pinned revision.
What is specific to launcher:
- What Publish produces: the
kurelbinaries for linux × amd64/arm64 astar.gzarchives,checksums.txt, an SBOM per archive, and a cosign signature bundle of the checksums (.goreleaser.yml). How many assets a complete release carries is decided by.goreleaser.ymlat that tag. guard-tag-ref: the wrapper’s own job refuses aworkflow_dispatchon anything but av*tag before the privileged publisher starts. The check that a tag has no release yet runs inside the shared publisher.
PR Review Workflow
File: .github/workflows/pr-review.yml, calling go-kure/.github’s pr-review.yml@main.
What it does, its configuration and the incident switch are on the Shared Workflows page.
Context Input
Claude Workflow
File: .github/workflows/claude.yml, calling go-kure/.github’s claude.yml@main. Triggers
and requirements are on the Shared Workflows page.
Configuration Standards
Go Version
All jobs read go-version from mise.toml dynamically:
mise.toml is the single source of truth. CI jobs need no sync — they read it at run time. Four
other places carry their own copy and do need syncing: every module’s go.mod (root, site/,
site/scripts/kuredepsync/), versions.yaml’s go.current, the README.md shields.io badge, and
DEVELOPMENT.md’s “Go X.Y.Z (managed by mise)” prerequisite line. scripts/sync-go-version.sh
propagates a mise.toml change into all of them in one pass — make sync-go-version runs it
locally, and it also runs as a Renovate postUpgradeTasks command so a bot-proposed mise bump
lands with every copy already in sync. make check-go-version verifies every module’s go.mod against mise.toml;
./scripts/sync-versions.sh check separately verifies the root go.mod against versions.yaml’s
go.current and the README badge against go.mod.
yq, lychee and Flux CLI Versions
Same pattern as Go: a Read <tool> version from mise.toml step greps the value and hard-fails if
empty, every downstream step (cache key, install URL, or the flux2 action’s version: input)
references steps.<tool>-version.outputs.version, and no hand-copied literal exists anywhere else
in the workflow files to fall out of sync. For example:
This replaced three hardcoded yq-4.44.6 cache-key/install pairs (in ci.yml’s validate,
docs-build and doc-gate jobs) plus a fourth in deploy-docs.yml, a hardcoded lychee-0.24.2
pair in docs-build, and a hardcoded version: 2.9.4 on the validate-manifests job’s Flux CLI
install step. The flux-schema plugin (flux plugin install schema@<version>) is a separate pin,
tracked only in site/scripts/validate-manifests.sh’s SCHEMA_PLUGIN_VERSION — a Renovate
customManager in renovate.json proposes its bumps; nothing in mise.toml covers it, since it is
a Flux plugin, not the Flux CLI itself.
Caching
setup-go runs with cache: false; caching is done with explicit actions/cache steps.
The runners are ephemeral ARC pods, so nothing on disk survives between jobs — everything
useful must round-trip through the cache server.
Module cache (dependency-only, split restore + save per Go job, like the build cache below):
Go build cache (~/.cache/go-build) uses split actions/cache/restore + actions/cache/save
so the log can show exact vs fallback restore (cache-matched-key). The key ends in a UTC-day
bucket and is split by job purpose so validate (non-race) and test (race+coverage) never
overwrite each other’s entry. GitHub expressions have no date function, so a one-line step before
the restore computes the bucket:
Purpose prefixes: gocache-validate-, gocache-test-race-cover-, gocache-security-,
gocache-build- (the cross-platform job adds <os>-<arch> because cross-compiled artifacts
differ per target). The deps- hash covers go.mod and go.sum; the source tree is
deliberately not in the key. The save runs only on a non-exact (fallback/miss) restore, only
when the run succeeded (so a broken build never publishes a cache), and only on main (see
below), so main writes at most one entry per job per day, plus one after a go.mod/go.sum
change. A per-commit source hash used to sit in place of the day bucket: every Go change missed
the exact key, so each merge to main saved a fresh full build cache for every Go job (about
6 GB per merge across the measured cache variants, from the cache server, go-kure/launcher#777), although the restore-key fallback already gave every run a warm cache. The
deps-first restore key picks the newest entry for the same go.mod/go.sum from any day; Go’s
build cache is content-addressed, so an entry from earlier in the day only costs recompiling the
packages changed since.
Cross-ref scoping caveat. GitHub caches are ref-scoped: a pull_request cache lives on
refs/pull/N/merge and is not visible to the merge_group (merge-queue) run — verified
empirically. The only scope both PR and queue runs can read is the default branch (main).
So these caches are warmed by push-to-main runs; a code-changing PR and its queue run restore
main’s entry (an exact hit when main already saved that day’s entry for the same
go.mod/go.sum, otherwise via restore-key fallback) and Go reuses unchanged package
entries internally. This lowers the absolute cost of both runs but does not deduplicate the
PR↔queue build — that duplication is inherent to the merge queue and cannot be removed with
GitHub-scoped caches.
Saves are default-branch only. Because nothing but the PR itself can read a PR-scoped entry,
and the gh-readonly-queue/* branch a queue run saves to is deleted right after the run, every
save step is gated on github.ref == 'refs/heads/main'. Saving from those refs never helps a
later run; it only fills the size-capped cache server, whose LRU eviction then pushes out the
main entries every run actually restores. The same rule applies to any new cache step: use
split restore/save with the save gated to main, never the combined actions/cache (which
saves on a miss from any ref). The docs-build Hugo modules cache follows the rule too, since it
also carries ~/go/pkg/mod. So do the small tool-binary caches keyed on a pinned version (yq,
Hugo and lychee in ci.yml, yq in deploy-docs.yml): their key changes only on a version bump,
but on a bump the PR and merge-queue runs miss too, and the combined form saved an entry from each
of those refs. These entries cannot warm main or another PR: a PR-scoped entry can be restored
only by reruns of that PR, and a merge-queue entry stays scoped to its queue ref. Their save step runs right after the install
step, so the binary is cached once the install succeeded.
The doc-gate job runs only on pull_request, so its yq cache is restore-only; it reads the
entry the lint and docs-build jobs save on main.
Cache and artifact traffic routes through an in-cluster cache server. Setting
ACTIONS_RESULTS_URL in the workflow env: block ensures upload/download-artifact and
actions/cache see the correct in-cluster URL (the runner binary patch renames the env var
injected into step processes as a side effect).
Self-Hosted Runner
Jobs run on the autops-kube-kure runner described on the Shared Workflows page. Its image lacks
make, so every job that calls make installs it first:
Maintenance Notes
- When adding/modifying workflows: Update this document
- Version updates: Run
make sync-go-versionto update Go version across all files - Version check: Run
make check-go-versionto verify consistency - New jobs using
make: Include the install guard step above - Shared workflows: behaviour from
go-kure/.githubis on the Shared Workflows page; reusable workflows run at@main, composite actions at the pinned SHA
See Also
- Makefile — Local development commands
- mise.toml — Local tool versions
- go-kure/.github AGENTS.md — Reusable workflow reference
- scripts/gen-versions-toml.sh — Versioned docs config generator