CI/CD Pipeline Guide
How GSKILL’s continuous-integration and release pipeline is hardened, what each gate
enforces, how to reproduce a gate locally, and how to waive a finding. This is the
companion to specs/004-cicd-best-practices/.
The golden rule: scripts/verify.sh is the single definition of done. Every other
check below is additive — it hardens or accelerates the pipeline but never replaces,
weakens, or lets you bypass that gate.
Gate catalog
Section titled “Gate catalog”| Gate (status-check name) | Source | Trigger | Disposition |
|---|---|---|---|
verify |
scripts/verify.sh |
PR + push + merge queue | required — definition of done |
build-test (ubuntu/macos) |
go build + go test -race |
PR + push + merge queue | required |
release-dryrun |
goreleaser check + snapshot |
PR + push + merge queue | required |
workflow-audit |
zizmor + actionlint |
PR + push + merge queue | required |
codeql |
CodeQL (Go, security-extended) | PR + push + weekly | required (high-severity blocks) |
pr-title |
semantic-pull-request | PR | required |
site |
scripts/site.sh check |
PR + push + merge queue | required (docs site builds, links valid, README install region in sync, landing example runs) |
image (amd64), image (arm64) |
scripts/image.sh check |
PR + push + merge queue | not required yet (both platforms build with ko; the runner’s platform passes the smoke suite) |
scorecard |
OpenSSF Scorecard | push-to-main + weekly |
informational (grade/trend, not a merge gate) |
example-vs-release (landing-example.yml) |
scripts/check-landing-example.sh --release |
each site publish + each release | informational (never blocks or undoes a deploy) |
Required gates fail closed: a degraded, errored, or unavailable required check blocks
merge rather than passing silently. scorecard is explicitly not a required check.
Action-pinning convention
Section titled “Action-pinning convention”Every uses: reference in every workflow MUST be pinned to a full 40-hex commit SHA with a
trailing version comment:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0Rules:
- SHA, never a tag or branch. Mutable tags can be re-pointed at malicious commits; an immutable SHA cannot. This is the single highest-leverage supply-chain control.
- Version at the end of the comment (
# v7.0.0, not# v7.0.0 (checkout)). Dependabot rewrites the comment on update only when the version sits at the end of the line. - Local actions (
uses: ./...) and same-repo reusable workflows are exempt from SHA pinning (they are version-controlled here) but are still audited. - Reuse an existing pin when a workflow needs an action another workflow already uses.
The image pipeline reuses the cosign, syft, and
actions/attest-build-provenancepins fromrelease.yml, and adds one:actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2for the SBOM attestations.
Enforced by the workflow-audit gate (see below). To pin a new action, resolve its tag to a
SHA:
gh api repos/<owner>/<repo>/commits/<tag> --jq .shaworkflow-audit gate (zizmor + actionlint)
Section titled “workflow-audit gate (zizmor + actionlint)”Runs zizmor (GitHub Actions static analyzer) and actionlint (workflow correctness) over
.github/workflows/. zizmor’s unpinned-uses audit requires hash-pinning on all
actions; any unpinned/mutable reference fails the gate with a diagnostic naming the
offending uses:. zizmor also covers template injection, excessive permissions, cache
poisoning, and dangerous pull_request_target use.
Reproduce locally (identical invocation to CI):
./scripts/bootstrap.sh # installs actionlint into ./bin; checks for uv./scripts/audit-workflows.sh # actionlint + uvx zizmor==$ZIZMOR over .github/workflowsPolicy lives in .github/zizmor.yml.
Waiving a zizmor finding
Section titled “Waiving a zizmor finding”Never disable the gate. Waive narrowly, with justification, either:
- inline:
# zizmor: ignore[<rule-id>] — <reason>on the offending line, or - in
.github/zizmor.ymlwith a comment explaining why.
CodeQL (static application security testing)
Section titled “CodeQL (static application security testing)”.github/workflows/codeql.yml runs CodeQL over the Go source (build-mode autobuild,
security-extended query suite) on every PR, on push to main, and weekly. Go cannot use
the build-mode none / default-setup path, so this is committed advanced setup — which
also lets us SHA-pin the action, scope its token to security-events: write, and review it
like any other workflow.
- Blocking: with
codeqlrequired in branch protection, an unresolved high-severity alert blocks merge. A degraded/errored analysis is a failing required check (fail closed). - Retention/trend: results persist in the repository code-scanning store; the weekly run keeps coverage of already-merged code so newly disclosed query findings still surface.
Waiving a CodeQL finding
Section titled “Waiving a CodeQL finding”A confirmed false positive is dismissed in the code-scanning UI with a recorded reason, or suppressed narrowly in code with a justification comment — never by removing or disabling the workflow.
OpenSSF Scorecard (supply-chain posture)
Section titled “OpenSSF Scorecard (supply-chain posture)”.github/workflows/scorecard.yml runs on push to main and weekly, uploads SARIF to code
scanning, and publishes the aggregate grade (badge in README.md). It needs
security-events: write (upload SARIF) and id-token: write (publish); it runs only on
default-branch/schedule contexts, so those scopes never see untrusted forked-PR code.
Agreed regression threshold: the aggregate Scorecard grade should stay ≥ 7.0/10. A
published grade below 7.0, or a drop of ≥ 1.0 from the previous run, is treated as a
posture regression to be triaged within one scheduled cycle. Scorecard publishes the grade
and per-check SARIF but does not natively alert on a drop — maintainers review the badge
and the weekly run; configure a notification on the scorecard code-scanning category if
automated alerting is desired. The highest-impact checks here are Pinned-Dependencies,
Token-Permissions, SAST, Branch-Protection, Dependency-Update-Tool, and Signed-Releases.
Caching & job runtime
Section titled “Caching & job runtime”- Tool cache: a
actions/cachestep restores./binkeyed onhashFiles('.config/tool-versions')+ runner OS, beforebootstrap.sh, so the pinned tools are reused instead ofgo install-ed every run. A pin bump changes the key and forces a clean reinstall. - Go build/module cache: provided by
actions/setup-gowithcache: true(keyed ongo.sum). - Verification-neutral invariant: caching only restores artifacts. The gate’s pass/fail verdict is identical warm vs cold. A corrupt or missing cache costs reinstall time, never correctness.
- Timeouts: every job declares
timeout-minutes(CI ≈ 15, release ≈ 30) so a hung step is terminated at the bound, not the platform ceiling.
Dependency & pin currency (ownership partition)
Section titled “Dependency & pin currency (ownership partition)”No overlap between updaters — each source has exactly one owner:
| Source | Owner | Notes |
|---|---|---|
go.mod / go.sum |
Dependabot (gomod) |
grouped minor/patch |
.github/workflows/* uses: SHAs |
Dependabot (github-actions) |
bumps the SHA and the trailing # vX.Y.Z comment; all github/codeql-action/* components are grouped into one PR and must remain on the same version |
.config/tool-versions |
Renovate (custom regex manager) | includes the KO and CRANE pins for the image pipeline |
.ko.yaml base image digest |
Renovate (custom regex manager, docker datasource) |
weekly, before 6am on Monday; Dependabot cannot read .ko.yaml |
Every Dependabot/Renovate PR triggers the full CI gate before it can merge.
Enabling Renovate (required — config alone does nothing)
Section titled “Enabling Renovate (required — config alone does nothing)”A committed renovate.json is inert until Renovate actually runs against the repo.
GSKILL uses the hosted Mend Renovate GitHub App:
- Install the Renovate app from the GitHub Marketplace
(https://github.com/apps/renovate) onto the
glapsfun/gskillrepository (a one-time repo-admin action; no secrets, no self-hosted runner). - Renovate reads
renovate.json, which restrictsenabledManagersto two custom regex managers, one for.config/tool-versionsand one for the.ko.yamlbase image digest (so it never overlaps Dependabot’s gomod / github-actions scope). - On its schedule, Renovate opens PRs that bump each tool pin while preserving the
NAME=versionformat, and a weekly PR that moves the base image to the current digest ofcgr.dev/chainguard/git:latest.
Alternative (not used): a self-hosted SHA-pinned
renovate.ymlworkflow runningrenovatebot/github-action. Documented for completeness; the Mend App is the chosen path.
Branch protection (apply in repo settings)
Section titled “Branch protection (apply in repo settings)”These gates only block merges when branch protection requires them. Configure on main:
- Required status checks:
verify,build-test (ubuntu-latest),build-test (macos-latest),release-dryrun,workflow-audit,codeql,pr-title,site.mainmerges through a merge queue, so a required check must also run onmerge_group(ci.ymldoes); one that does not leaves queued merges waiting until they time out. image (amd64)andimage (arm64)also run onmerge_group, so they can be made required. They are not required yet: the maintainers plan to require them once the first release candidate has run the image pipeline end to end.- Do not mark
scorecardrequired (it is informational). - Require a pull request with at least one review; dismiss stale approvals on new commits.
- Disallow force-pushes and deletions on
main.
Once set, OpenSSF Scorecard’s Branch-Protection check will score this configuration.
Documentation site
Section titled “Documentation site”The site at https://glapsfun.github.io/gskill/ is built from docs/ and published by its own
workflow. See Work on the documentation site for local commands.
| Piece | What it does | Permissions |
|---|---|---|
ci.yml job site |
scripts/site.sh check: fails on a stale README install region, regenerates the content, builds with link and anchor validation, runs the landing example against the gskill built from the commit |
contents: read |
pages.yml job build |
README drift check and scripts/site.sh build, no caches (it produces the published artifact), uploads the Pages artifact |
contents: read |
pages.yml job deploy |
actions/deploy-pages, environment github-pages |
pages: write, id-token: write |
pages.yml job example |
After each deploy, dispatches landing-example.yml against the latest stable release; skipped on release runs, which release.yml checks with the tag |
actions: write |
landing-example.yml job example-vs-release |
Runs the landing example against the latest stable release, or against the tag release.yml passes in; red means the site documents something visitors cannot install |
contents: read |
release.yml step |
Dispatches pages.yml and landing-example.yml (with the tag) on main after each release |
existing actions: write |
pages.yml runs on pushes to main that touch the site’s inputs, on manual dispatch, and from
release.yml. Deployments share one concurrency group and are never cancelled mid-flight, so
the site always serves one complete build. A failed build never reaches deploy.
landing-example.yml runs after every publish (dispatched by pages.yml), on pushes to main
that change what the example fetches, and from release.yml. It stays out of the deploy concurrency group, so a later publish can never
cancel a queued release check.
Reproduce locally: scripts/site.sh check (the site job) and
scripts/check-landing-example.sh --release (the post-release check; set VERSION=vX.Y.Z to pin
a release).
Container image release
Section titled “Container image release”.github/workflows/image-release.yml publishes the image ghcr.io/glapsfun/gskill for each
release tag. It is a separate workflow, like npm-release.yml: a registry failure never
turns the release run red after the GitHub Release exists, and it can be re-run on its own.
release.yml dispatches it as its last step, on the tag ref. Each tag has its own concurrency
group, so runs for different tags never cancel each other.
Tag-ref rule: the only trigger is workflow_dispatch with a tag input, and the
publish job refuses any run whose ref is not refs/tags/<tag>. The image is always built
from the tag’s own .ko.yaml, tool pins, and workflow, and the cosign signer identity always
ends in @refs/tags/<tag>.
Workflow-level permissions: {}; each job asks for what it needs:
| Job | Runner | Permissions | Does |
|---|---|---|---|
publish |
ubuntu-latest |
contents: read, packages: write, id-token: write, attestations: write |
guards, then scripts/image.sh publish (build once with ko, digest guard, push the build with crane), cosign signature, SBOM and provenance attestations; no build cache |
verify |
ubuntu-latest, ubuntu-24.04-arm |
contents: read, attestations: read |
anonymous pull by digest, platforms and size, tags, signature, provenance, version --json against the release archive, scripts/image.sh smoke |
record |
ubuntu-latest |
contents: write |
after verify passes, writes the “Container image” section of the release notes |
Registry write is confined to publish, which logs in with the workflow’s GITHUB_TOKEN;
there is no registry secret. verify never logs in, so a private package fails there before
the release notes point anyone at it.
Re-run safety: scripts/image.sh publish builds the image once, into an OCI layout, and
compares its digest with the one the version tag already points at. A different digest stops
the run before anything is pushed, so a version tag never moves. Otherwise crane pushes that
layout, the exact bytes the guard compared; when the digest is already there the push is a
no-op, so a re-run completes a failed sign, attest, or record step, and record replaces its
section instead of adding a second one. Re-run with
gh workflow run image-release.yml --ref vX.Y.Z -f tag=vX.Y.Z.
Channel tags: latest goes to a stable release and next to a prerelease, each only when
it is the highest tag of its kind on origin, read right before the push. A backport or a re-run
for an older release candidate never moves a channel back, and a run that overlaps a newer
release’s run cannot either.
Forked-PR safety
Section titled “Forked-PR safety”- Required security gates (
workflow-audit,codeql) run on the standardpull_requesttrigger and never receive write-scoped secrets. - Privileged, secret-touching workflows (
scorecardpublish,release) run only on default-branch / tag / schedule contexts — they never execute forked-PR code with elevated scopes. - The
imagejob builds and smoke-tests the image with onlycontents: read: no secrets, no registry login, and nothing pushed. Onlyimage-release.yml, dispatched on a release tag, publishes images. pr-title.ymlusespull_request_targetwith onlypull-requests: read(it reads the PR title and runs no untrusted checkout) — the documented safe use of that trigger.