Authoring cicd components¶
This guide is for anyone adding or changing a component in
phpboyscout/cicd. It distils the conventions and intent behind the
components into one place; the authoritative decision records are the
dated specs in docs/development/specs/.
What the components are¶
phpboyscout/cicd is a monorepo of reusable GitLab CI/CD
components, released together under one tag stream. Each component is
a single templates/<name>.yml file. A consumer references it by URL
and pins a tag:
Three goals shape every component:
- Reusable — one component, many consumers; behaviour is driven by inputs, never by editing the template.
- Caller-agnostic — a component knows nothing about a specific project, AWS account, branch, or token name. Consumer-specific facts arrive as inputs.
- Third-party-friendly — the components are MIT-licensed and usable outside phpboyscout. Defaults assume the common case; anything a different consumer would need is an overridable input.
Components run inside
registry.gitlab.com/phpboyscout/images/infra-tools. Tool versions are
pinned in that image, not here.
Anatomy of a component¶
A component is one file in single-file form — an input spec, a ---
separator, then the jobs:
# templates/<name>.yml
spec:
component: [version] # always — see below
inputs:
image_version: { type: string, default: "vX.Y.Z" }
stage: { type: string, default: "<stage>" }
# ... component-specific inputs
---
<job-name>:
stage: $[[ inputs.stage ]]
image: registry.gitlab.com/phpboyscout/images/infra-tools:$[[ inputs.image_version ]]
script:
- echo "<name> $[[ component.version ]] (image $[[ inputs.image_version ]])"
# ...
spec.component: [version] is mandatory: it lets a job echo the
running component version as its first action, so a CI trace shows
exactly which release ran.
Conventions¶
Inputs¶
image_versionandstageare always inputs. Default them sensibly, never hardcode — the consumer's image pin and stage layout win.- No global keywords (
stages:,default:,variables:) at the top level of a template. Declare everything per-job. - Path-list inputs are strings, not arrays (e.g.
"a/* b/*"). The job word-splits them in shell; GitLab component arrays do not iterate. - A default serves the common case. If a value is consumer-specific, it is an input — not a hardcoded constant.
Token inputs¶
Where a component authenticates to GitLab — state-backend access, the jobs-artifacts API, the package registry — it must not hardcode a credential or name a consumer's CI variable. Instead:
- Expose a string input for the token, defaulting to
$CI_JOB_TOKEN— GitLab's predefined job token. - The consumer overrides the input with whatever credential they hold, under whatever name they chose:
- Document, in the input's
description, when the default is insufficient and what scope an override needs (e.g. "on GitLab Free, override with a PAT carrying job-artifact read"). - A token value reaches the runner only through a CI variable; the consumer marks that variable Masked to keep it out of job logs.
This is what makes a token-using component reusable: a consumer on a
tier and topology where the job token suffices configures nothing; one
who needs a scoped PAT passes it, without inheriting phpboyscout's
token names or conventions. See specs/2026-05-19-token-inputs-v0.5.md
(D1).
rules: — match the trigger to where the job can run¶
A component's rules: must only let the job run where it can actually
succeed:
- Gate components (
tofu-lint,tofu-security, …) carry an explicit unconditionalrules: [{ when: on_success }]— a rule-less job is skipped in merge-request pipelines, so the gate would miss the MR (v0.4 spec). - OIDC jobs (
tofu-plan,tofu-apply) run only where the cloud IAM trust policy accepts the pipeline's OIDC subject — MR and default branch for a plan, release tags for aref-mode apply (v0.3 spec D3). A job that runs where assume-role will fail is a bug.
Interpolation: config-time vs runtime¶
$[[ inputs.x ]]is resolved when the pipeline config is assembled;$VARis resolved by the runner at job time. A token input works because$[[ inputs.token ]]interpolates to the literal$VAR, which the runner then expands.- You cannot conditionally interpolate into
needs:orrules:. When a component has modes needing differentneeds:/rules:, put each mode's wiring in a hidden job and select it withextends: ".<name>--$[[ inputs.mode ]]"(v0.3 spec D1). - A boolean input does not interpolate into
rules: when:. Use a string input withoptions:instead (v0.2 spec,apply_when).
Cloud auth — no static credentials¶
A component that touches a cloud provider authenticates via OIDC
(id_tokens: → AssumeRoleWithWebIdentity). Never take an access key
as an input or a variable.
Workflow¶
- Spec first. No template change without a spec it implements —
an addendum to an existing spec, or a new
docs/development/specs/<YYYY-MM-DD>-<slug>.md. The spec is the decision record and carriesstatus: draft | approved | implementedin frontmatter. Implement only anapprovedspec. - Write the template per the conventions above.
- Self-test. Add
tests/<name>/fixture/— the minimal artefact the component operates on — andtests/<name>/.gitlab-ci.yml, a child pipeline including the component from$CI_SERVER_FQDN/$CI_PROJECT_PATH/<name>@$CI_COMMIT_SHA. Add a trigger job to the root.gitlab-ci.yml. A component without a self-test does not merge. - CHANGELOG — an entry under
[Unreleased]. - Review and release — MR to
develop; once green, mergedevelop → main, promote the CHANGELOG entry, tagvX.Y.Z.
Versioning¶
Semantic versioning, with a pre-1.0 caveat: while the major is 0, a
minor bump may change input shape. Consumers pin @vX.Y.Z.
- New component, or new input → minor — even when the input has a behaviour-preserving default; the input surface grew.
- Bug fix with no input change → patch.
Decision records¶
| Spec | Decides |
|---|---|
2026-05-15-cicd-v0.1.md |
Gate components; single-file form; the infra-tools image; the core input rules. |
2026-05-16-tofu-plan-apply-v0.2.md |
tofu-plan / tofu-apply; OIDC auth, no static credentials; the GitLab HTTP state backend; apply_when. |
2026-05-16-tofu-apply-plan-sources-v0.3.md |
plan_source (job / ref); the hidden-job extends: pattern; mode-coupled rules:; tag_pattern. |
2026-05-18-gate-component-rules-v0.4.md |
Gate jobs carry an explicit rules: so they run in merge-request pipelines. |
2026-05-19-token-inputs-v0.5.md |
Token-requiring inputs default to $CI_JOB_TOKEN; the consumer overrides. |