CI/CD Guidelines¶
Summary:
Pipeline Stages¶
Run in order; parallelize within each stage:
- Pre-Check — lint & static analysis | secret scan (every push, correctness signal not time budget)
- 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 tomain, and tags; manually triggerable on a plain push) - Test — unit tests (every push, MR, and merge to
main; always against Debug) | BDD scenarios (automatic on MR and merge tomain; manually triggerable on a plain push) - 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:
changelog(Build stage, runs first): regeneratesCHANGELOG.md(full history) viagit-cliffand 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.release(Package & Publish/Deploy stage): generatesRELEASE_NOTES.md(latest tag only, from thechangelogjob'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 afterCHANGELOG.mdhas 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 namedbuildleft to depend on here. - A job named
pagesalways 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 aspecs/directory or anmkdocs.ymlchanged, keepspublic/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):
- Protected Branch setting — Settings → Repository → Protected Branches →
main- "Allowed to push": Maintainers (Developers still cannot push directly and must use Merge Requests.)
- Create a Project Access Token — Settings → Access Tokens
- Role: Maintainer
- Scope:
write_repository
- Add CI/CD variable — Settings → CI/CD → Variables
- Name:
CI_ACCESS_TOKEN - Value: the token
- Enable Protected
- Name:
- 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, sov*never matches and protected-variable injection silently fails
- Add pattern
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 owngit push origin refs/tags/<name>. - The
changelogjob 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-imagecan 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:
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, getsDENIED: access forbiddenon 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:
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):
- 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
- Add it as a repository secret — Settings (repository) → Secrets and variables → Actions
- Name:
RELEASE_TOKEN - Value: the token
- Name:
- 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 thechangelogjob'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
mainalso builds and tests Release - Merge only a branch that is up to date with
mainand green; no direct pushes tomain - At least 80 % coverage via
make coverageon the Debug build; fail below commit-lintvalidates the merge request title, not each commitgit-cliffregeneratesCHANGELOG.mdon a release tag and pushes it back with a protected token:CI_ACCESS_TOKENon GitLab,RELEASE_TOKENon 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/, keptwhen: alwaysfor 7 days - GitLab Pages: the job is named
pages, publishespublic/, 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 withgit push --tags - GitHub Actions mirrors the GitLab jobs and tiers through
if:conditions; a job GitLab starts manually runs onworkflow_dispatch - A release tag re-tags the image built on
mainasX.Y.Zandstable; 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 fsskips the cache directories