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).
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) |
| security.yml | gitleaks, deps, semgrep, sbom, config, licenses, image, 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 + provenance attached, 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 |
| selftest.yml | — | proves the harness against tests/fixtures |
Gates for Node, Python and chart/shell repositories follow the same pattern and are 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) |
test |
hermetic unit tests. Go: go test -race -shuffle=on -count=1 ./... |
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.
Every run writes a summary table to the job summary and keeps the raw reports
as artifacts (security-findings-*, security-sbom) 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.
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.
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 and the image 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, 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. - 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); the self-test is green only when each one is reported, the same chart without its dependency fails the scan, 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.