Skip to content

Spec: phpboyscout/cicd v0.1

  • Repository: gitlab.com/phpboyscout/cicd
  • First consumers: phpboyscout/terraform-aws-bootstrap, phpboyscout/terraform-aws-security-baseline, phpboyscout/infra — via include: component: gitlab.com/phpboyscout/cicd/<name>@v0.1.0 in 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-linttofu fmt -check, tflint --recursive, terraform-docs --check.
  • tofu-securitytrivy config, checkov -d ., gitleaks detect.
  • tofu-validatetofu init -backend=false && tofu validate walked across consumer-specified paths.
  • zensical-pages — pip-install zensical from the consumer's requirements-lock.txt, build the microsite, deploy via GitLab Pages (on main only 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 own image:, 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: registry.gitlab.com/phpboyscout/images/infra-tools:$[[ inputs.image_version ]]

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-pages run on every push or only on main? The component defaults to building on every pipeline (so MR previews are possible later) but only deploying Pages on the configured deploy_branch. Verify with first consumer.
  • OQ2 — Should tofu-validate set terraform init cache anywhere? CI runs are short-lived and the providers download is small. v0.1 ships without cache. If pipeline duration becomes painful, add a cache_key input and use cache: 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

  1. Spec lands — this file.
  2. templates/ — four component files per the catalogue above.
  3. tests/ — one fixture per component plus a tiny .tf / docs/ directory that exercises it.
  4. Root .gitlab-ci.yml — child-pipeline triggers, one per component, with changes: rules narrowing to the relevant template file.
  5. renovate.json — mise + dockerfile managers (we depend on the infra-tools image tag).
  6. README.md + CHANGELOG.md + CLAUDE.md + SECURITY.md + LICENSE.
  7. CI green on develop tip; merge develop → main; tag v0.1.0.
  8. Consumer wiring — Phase C.2b (separate task #19).

Follow-ups

  • v0.2.0 — Catalog publication: add the Settings toggle + a release: job. Project goes discoverable.
  • v0.x — go-build / go-test / go-release components, when phpboyscout/go-tool-base migrates to GitLab CI.
  • Per-component README.md under templates/<name>/README.md if a component grows enough docs to need its own page.
  • Renovate customManagers for component-version references in consumer repos — so consumers' @vX.Y.Z references get bumped alongside everything else.