Reusable GitHub Actions workflows that give every IronFlock repository the same gate (lint, unit tests, build) and the same security screening (secrets, dependencies, SAST, configuration, licences, SBOM, container image, built root filesystems).
Every logic step calls a just recipe of the calling repository, so a
developer runs the same checks locally that CI runs.
.github/workflows/ci.yml (see templates/workflows):
jobs:
gate:
uses: IronFlock/workflows/.github/workflows/go-gate.yml@v1
with:
working-directory: .
targets-file: targets
workflows:
uses: IronFlock/workflows/.github/workflows/workflows-lint.yml@v1.github/workflows/security.yml:
jobs:
security:
uses: IronFlock/workflows/.github/workflows/security.yml@v1
permissions:
contents: read
security-events: write
with:
languages: go
image: trueThe inputs of each workflow are documented in its on.workflow_call block.
| Workflow | Jobs | Purpose |
|---|---|---|
| go-gate.yml | lint, unit, build |
Go gate; cross-build matrix from targets / targets-file |
| node-gate.yml | lint, unit, build |
Node/Bun gate: package-manager bun or npm, installs from the lockfile only (bun install --frozen-lockfile / npm ci), fails on a lockfile of the other package manager; skip drops jobs a repository has no recipe for |
| python-gate.yml | lint, unit, build |
Python gate: uv from tools.lock; with a uv.lock, uv sync --frozen (all groups) before the recipes, otherwise the recipes bring their own tools (pinned uvx) |
| shell-gate.yml | lint, unit, build |
Shell/just gate (image builders, installers, script collections): just, shellcheck, shfmt from tools.lock, yamllint and bats from the Ubuntu archive; build-apt-packages for build tools, build-args for the build recipe, build-runs-on for a native arm64 build, build-artifact-path hands the build output to a later job |
| security.yml | gitleaks, deps, semgrep, sbom, config, licenses, image, rootfs, report |
security screening; report applies fail-on |
| workflows-lint.yml | lint |
actionlint, zizmor, pinact on the caller's workflows; always blocking |
| release-intent.yml | intent |
the version file decides: a rise makes a release PR (title release <name> <tag> required), an unchanged version a no-release PR; output kind gates the release dry run; listed in the needs of ci-ok |
| release-image.yml | verify, build, publish |
release of a container image on the merge of a release PR into the protected main; native multi-arch build, SBOM attached, SLSA provenance signed with a Cloud KMS key and attached (see Signed image provenance), keyless push; on a release PR a dry run (every platform build, no push); re-runs are safe; read rights only; build-contexts-artifact hands data an earlier job prepared (with secrets the build must not see) to the build as named contexts |
| release-tag.yml | tag |
the annotated git tag of a release, after every image job of the caller; a tag always means "in the registry"; written with a token of the release GitHub App from the caller's release environment |
| attest-provenance.yml | attest |
SLSA Build Level 3 provenance of files a release built (disk images, bundles): signed by a job that runs none of the caller's code, under this workflow's identity; pin it with gh attestation verify --signer-workflow |
| selftest.yml | — | proves the harness against tests/fixtures |
A gate for chart repositories follows the same pattern and is added when the first repository of that kind adopts the harness.
| Recipe | Meaning |
|---|---|
lint |
non-mutating static checks. Go: gofmt -l, go mod tidy -diff, go vet, golangci-lint (incl. gosec, config in templates/repo/.golangci.yml). Shell: just --fmt --check, shellcheck, shfmt -d, yamllint |
test |
hermetic unit tests. Go: go test -race -shuffle=on -count=1 ./.... Shell: bats |
build |
the artefact build, without publishing |
sarif, sbom |
optional (Go): write govulncheck SARIF / CycloneDX files; otherwise the harness runs govulncheck and cyclonedx-gomod itself |
Tests that need services (databases, Docker, a router) stay in a
repository-local integration job next to the gate.
-
lint,unit,buildand workflow lint block from the first run. -
The security scan starts report-only (
fail-on: ""). After two weeks or ten green runs, a repository setsfail-on: secrets,vuln-critical,vuln-high,misconfig-high,license-forbidden,semgrep-error. -
Vulnerabilities count only when a fixed version exists (trivy
--ignore-unfixed); govulncheck findings count as high only when the vulnerable code is reachable. -
A scanner job that fails or is cancelled (rather than finds something) always fails
report: an incomplete scan is not a pass. -
Helm charts are scanned rendered. trivy skips a chart it cannot load (a dependency missing from
charts/, a required value without a default) with only a warning, subcharts included, so a skipped chart fails theconfigjob. A repository whose chart dependencies are not committed fetches them inconfig-setup-recipe(run inworking-directory, with the tools fromtools.locklisted inconfig-tools, e.g.helm); values a chart requires go intomisconfiguration.helm.setof atrivy.yamlin the repository root. -
semgrep runs the IronFlock rules plus
semgrep-rulesets(defaultp/default). Go repositories, whosejust lintruns gosec, setsemgrep-rulesets: ""so each finding is reported (and annotated) once. -
The container image (
image: true) is built and scanned on the events inimage-on, and on every pull request of a public repository. Private repositories skip the build on pull requests to save Actions minutes, except on release pull requests (titlerelease <name> v<x>): the image that the merge publishes is scanned before the merge. -
Root filesystems (the tree of an OS image) are built and scanned in the
rootfsjob, one job per entry ofrootfs-matrix, on every event. The repository'srootfs-reciperuns inworking-directorywith the entry'sargsandROOTFS_WORKset, a scratch directory on the runner's larger disk, and leaves the tree there;pathnames it (relative toROOTFS_WORK, or absolute). trivy reads it as root, so the build may leave it readable by root only. The vulnerabilities with a fix count in thevuln-*categories; a tree without an operating system and its packages (a wrongpath, a build that stopped early) fails the job, and so the report, instead of looking clean.rootfs-recipe: rootfs rootfs-matrix: >- [{"name": "amd64-base", "args": "amd64 base", "path": "amd64-base/rootfs"}, {"name": "arm64-base", "args": "arm64 base", "path": "arm64-base/rootfs", "runner": "ubuntu-24.04-arm"}]
Every run writes a summary table to the job summary and keeps the raw reports
as artifacts (security-findings-*, security-sbom, security-rootfs-sbom-<name>) for 90 days. A workflow that
calls security.yml more than once per run gives each call its own artifact-prefix. Public repositories also get
the results in their Security tab, without the vulnerabilities the VEX file
accepts, so the tab and the report agree.
The logic lives here: the reusable workflows, the scripts and the tool pins
(tools.lock). Each repository keeps only thin caller files — ci.yml,
security.yml, release.yml — because GitHub starts workflows only from files
in the repository itself. A caller holds the triggers, the repository's settings
(image, version file, directory, targets) and uses: IronFlock/workflows/...@v1,
plus jobs only that repository needs (integration tests, a second image). Copy
them from templates/workflows. A change to the logic is
made once here and reaches every repository when v1 moves after a green
self-test; a change to triggers or settings needs a pull request per repository.
tools/sign-openpgp makes the detached OpenPGP signatures
(<file>.asc) that an install or update checks before it runs or uses what it
downloaded — with gpgv or sqv, which every apt-based host has. The private
key stays in Cloud KMS (-kms-key, Application Default Credentials: the
release job's workload identity) or OpenBao transit (-signer); the tool
hashes locally and sends only digests. The signing account needs two roles on
the KMS key, roles/cloudkms.signer and roles/cloudkms.publicKeyViewer:
before it signs, the tool reads the key's public half to check it against the
certificate (-cert), and signer alone does not allow that. A release job
runs it from its harness checkout (add tools/sign-openpgp to the sparse
checkout, set up Go from its go.mod):
cd "$HARNESS/tools/sign-openpgp"
go run . sign -cert "$CERT" -kms-key "$KMS_KEY_VERSION" FILE... # writes FILE.asc
go run . verify -cert "$CERT" FILE...cert makes the public certificate once (-uid names it), export its base64
form for a script to embed, fingerprint the fingerprint the operator docs
publish, keygen a development key for tests. The commands are documented at
the top of main.go.
Every image release-image.yml releases carries, on its multi-arch index, one
signed in-toto attestation per platform: the SLSA v1 provenance BuildKit
recorded for that platform's build (source repository, commit, Dockerfile,
build arguments, base images; the platform in
buildDefinition.internalParameters.targetPlatform). The publish job signs
it with scripts/sign-provenance.sh and cosign,
after checking that it names this run as builder and the released commit of
the calling repository as a clean source. The key is a Cloud KMS key version;
the attestation is kept in the registry next to the image (an OCI referrer of
the index) and goes to no transparency log, so no image name or digest of a
private repository becomes public.
The signing account is not the publisher: the workload identity provider maps
an attribute that only the publish job of this workflow, called at a harness
tag (@v1) on a push to main or of a version tag, receives, and only that
attribute may act as the signing account. Every job of a reusable workflow
carries the same job_workflow_ref claim, so the attribute also requires the
environment claim image-provenance, which only publish has
(environment: { name: image-provenance, deployment: false }: no deployment
is recorded; a calling repository gets the environment on its first release).
The mapping:
attribute.provenance = (assertion.event_name == 'push'
&& (assertion.ref == 'refs/heads/main' || assertion.ref.startsWith('refs/tags/v'))
&& assertion.job_workflow_ref.startsWith('IronFlock/workflows/.github/workflows/release-image.yml@refs/tags/v')
&& has(assertion.environment) && assertion.environment == 'image-provenance')
? 'release-image' : 'other'
has() keeps the mapping valid for the many tokens without an environment;
a mapping that fails to evaluate refuses every token of the provider. The
self-test job provenance-environment-claim checks that GitHub still issues
the claim with deployment: false. The signing account holds two roles on the
key and nothing else:
roles/cloudkms.signerVerifier (sign, read the public key) and
roles/cloudkms.viewer (cosign reads the key and the key version before it
signs; signerVerifier alone fails with PermissionDenied on
cloudkms.cryptoKeys.get). A caller's own jobs cannot obtain it, nor can the
build job, where the Dockerfile's steps run, even if a step broke out of
BuildKit's sandbox; no build job holds a credentials file. The
publish job takes its scripts and cosign from the commit of
release-image.yml it runs from (the job_workflow_sha claim of its OIDC
token), never from the caller's harness-ref.
Configuration: the organization variables IMAGE_PROVENANCE_KEY (cosign's
form, gcpkms://projects/<project>/locations/<location>/keyRings/<ring>/cryptoKeys/<key>/versions/<n>)
and IMAGE_PROVENANCE_SERVICE_ACCOUNT, or the inputs provenance-key and
provenance-service-account. A release without them fails in verify,
before anything is built; dry runs need neither. A re-run signs only what is
missing.
Verify a released image with the public key
(cosign public-key --key <the key's gcpkms URI> prints it):
cosign verify-attestation --key provenance.pub --type slsaprovenance1 \
--insecure-ignore-tlog <image>@<index digest>--insecure-ignore-tlog only tells cosign that no transparency log entry is
expected; the signature is checked against the key.
Each repository protects main with a ruleset: changes only through pull
requests, squash-merged, which must be up to date with main and pass the
required checks ci-ok and security / report; no force pushes, no deletion,
no bypass. So every commit on main is green, and release-image.yml only
checks that the released commit is on main. ci-ok is a small job at the end
of the caller's ci.yml that succeeds only when every other job in it
succeeded (see templates/workflows/ci-go.yml).
The check workflows (ci.yml, security.yml) run on pull requests into
main, plus the weekly security scan; a run after the merge would test the
same tree again. The weekly scan covers the dependencies, the image and the
root filesystems only, where new advisories appear without a code change (scheduled-full: true
runs every scanner). The version file decides what a pull request is
(release-intent.yml):
| Kind | Made with | Title | Version | On the pull request | After the merge |
|---|---|---|---|---|---|
| release PR | just release-patch (-minor, -major) |
release <name> v<version> |
rises | ci (incl. the release dry run: every platform build, no push) + security | release.yml publishes the image, then sets the tag |
| no-release PR | just merge |
anything that does not read like a release | unchanged | ci + security | nothing runs |
If a release run fails after the merge, main holds the new version without
an image or tag; nothing reads it (deployments take versions from the
registry). A transient or permission failure: fix the cause and re-run the run
(safe: nothing is pushed twice, the tag comes last). A code failure: fix it in
the next release PR; the unreleased version stays a gap in the history.
release.yml runs on pushes to main that touch the version file, and its job
only when the squash-merge title is a release title (release <name> v...;
templates/workflows/release.yml). The
identity provider grants the publisher service account only to main-branch
pushes (and version tags) through release-image.yml. Only the IronFlock
release GitHub App may create v* tags (bypass actor of the repository's
release tags v* tag ruleset; the built-in GitHub Actions app cannot be one).
release-tag.yml takes its token in the repository's release environment:
deployments from main only, variable RELEASE_APP_CLIENT_ID, secret
RELEASE_APP_PRIVATE_KEY; the token covers the contents of that one
repository. Callers grant contents: read only and pass secrets: inherit:
environment secrets reach a reusable workflow only that way.
With promote-branch set, the tag job then fast-forwards that branch to the
released commit (never a force push), for a hosting service that deploys from
a branch: only release merges reach it. Protect that branch with a ruleset
whose only bypass actor is the release App.
With github-release: true it also creates the GitHub release of the tag
(generated notes), with the same token, so callers need no write rights.
release-notes names a Markdown file in the repository to use as the
release's text instead ({version} stands for the version, e.g.
release-notes/{version}.md); the step fails when that file is missing. Both
steps catch up on a re-run that finds the tag already set.
just merge and just release-patch|minor|major behave the same in every
repository: the Justfile fetches scripts/pr.sh at v1 (with
gh) and runs it. A repository only names its release, its version file and
the files a bump changes, and writes the version in _write-version; copy
templates/repo/Justfile.release.
just merge [TITLE]: pushes the current feature branch (also when origin does not have it yet), opens the pull request and turns on auto-merge. It refuses a changed version and a title starting withrelease. A repository without a version, whosemainis the publication (a content site, a plugin marketplace), passes-as version file and has no release recipes.just release-patch: the next version is computed from the version onorigin/main, so a second run on the same branch does not bump twice. The release commitrelease <name> v<x>holds only the listed files and sits on top of the branch's work; on an up-to-datemainit goes on a new branchrelease/v<x>. It then waits for the merge and for the release run, and ends back onmain(detached onorigin/mainin a worktree).- Run either again when a run broke off (a dropped connection, Ctrl-C): a
branch whose pull request is merged already is left for
main; a release then follows the release run of the merged release pull request instead of releasing twice, or releasesmainwhen the branch was merged without a release. Polls survive short outages. A start that cannot work is refused with what to do:ghnot logged in (an exportedGH_TOKENorGITHUB_TOKENoverrides its login),mainbehind or ahead oforigin/main, a detachedHEAD, a branch missing commits oforigin/main.
A repository with several release projects (each its own version file, tag
prefix and release workflow, e.g. db-v* and migrator-v*): each project's
release-intent call gets its release-name, so a release pull request of
one project is a no-release for the others (and may not change their
versions); pr.sh release takes TAG_PREFIX and WRITE_VERSION (the
project's version recipe) from the environment, and pr.sh merge a
comma-separated list of every version file. One release per pull request.
Bare-version tags (1.0.10, the widgets): an empty TAG_PREFIX for
pr.sh release and tag-prefix: "" for release-intent and release-tag.
Onboarding a repository onto this flow, besides the caller files:
mainruleset: squash merges only; repository setting "default commit message" = pull request title (so the merge commit carries the checked title).- Environment
release: deployment branchesmainonly; variableRELEASE_APP_CLIENT_ID, secretRELEASE_APP_PRIVATE_KEY. - The release GitHub App installed on the repository and listed as bypass
actor of the
release tags v*ruleset. - The publisher binding of the identity provider for the repository.
Each exception needs a reason and an expiry, and is reviewed in the pull request that adds it. Templates are in templates/repo.
| File | For | Expiry |
|---|---|---|
security/vex.openvex.json |
vulnerabilities that do not affect the code (OpenVEX not_affected with a justification, or fixed) |
statement timestamp + 12 months |
.trivyignore.yaml |
image, root filesystem, configuration and licence findings | expired_at (required) |
.gitleaks.toml |
reviewed secret false positives | review with each change |
.gitleaks-baseline.json |
historical findings that cannot be removed from history | — |
// nosemgrep: <rule> |
a reviewed semgrep false positive, with the reason | — |
semgrep-exclude-rules (caller input) |
a semgrep rule that cannot apply to the repository at all, with the reason next to it | review with each change |
The summary lists exceptions that expire within 30 days.
- Versions. Scanner binaries are pinned with their sha256 in
tools.lock and installed by
scripts/install-tool.sh; Go tools and semgrep are
pinned in the workflows'
envblocks; actions are pinned to commit SHAs. Every tool has an x86_64 line and a<name>/arm64line at the same version;install-tool.shtakes the one of the runner's architecture, so callers and the gates'build-runs-on: ubuntu-24.04-armwork on arm64 runners.scripts/test_tools_lock.shkeeps the two lines in step. - Self-test. selftest.yml runs on every
pull request and weekly. The fixtures plant one finding per scanner (a fake key,
a reachable call into a vulnerable
golang.org/x/text, a secret written to the console, a root container on an outdated base image, an unpinned action, a failing test, a privileged container in a Helm chart whose dependency is fetched first, an outdated package in a root tree built on an x64 and an arm64 runner); the self-test is green only when each one is reported, the same chart without its dependency fails the scan, so does a root tree without an operating system, and the clean fixture passes with everyfail-oncategory set. Alerts that GitHub raises for the fixtures are expected. - Releases. Callers use the moving tag
v1, so a change reaches every repository whenv1moves. The harness has the same recipes as every repository (Justfile):just merge [TITLE]opens the pull request from a feature branch (merged once the self-test is green; that publishes nothing).just release-patch(-minor) publishes (scripts/publish.sh), like a release in every repository: on a feature branch it opens the pull request, waits for the self-test and the merge, and publishes that merge commit; on an up-to-datemainit publishes the newest merge. Only the merge commit of a pull request whose checks are green, tagged with the nextv1.x.y, andv1moved there. It asks first, and prints the rollback (git tag -f v1 <previous> && git push -f origin v1). A breaking input change isjust release-major:v2.0.0and a newv2, whilev1stays until every caller says@v2. A new input that callers will pass: publish first, then change the callers (a caller passing an input its@v1does not know fails to start). - Publish script.
bash scripts/test_publish.shtests the refusals, the versions and the moved tag against a local remote. - Report script.
python3 -I scripts/test_sarif_summary.pytests the categories,fail-on, VEX expiry and the code-scanning copy. - Release script.
bash scripts/test_release.shtests the release intent, the run modes ofrelease-image.ymland the idempotent tag. - Ubuntu packages. Every
apt-geton a runner goes through scripts/apt-install.sh: when a name of the runner's package sources does not resolve, it asks public resolvers (1.1.1.1, 8.8.8.8) before the first try; eachapt-getcall gets 10 minutes; a failed try gets a second one, through public resolvers when the first did not use them. A self-hosted runner keeps its resolver.bash scripts/test_apt_install.shtests it with stand-ins forapt-getand the resolver, and that no workflow callsapt-getitself.