Skip to content

CI/CD Guidelines

Summary:

Pipeline Stages

Run in order; parallelize within each stage:

  1. Pre-Check — lint & static analysis | secret scan (every push, correctness signal not time budget)
  2. Build — generate changelog (tags only, before Build Docs) | build software (every push, MR, and merge to main — not tags) | build docs (automatic at MR, merge to main, and tags; manually triggerable on a plain push)
  3. Test — unit tests (every push, MR, and merge to main; always against Debug) | BDD scenarios (automatic on MR and merge to main; manually triggerable on a plain push)
  4. Package & Publish / Deploy — package image/artifact | security scan | promote/publish

Never recompile between stages, and never recompile a released artifact — it is promoted (re-tagged) from an already-tested build, not rebuilt. (Build-once principle, Humble & Farley, "Continuous Delivery", 2010.) Inject all configuration via environment variables.

Trigger tiers

Trigger What runs
Push to feature branch (Push tier) Pre-Check (lint, static analysis, secret scan); Debug build; Unit Tests against Debug — the correctness signal every commit needs, not a time budget. BDD scenarios, the coverage job, the docs build, and the Debug image/package preview are available as manual jobs (see "Manual jobs on the Push tier" below).
Merge Request (MR tier) Everything the Push tier already ran, plus: BDD scenarios against Debug now run automatically, the coverage gate, an architecture check, a vulnerability scan, and a docs build (build only, not published)
Merge to main (Merge-to-main tier) Everything the MR tier did already covered by branch protection (see below); additionally builds Release and runs the tests against it; packages a commit-SHA-tagged intermediate image; re-runs coverage automatically so the repository's coverage badge reflects main, not a stale manual run
Git tag vX.Y.Z (promotion tier) No rebuild, no re-test: re-tags the Merge-to-main tier's intermediate image as the release; regenerates CHANGELOG.md; rebuilds docs to embed it; publishes

Precondition: a Merge Request may only be merged once its branch is up to date with main and its pipeline is green; direct pushes to main are disabled. This is what makes the Merge-to-main tier able to skip re-running what the MR tier already validated — the commit landing on main is exactly the tree the MR pipeline tested.

Manual jobs on the Push tier

BDD scenarios, the coverage job, the docs build, and the Debug image/package preview job remain available as manually triggered jobs on a plain feature-branch push, in addition to running automatically at the MR tier. Local execution cannot always reproduce the CI runner's exact toolchain/browser/simulator versions, and an instrumented coverage or documentation build is too expensive to run unconditionally on every push — this gives a developer that signal on demand, before opening an MR, at no cost when never triggered. Jobs with no value before their gating event exists (Release packaging, which only makes sense once a commit has actually reached main) are not offered this way.

coverage additionally runs automatically on the Merge-to-main tier, unlike the other jobs listed above. A repository-hosted coverage badge reads the coverage: value of the latest pipeline on the default branch; an MR-tier run never touches that pipeline, so without this exception the badge would go stale whenever nobody happens to trigger coverage manually on a main push.

Manual docs publish on the default branch

pages itself also carries a manual rule on a plain push to the default branch, alongside its automatic release-tag rule — a different category from the jobs above, worth distinguishing explicitly. The jobs in "Manual jobs on the Push tier" exist because their gating event (a release) hasn't happened yet; offering them early costs nothing when never triggered. pages on the default branch is a manual substitute for part of what the gating event normally does (publish), used when a docs-only fix needs to reach the published site without a version bump. The release-tag rule stays mandatory and automatic; nothing about a release publishing its docs changes.

Commit message validation

The commit-lint job validates $CI_MERGE_REQUEST_TITLE at the MR tier — never individual commit messages on the feature branch, and it does not run on a plain push at all (no MR title exists yet). git.md requires Conventional Commits only for the squash-commit that lands on main, whose message GitLab derives from the MR title by default; linting intermediate feature-branch commits would validate messages the squash discards.

Registry Tagging

"Image" below covers both container images (services, CLI tools) and versioned packages (libraries — e.g. a Conan package, Go module, or Maven artifact) published to their respective registries.

Event Tags Note
Merge Request pr-<N>-sha-<short> (manual only) Debug-tested preview, not release-validated
Merge to main sha-<short>, latest/nightly Release-tested; this is the artifact a tag will promote
Git tag v1.0.0 1.0.0, stable Re-tag (promotion) of the merge-to-main image — no rebuild

Package registries without a native retag-by-reference primitive (Conan, Maven — unlike OCI image registries) promote via a deterministic rebuild from the exact tagged commit instead of a registry-level retag; branch protection already guarantees that commit is content-identical to what the Merge-to-main tier built and tested.

How promotion resolves to the right image: the tag pipeline never searches the registry or picks among candidates. It re-derives the same sha-<short> tag from its own $CI_COMMIT_SHORT_SHA — the tag's own commit — and re-tags exactly that. This is unambiguous only because intermediate images are pushed exclusively from the Merge-to-main tier (see "Trigger tiers" above), never from an MR pipeline: a given commit SHA therefore has at most one intermediate image, so a version tag placed on that commit resolves back to precisely it, with no lookup step that could pick the wrong one among several developers' concurrent MR builds.

Image Build, Scan, and Push

This section applies to the profiles that build a container image.

A container image (service, React frontend, or Go/C++ CLI tool) is built, scanned, and pushed as three separate jobs, not one combined job — the scan gates the push, instead of only reporting on an image that has already shipped. The same three-job shape applies to both the Debug image and the Release image:

build-release-image:
  stage: image
  needs: [release-build]
  image: quay.io/buildah/stable:v1.43.2
  script:
    - buildah build --target release -f image/Dockerfile -t "$IMG" .
    # No registry login: this writes a local archive, nothing is pushed yet. Must be
    # docker-archive:, not oci-archive: — trivy --input below only reads a docker save-style
    # tar (manifest.json at its root), not a tarred OCI image layout.
    - buildah push "$IMG" "docker-archive:image-release.tar:$TAG"
  artifacts:
    paths: [image-release.tar]
    expire_in: 7 days

scan-release-image:
  stage: image
  needs: [build-release-image]
  image:
    name: aquasec/trivy:0.74.0
    entrypoint: [""]
  script:
    # No credentials needed: --input reads the local archive artifact, not the registry.
    - trivy image --input image-release.tar --severity HIGH,CRITICAL --exit-code 0 ...
    - trivy image --input image-release.tar --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 ...

push-release-image:
  stage: image
  needs: [build-release-image, scan-release-image]
  image: quay.io/buildah/stable:v1.43.2
  variables:
    STORAGE_DRIVER: vfs
  before_script:
    - buildah login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
  script:
    # buildah push cannot read docker-archive:image-release.tar directly as its source (only
    # as a destination) — "unsupported transport ... for looking up local images". Load it
    # into local storage first, then push by the image ID that returns.
    - |
      IMAGE_ID=$(buildah pull "docker-archive:image-release.tar:$TAG")
      buildah push "$IMAGE_ID" "docker://$CI_REGISTRY_IMAGE:$TAG"

needs: [build-release-image, scan-release-image] on the push job is deliberate, not redundant: GitLab needs: only pulls artifacts from jobs listed directly, so the push job lists the scan (for the ordering gate) and the build (for the archive artifact itself) — listing only the scan would leave the push job without the image to push.

Only the push job needs registry credentials — the build and scan jobs never talk to the registry at all, a narrower credential footprint than scanning an already-pushed image would need (see "Scanning a local archive vs. an image already in the registry" below).

Makefile Targets

  • build — debug symbols, no optimizations; used on the MR tier for both build and test.
  • release-build — no debug symbols, speed optimizations; built and tested on the Merge-to-main tier, then promoted (not rebuilt) at tag time.
  • release-test (C++ only) — re-runs the same test suite against the Release build. Only languages whose Release mode disables safety/correctness checks that Debug enforces benefit from this; Go and Java skip it.

Releases

A Git tag vX.Y.Z triggers two Build-stage/Package-stage jobs, in this order:

  1. changelog (Build stage, runs first): regenerates CHANGELOG.md (full history) via git-cliff and commits it back to the default branch. Must run before the docs build, since the docs site embeds this file — the docs job depends on (needs:) this job.
  2. release (Package & Publish/Deploy stage): generates RELEASE_NOTES.md (latest tag only, from the changelog job's output) for the GitLab release description, creates the GitLab release entry, re-tags (promotes) the merge-to-main image as the release, and triggers publishing the docs built alongside the changelog.

Required CI/CD variable: CI_ACCESS_TOKEN

Set this as a protected project CI/CD variable with write_repository scope (Project Access Token or Personal Access Token). It allows the pipeline to push the updated CHANGELOG.md back to the default branch. Without it, the changelog commit step is skipped and the job fails.

Documentation

A Git tag vX.Y.Z triggers publishing the documentation as a GitLab Pages site. Publish on release tags only — not on every commit — so published docs always match a stable, released version. The docs site embeds a page rendered from CHANGELOG.md, so the docs build must depend on (needs:) the changelog-generation job, not just on the code build — otherwise it risks embedding a stale changelog.

GitLab Pages requirements

  • Job name must be exactly pages — GitLab Pages ignores any other job name.
  • Artifacts must be in public/ — place the built site output there.
  • expire_in: never — expiring Pages artifacts takes the site offline.
  • Use needs: [changelog] so docs publish only after CHANGELOG.md has been regenerated (see "Releases"). No code-rebuild job runs on the tag pipeline under the trigger-tiered model — the binary was already Release-tested at the Merge-to-main tier, so there is nothing named build left to depend on here.
  • A job named pages always deploys when it runs. "Build the site as a test, without publishing it" therefore needs a second job under a different name — see the mono-repo split below.

Skeleton

pages:
  stage: docs
  image: <your-mkdocs-image>
  needs: [changelog]
  rules:
    - if: $CI_COMMIT_TAG =~ /^v[0-9]+\.[0-9]+\.[0-9]+$/
  cache:
    paths: []
  script:
    - mkdocs build --site-dir public
  artifacts:
    paths:
      - public
    expire_in: never

Mono-repos: one combined site, built often, published on a tag or on request

GitLab allows only one Pages site per project, so in a mono-repo each artifact's own pages job is suppressed and the root pipeline builds a single combined site instead. That site is split across two jobs:

  • docs-build — runs on merge requests and on the default branch whenever a specs/ directory or an mkdocs.yml changed, keeps public/ as a short-lived artifact, and publishes nothing. This catches a broken docs build at review time.
  • pages — runs automatically on any artifact's release tag and publishes; it also carries a manual rule on a plain push to the default branch, for republishing a docs-only fix without waiting for the next release (see "Manual docs publish on the default branch" above).

The combined site is a catalogue rather than a released artifact of its own, so it carries a build date instead of a version number either way, plus a table of each artifact's released version regenerated from the repository's Git tags.

Single-artifact projects: the same split, automatic at MR

A single-artifact (non-mono-repo) project gets the same two-job split, minus the multi-artifact version table: docs-build runs automatically at the MR tier and is manually triggerable on a plain push (see "Manual jobs on the Push tier" above, same as coverage), keeps public/ as a short-lived artifact, and publishes nothing — this catches a broken docs build at review time without slowing down every plain push. pages itself carries the same two rules as the mono-repo root's: automatic and mandatory on a release tag, manual on a plain push to the default branch.

GitLab Hints

CI_ACCESS_TOKEN setup (required for the changelog job)

The changelog job pushes the updated CHANGELOG.md back to the default branch. This requires a token with push access and matching branch protection settings.

Required setup (once per project):

  1. Protected Branch setting — Settings → Repository → Protected Branches → main
    • "Allowed to push": Maintainers (Developers still cannot push directly and must use Merge Requests.)
  2. Create a Project Access Token — Settings → Access Tokens
    • Role: Maintainer
    • Scope: write_repository
  3. Add CI/CD variable — Settings → CI/CD → Variables
    • Name: CI_ACCESS_TOKEN
    • Value: the token
    • Enable Protected
  4. Protect the version tag pattern — Settings → Repository → Protected Tags
    • Add pattern v*, or *-v* in a mono-repository: its tags are namespaced <artifact>-vX.Y.Z, so v* never matches and protected-variable injection silently fails

Protected variables are only injected into pipelines triggered by protected branches or tags. If the tag is not protected, CI_ACCESS_TOKEN is empty and the push fails with 401.

GitLab quirks worth knowing before your first release

  • Never push multiple tags in one git push (e.g. git push --tags) — GitLab only creates a pipeline for a limited number of refs per push; extra tags are silently skipped. Push each tag with its own git push origin refs/tags/<name>.
  • The changelog job moves the default branch by committing back to it. If you tag a second artifact afterward, always target an explicit commit SHA (git tag <name> <sha>), never "create from branch tip" — the tip may already be a sibling's changelog commit that never built this artifact's own image.
  • "manifest unknown" right after a successful push-release-image can be registry propagation lag — retry the job. If it persists, check the namespace's storage quota (Usage Quotas → Storage); job artifacts count against it and can silently starve the container registry.

Reserved job name image:

image: is a reserved keyword in GitLab CI/CD — it sets the Docker executor image for a job, not a job name. Using it as a job name causes a pipeline parse error.

Name jobs after what they build, e.g. build-debug-image: and push-release-image:, never bare image:.

Registry promotion (retag without rebuild)

The tag/promotion tier re-tags the merge-to-main image as the release instead of rebuilding it. Plain docker pull + docker tag + docker push works, but a dedicated promotion tool avoids pulling the full image layer set just to re-tag it:

  • crane tag <image>@<digest> vX.Y.Z (go-containerregistry)
  • skopeo copy docker://<image>:sha-<short> docker://<image>:vX.Y.Z
  • or the CI provider's native retag/promote mechanism, if it has one

For library projects, the equivalent is promoting/aliasing the already-published merge-to-main package version in the package registry, not rebuilding the package.

Overriding ENTRYPOINT for non-shell images

Some CI images declare a custom ENTRYPOINT (e.g. aquasec/trivy uses trivy). GitLab CI injects commands via sh -c, which fails when a custom ENTRYPOINT is set. Override it with entrypoint: [""] to restore normal script execution.

Always use the long-form image: block for these images:

scan-source:
  image:
    name: aquasec/trivy:0.74.0
    entrypoint: [""]
  script:
    - trivy fs ...

Scanning a local archive vs. an image already in the registry

A filesystem scan (trivy fs) reads the checkout and needs no authentication. An image scan (trivy image) needs one of two sources, with different credential requirements:

  • A local archive (the build → scan → push chain below): trivy image --input <archive> reads the file directly — no registry access, no credentials at all. This is the normal case: a freshly built image is scanned before it is ever pushed.
  • An image already in the registry (scan-promoted-image, re-scanning at tag/promotion time because vulnerability databases evolve independently of image contents — the image itself doesn't change): a CI runner has no Docker daemon, containerd, or podman to fetch it with, so Trivy falls back to the registry and, without credentials, gets DENIED: access forbidden on a private registry. The image-building job's credentials do not carry over — those live in that job's own container — so the scan job needs its own:
scan-promoted-image:
  image:
    name: aquasec/trivy:0.74.0
    entrypoint: [""]
  variables:
    TRIVY_USERNAME: "$CI_REGISTRY_USER"
    TRIVY_PASSWORD: "$CI_REGISTRY_PASSWORD"
  script:
    - trivy image --image-src remote ...

Pin the source explicitly (--input/--image-src remote) either way, so a misconfigured job fails naming the one real problem instead of Trivy trying four unavailable runtimes in sequence.

A source scan must know about the dependency cache

A pipeline that caches dependencies inside $CI_PROJECT_DIR — .m2/repository, .go-cache, .conan, .npm-cache — has that cache restored into every job, because the cache: block lives under default:. The scan job gets it too, and that cuts both ways.

Keep the cache out of the walk. Where Trivy's analyzers recognise what the cache holds, it becomes part of the scan. Go is the clear case: every go.mod below .go-cache/pkg/mod is a scan target of its own, so a freshly generated service reports 35 targets and 228 packages instead of 1 and 13 — the surplus being module versions the project does not depend on.

  script:
    - trivy fs --exit-code 1 --severity HIGH,CRITICAL --scanners secret,vuln --skip-dirs .go-cache .

Pass --skip-dirs for the cache directory in every language, not just Go. The other caches happen not to match any analyzer today — Trivy looks for pom.xml, package-lock.json and conan.lock, none of which a package cache contains — but that is a property of the current analyzer set, not a guarantee, and the flag costs nothing.

Let the resolver read the cache. For a Maven project the same directory is something Trivy needs. Resolving a pom.xml means following its parent and imported-BOM chain to find the versions of managed dependencies, and Trivy looks for those POMs in ~/.m2/repository — not where -Dmaven.repo.local put them. Without the pointer it fetches every POM from Maven Central, which answers a shared runner IP with 429 Too Many Requests and a 30-minute block:

  before_script:
    - mkdir -p ~/.m2
    - printf '<settings><localRepository>%s/.m2/repository</localRepository></settings>\n' "$CI_PROJECT_DIR" > ~/.m2/settings.xml

The effect is not subtle. On a generated Spring Boot service, scanning without the pointer resolves 2 packages; with it, 41 — and no longer touches the network at all. --skip-dirs and <localRepository> do not conflict: the first controls the walk over the scan target, the second the resolver's lookups.

Resist --offline-scan as the fix. It removes the network access, but on a cold cache Trivy then drops every dependency it cannot resolve — for a BOM-managed stack nearly all of them — logs a single WARN, and exits successfully. A rare rate-limit failure is a better outcome than a scan that passes because it found nothing to look at.

Order the scan after whatever resolves the dependencies, don't just point it at the cache. Pointing Trivy at $CI_PROJECT_DIR/.m2/repository only pays off once that directory holds something. Jobs in the same stage run in parallel by default, and cache: is restored at job start and saved at job end — a job never sees what a sibling job resolved while both are still running. So the very first pipeline to see a new dependency version (an ordinary Maven-BOM version bump, not just a brand-new project) races the scan job against every other job that needs that same version, over the network, at the same moment:

scan-source:
  stage: check
  needs: [arch-test]  # arch-test shares scan-source's rule set, so it always runs alongside it,
                       # and its own `mvn` resolves the graph before scan-source needs it

Pick a needs target that carries the exact same rules: as the scan job itself — otherwise a rule change to one, and not the other, can leave scan-source waiting on a job that never starts.

Gate on fixable findings, report everything

A base image regularly carries CVEs its distribution has not patched yet — Trivy reports those with status affected and an empty fixed version. Nothing in your repository can resolve them, so failing the job on them leaves it permanently red and trains everyone to merge past a failing pipeline. Run the scan twice instead: once reporting everything without failing, once gating on what has a published fix.

  script:
    - trivy image --image-src remote --severity HIGH,CRITICAL --exit-code 0 "$IMAGE"
    - trivy image --image-src remote --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 "$IMAGE"

The vulnerability database is fetched once and cached within the job, so the second pass is cheap. Keeping base image tags current remains a separate, ongoing duty — --ignore-unfixed silences the gate, not the risk.

Quality report artifact convention

All quality gate outputs must be written to test_reports/ and exposed as CI artifacts with when: always and expire_in: 7 days. This makes reports downloadable from any pipeline run — including failed runs where the report is most needed.

Sub-directory Content
test_reports/linting/ Linter output (checkstyle XML, text)
test_reports/arch-test/ Architecture check output
test_reports/test/ Unit / BDD test results (JUnit XML)
test_reports/coverage/ Coverage reports (Cobertura XML)

CI artifact block for quality gate jobs:

  artifacts:
    when: always
    paths:
      - test_reports/<subdir>/
    expire_in: 7 days

GitHub Hints

RELEASE_TOKEN setup (required for the changelog job)

The changelog job pushes the updated CHANGELOG.md back to the default branch. The built-in GITHUB_TOKEN cannot do this on a protected branch — a personal access token with write access is required.

Required setup (once per repository):

  1. Create a fine-grained personal access token — Settings (user) → Developer settings → Personal access tokens → Fine-grained tokens
    • Repository access: this repository only
    • Permissions: Contents: Read and write
  2. Add it as a repository secret — Settings (repository) → Secrets and variables → Actions
    • Name: RELEASE_TOKEN
    • Value: the token
  3. Branch protection — Settings → Branches → protect main, but allow this token's owning account (or a bot account, for a team) in the bypass/allowlist if "Restrict who can push" is enabled — otherwise the changelog job's push is rejected the same way an unprivileged push would be.

packages: write for ghcr.io

This and the next section apply to the profiles that build a container image.

Every job that pushes or retags an image (push-{debug,release}-image, promote-release-image) declares its own permissions: { packages: write } — GitHub Actions' default GITHUB_TOKEN permissions do not include registry write access unless a job opts in explicitly. Scope it per-job, not workflow-wide: a build/scan job that never talks to the registry should not carry write access it doesn't use (see "least-privilege credential scoping" in security.md).

push-release-image:
  permissions:
    packages: write
  steps:
    - uses: docker/login-action@v3
      with:
        registry: ghcr.io
        username: ${{ github.actor }}
        password: ${{ secrets.GITHUB_TOKEN }}

secrets.GITHUB_TOKEN (not a separate PAT) is sufficient here: pushing to ghcr.io only needs the job's own repository-scoped token, unlike changelog's cross-branch push above.

Triggering a Debug-image build (workflow_dispatch)

GitHub Actions has no per-job manual trigger equivalent to GitLab's when: manual — a workflow is either triggered automatically by an event or manually as a whole. The Debug-image chain (build-debug-image → scan-debug-image → push-debug-image) is therefore gated on workflow_dispatch instead: every job in that chain carries if: github.event_name == 'workflow_dispatch', and the whole workflow is re-run on demand rather than one job being escalated within an existing run.

To build and push a Debug image: Actions tab → select the CI workflow → Run workflow → choose the branch → Run workflow. Every job whose if: does not match workflow_dispatch (the ordinary push/PR jobs) is skipped in that run, and only the Debug-image chain executes.

Handing an image between jobs without a registry round-trip

GitLab's build → scan → push chain hands the image between jobs as a job artifact (docker-archive:image.tar). The GitHub-native equivalent uses docker/build-push-action's outputs: type=docker,dest=<file> to write the same kind of local archive, then actions/upload-artifact/actions/download-artifact to move it between jobs — docker load in the push job turns it back into a locally available image:

build-release-image:
  steps:
    - uses: docker/build-push-action@v6
      with:
        push: false
        outputs: type=docker,dest=image-release.tar
    - uses: actions/upload-artifact@v4
      with: { name: image-release, path: image-release.tar }

scan-release-image:
  needs: build-release-image
  steps:
    - uses: actions/download-artifact@v4
      with: { name: image-release }
    - uses: aquasecurity/trivy-action@v0.36.0
      with: { input: image-release.tar, exit-code: "1", ignore-unfixed: true }

As on GitLab, only the push job ever logs in — build and scan never see registry credentials.

Coverage quality gate

All projects enforce a minimum coverage of 80%. The make coverage target fails if the threshold is not met — this works both locally and in CI. Coverage is measured against the Debug build, which is what Unit Tests always run against. It counts the unit and component tests, programmed ones and BDD scenarios alike, but no integrative test (see testing.md).

make coverage prints Coverage: <n>% in every language. GitLab extracts the figure for pipeline views and merge request diffs with the coverage: key of the coverage job, using /Coverage:\s+(\d+(?:\.\d+)?%)/ for every language.

Language Tool Metric
C++ gcovr Lines
Go go test, go tool covdata Statements
Java JaCoCo Lines
TypeScript Vitest, Playwright Lines

The threshold is enforced inside make coverage, not in the CI YAML, so running make coverage locally also fails fast on insufficient coverage.

Runtime dependency resolution isn't "installing tools"

"Never install tools in pipeline jobs" (below) targets OS-level tooling — compilers, linters, curl, jq — which never changes per commit and belongs baked into the CI build image once. A project's own package-manager dependencies (Conan, npm, Maven, Go modules) are different: they are pinned per commit in the project's own manifest, change with ordinary feature work, and resolving them at pipeline runtime — backed by the dependency cache (see "A source scan must know about the dependency cache" above), not baked into the image — is the intended tradeoff, not a rule violation.

Directives

  • Stages in order: Pre-Check → Build → Test → Package & Publish/Deploy; never recompile between stages
  • A push builds and unit-tests the Debug build; a merge request adds BDD scenarios, coverage, arch-test, vulnerability scan, and a docs build; a merge to main also builds and tests Release
  • Merge only a branch that is up to date with main and green; no direct pushes to main
  • At least 80 % coverage via make coverage on the Debug build; fail below
  • commit-lint validates the merge request title, not each commit
  • git-cliff regenerates CHANGELOG.md on a release tag and pushes it back with a protected token: CI_ACCESS_TOKEN on GitLab, RELEASE_TOKEN on GitHub
  • Inject configuration through environment variables; never bake it into an image
  • Never install tools in a job; the CI image provides them. Resolving the project's own dependencies against the cache is not installing tools
  • Quality reports go to test_reports/, kept when: always for 7 days
  • GitLab Pages: the job is named pages, publishes public/, and runs on a release tag and, manually, on the default branch; a job that only builds the docs has another name
  • Never name a job image:; push tags one by one, never with git push --tags
  • GitHub Actions mirrors the GitLab jobs and tiers through if: conditions; a job GitLab starts manually runs on workflow_dispatch
  • A release tag re-tags the image built on main as X.Y.Z and stable; never rebuild or re-test it
  • Build, scan, and push an image in three jobs, handing it over as an archive; the push job needs: both other jobs
  • Scan twice: report everything with --exit-code 0, gate with --ignore-unfixed --exit-code 1
  • Never pass --offline-scan; trivy fs skips the cache directories