Spec: phpboyscout/cicd v0.1¶
- Repository:
gitlab.com/phpboyscout/cicd - First consumers:
phpboyscout/terraform-aws-bootstrap,phpboyscout/terraform-aws-security-baseline,phpboyscout/infra— viainclude: component: gitlab.com/phpboyscout/cicd/<name>@v0.1.0in each project's.gitlab-ci.yml(GitLab migration spec Phase C.2b).
Summary¶
A single GitLab project hosting versioned CI/CD components reusable across every phpboyscout project. v0.1.0 ships the four components needed to replace the existing GitHub Actions gate on the three terraform-related repos:
tofu-lint—tofu fmt -check,tflint --recursive,terraform-docs --check.tofu-security—trivy config,checkov -d .,gitleaks detect.tofu-validate—tofu init -backend=false && tofu validatewalked across consumer-specified paths.zensical-pages— pip-installzensicalfrom the consumer'srequirements-lock.txt, build the microsite, deploy via GitLab Pages (onmainonly by default).
All four run inside registry.gitlab.com/phpboyscout/images/infra-tools
(see phpboyscout/images/infra-tools v0.1.0). Tool versions are pinned
in that image and bumped via Renovate's mise manager — components
themselves stay tool-version-agnostic.
The project is intentionally scope-broader-than-infra at the path
level (just phpboyscout/cicd, not phpboyscout/components/infra-ci).
Future components for Go / Rust / Python / Node CI live in the same
project, prefixed by domain in their template name (go-test.yml,
rust-build.yml, etc.).
Motivation¶
Without shared components, the three terraform repos (and any future
ones) duplicate identical CI gate logic in their .gitlab-ci.yml.
Bug fixes and policy changes (e.g. the trivy soft-fail decision we
made in infra-tools) would need backporting N times.
Versioned shared components turn that into:
- One repo to author / review / test the gate logic.
- One Renovate bump per repo to roll forward.
- One CHANGELOG showing precisely what changed in each consumer-visible release.
GitLab CI/CD Components are the native primitive for this; using them
keeps us inside GitLab's intended affordances (no script:-based
bootstrap, no shell function libraries, no copy-pasted YAML anchors).
Decisions¶
D1 — Single project, monorepo of components¶
All components live in one project at gitlab.com/phpboyscout/cicd,
not split per domain (e.g. phpboyscout/components/infra-ci,
phpboyscout/components/go-ci). Reasons:
- Reuse the same Renovate config / release flow across every component. One pipeline, one CHANGELOG, one CI/CD Catalog entry.
- Versioning is shared anyway — GitLab tags the project, not individual components. Multiple project paths wouldn't decouple versioning.
- Future cross-domain consumers (e.g. an OSS tool repo using Zensical for docs AND Go for tests) include both via the same project path; less mental overhead than remembering N domain-specific component projects.
Names disambiguate domain: tofu-* for OpenTofu, go-* for Go,
zensical-* for Zensical, etc.
D2 — Single-file templates (not directory-form)¶
Each component is templates/<name>.yml. The directory form
(templates/<name>/template.yml with sidecar files) is reserved for
components that ship supporting scripts or Dockerfiles. None of v0.1's
four need that.
D3 — Inputs surface¶
Per the GitLab CI/CD Components guide best-practice, components do
not hardcode stage or image:. All four expose at minimum:
| Input | Type | Default | Notes |
|---|---|---|---|
image_version |
string | "v0.1.0" |
infra-tools image tag |
stage |
string | per-component (lint / security / test / pages) |
consumer's stage layout |
Per-component inputs:
| Component | Additional inputs |
|---|---|
tofu-lint |
paths (string, space-separated dirs to recurse, default ".") |
tofu-security |
paths (string, space-separated dirs to scan, default ".") |
tofu-validate |
validate_paths (string, space-separated globs to walk per-directory, default ".") |
zensical-pages |
docs_path (string, default "docs"), deploy_branch (string, default "main"), python_lock (string, default "requirements-lock.txt") |
Strings used for path lists rather than array because shell
expansion handles iteration naturally; arrays in GitLab components
need indexed access ($[[ inputs.paths[0] ]]) and don't support
iteration. The tradeoff: callers pass "a b c" instead of
["a","b","c"]; we accept that.
D4 — No global keywords inside templates¶
Templates declare jobs, not pipeline-level configuration. Specifically:
- No
stages:block — consumers declare their own stage list once at the root of their.gitlab-ci.yml. - No
default:block — every job sets its ownimage:,cache:, etc. - No global
variables:— job-level only.
This avoids surprises when consumers compose multiple components.
D5 — spec.component: [version] for traceability¶
Every template declares spec.component: [version] and emits the
running component version in its log preamble:
spec:
component: [version]
inputs:
# ...
---
tofu-fmt:
before_script:
- echo "tofu-lint $[[ component.version ]] — image $[[ inputs.image_version ]]"
Trivial to add, surfaces "which version produced this run" in CI logs
without users opening the consumer's .gitlab-ci.yml.
D6 — Image-pinning convention¶
Components use:
image_version defaults to the same major.minor as phpboyscout/cicd
itself (v0.1.0 defaults to image_version: "v0.1.0"). When cicd cuts
v0.2.0 we'd typically bump the default to match the latest
infra-tools minor. This keeps the two projects loosely coupled but
predictably aligned.
D7 — Self-test via tests/¶
Each component has a fixture at tests/<name>/.gitlab-ci.yml that
includes the component from $CI_SERVER_FQDN/$CI_PROJECT_PATH/<name>@$CI_COMMIT_SHA
and runs it against a minimal fixture directory in tests/<name>/fixture/.
The project's root .gitlab-ci.yml triggers each test as a child
pipeline so failures isolate per-component. Trigger-on-changes
narrows to the relevant template directory.
D8 — Catalog publication deferred to v0.2¶
v0.1.0 ships consumable-by-include but not cataloged. Catalog publication requires:
- The "CI/CD Catalog project" Settings toggle ON.
- A
release:job in the root.gitlab-ci.yml(not the Releases API). - Project description set (we have one), README at the tagged commit (we will), at least one template (we have four).
We add catalog publication when there's a real reason to surface the
project outside phpboyscout. v0.1 consumers reference by URL
directly; catalog adds discoverability, not capability.
D9 — Versioning policy¶
Same pre-1.0 caveat as infra-tools: minor bumps may break input
shape. Components are pinned to vX.Y.Z by consumers and bumped via
Renovate. The CHANGELOG calls out every input-shape change.
Open questions¶
- OQ1 — Should
zensical-pagesrun on every push or only onmain? The component defaults to building on every pipeline (so MR previews are possible later) but only deploying Pages on the configureddeploy_branch. Verify with first consumer. - OQ2 — Should
tofu-validatesetterraform initcache anywhere? CI runs are short-lived and the providers download is small. v0.1 ships without cache. If pipeline duration becomes painful, add acache_keyinput and usecache:per job.
Component catalogue¶
tofu-lint¶
Runs in infra-tools. Three jobs:
tofu-fmt:
stage: $[[ inputs.stage ]]
image: registry.gitlab.com/phpboyscout/images/infra-tools:$[[ inputs.image_version ]]
script:
- tofu fmt -check -recursive $[[ inputs.paths ]]
tflint:
stage: $[[ inputs.stage ]]
image: registry.gitlab.com/phpboyscout/images/infra-tools:$[[ inputs.image_version ]]
script:
- tflint --init
- tflint --recursive $[[ inputs.paths ]]
terraform-docs-drift:
stage: $[[ inputs.stage ]]
image: registry.gitlab.com/phpboyscout/images/infra-tools:$[[ inputs.image_version ]]
script:
- terraform-docs --output-mode=inject --output-file=README.md
markdown table $[[ inputs.paths ]]
- git diff --exit-code || (echo "terraform-docs drift — re-run locally and commit." && exit 1)
tofu-security¶
Three jobs:
trivy-config:
stage: $[[ inputs.stage ]]
image: registry.gitlab.com/phpboyscout/images/infra-tools:$[[ inputs.image_version ]]
script:
- trivy config --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 $[[ inputs.paths ]]
checkov:
stage: $[[ inputs.stage ]]
image: registry.gitlab.com/phpboyscout/images/infra-tools:$[[ inputs.image_version ]]
script:
- checkov -d $[[ inputs.paths ]] --framework terraform --quiet
gitleaks:
stage: $[[ inputs.stage ]]
image: registry.gitlab.com/phpboyscout/images/infra-tools:$[[ inputs.image_version ]]
script:
- gitleaks detect --source=$[[ inputs.paths ]] --no-banner
tofu-validate¶
One job that iterates:
tofu-validate:
stage: $[[ inputs.stage ]]
image: registry.gitlab.com/phpboyscout/images/infra-tools:$[[ inputs.image_version ]]
script:
- for dir in $[[ inputs.validate_paths ]]; do
echo "::: $dir";
(cd "$dir" && tofu init -backend=false -input=false && tofu validate -no-color);
done
zensical-pages¶
Two jobs (build always, pages deploy on deploy_branch):
zensical-build:
stage: $[[ inputs.stage ]]
image: registry.gitlab.com/phpboyscout/images/infra-tools:$[[ inputs.image_version ]]
script:
- python3 -m venv .venv
- .venv/bin/pip install --quiet -r $[[ inputs.python_lock ]]
- .venv/bin/zensical build --site-dir public
artifacts:
paths:
- public
pages:
stage: $[[ inputs.stage ]]
image: registry.gitlab.com/phpboyscout/images/infra-tools:$[[ inputs.image_version ]]
needs: [zensical-build]
rules:
- if: $CI_COMMIT_BRANCH == "$[[ inputs.deploy_branch ]]"
script:
- echo "Publishing $[[ inputs.docs_path ]] -> GitLab Pages"
pages: true
artifacts:
paths:
- public
Risk register¶
| Risk | Mitigation |
|---|---|
| Component breaks all three consumers simultaneously | Self-tests in tests/<name>/ exercise each component on a fixture before tagging; consumers pin to vX.Y.Z so a broken push to develop never affects them. |
| Consumer hits a job-name collision when including multiple components | Each component uses unique job names (tofu-fmt, tflint, terraform-docs-drift, trivy-config, checkov, gitleaks, tofu-validate, zensical-build, pages). Eight unique names across the four components. |
Image version drift between cicd and infra-tools confuses consumers |
Document the convention in README and in each component's image_version default; Renovate bumps both. |
| Zensical install (~20s) per pipeline becomes painful | Track via CI durations; bake into infra-tools (or a dedicated image) when proven. |
trivy/checkov in components hard-fail vs infra-tools soft-fail |
In cicd components we hard-fail (consumer projects' security findings ARE in our control, unlike upstream Go binaries). |
Implementation plan¶
- Spec lands — this file.
templates/— four component files per the catalogue above.tests/— one fixture per component plus a tiny.tf/docs/directory that exercises it.- Root
.gitlab-ci.yml— child-pipeline triggers, one per component, withchanges:rules narrowing to the relevant template file. renovate.json— mise + dockerfile managers (we depend on theinfra-toolsimage tag).README.md+CHANGELOG.md+CLAUDE.md+SECURITY.md+LICENSE.- CI green on develop tip; merge develop → main; tag v0.1.0.
- Consumer wiring — Phase C.2b (separate task #19).
Follow-ups¶
v0.2.0— Catalog publication: add the Settings toggle + arelease:job. Project goes discoverable.v0.x— go-build / go-test / go-release components, whenphpboyscout/go-tool-basemigrates to GitLab CI.- Per-component README.md under
templates/<name>/README.mdif a component grows enough docs to need its own page. - Renovate
customManagersfor component-version references in consumer repos — so consumers'@vX.Y.Zreferences get bumped alongside everything else.