Skip to content

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 (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.


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.0

Rules:

  • 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-provenance pins from release.yml, and adds one: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 for the SBOM attestations.

Enforced by the workflow-audit gate (see below). To pin a new action, resolve its tag to a SHA:

Terminal window
gh api repos/<owner>/<repo>/commits/<tag> --jq .sha

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):

Terminal window
./scripts/bootstrap.sh # installs actionlint into ./bin; checks for uv
./scripts/audit-workflows.sh # actionlint + uvx zizmor==$ZIZMOR over .github/workflows

Policy lives in .github/zizmor.yml.

Never disable the gate. Waive narrowly, with justification, either:

  • inline: # zizmor: ignore[<rule-id>] — <reason> on the offending line, or
  • in .github/zizmor.yml with 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 codeql required 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.

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.


.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.


  • Tool cache: a actions/cache step restores ./bin keyed on hashFiles('.config/tool-versions') + runner OS, before bootstrap.sh, so the pinned tools are reused instead of go install-ed every run. A pin bump changes the key and forces a clean reinstall.
  • Go build/module cache: provided by actions/setup-go with cache: true (keyed on go.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:

  1. Install the Renovate app from the GitHub Marketplace (https://github.com/apps/renovate) onto the glapsfun/gskill repository (a one-time repo-admin action; no secrets, no self-hosted runner).
  2. Renovate reads renovate.json, which restricts enabledManagers to two custom regex managers, one for .config/tool-versions and one for the .ko.yaml base image digest (so it never overlaps Dependabot’s gomod / github-actions scope).
  3. On its schedule, Renovate opens PRs that bump each tool pin while preserving the NAME=version format, and a weekly PR that moves the base image to the current digest of cgr.dev/chainguard/git:latest.

Alternative (not used): a self-hosted SHA-pinned renovate.yml workflow running renovatebot/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. main merges through a merge queue, so a required check must also run on merge_group (ci.yml does); one that does not leaves queued merges waiting until they time out.
  • image (amd64) and image (arm64) also run on merge_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 scorecard required (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.


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).


.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.


  • Required security gates (workflow-audit, codeql) run on the standard pull_request trigger and never receive write-scoped secrets.
  • Privileged, secret-touching workflows (scorecard publish, release) run only on default-branch / tag / schedule contexts — they never execute forked-PR code with elevated scopes.
  • The image job builds and smoke-tests the image with only contents: read: no secrets, no registry login, and nothing pushed. Only image-release.yml, dispatched on a release tag, publishes images.
  • pr-title.yml uses pull_request_target with only pull-requests: read (it reads the PR title and runs no untrusted checkout) — the documented safe use of that trigger.