Contributing¶
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/, indexed at the
decision log.
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.
The Tofu and static-site components run inside
registry.gitlab.com/phpboyscout/images/infra-tools; the Go, Rust, and
Svelte components run inside registry.gitlab.com/phpboyscout/images/dev-tools.
Tool versions are pinned in those images, 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 the token-input convention's
Explanation page and
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. - Scheduled pipelines are for
renovate-selfonly. Every other component carries, as the first rule, a schedule guard:
A schedule runs on a branch (normally the default branch), so without
the guard any when: on_success gate or $CI_COMMIT_BRANCH ==
$CI_DEFAULT_BRANCH deploy/release job fires on the Renovate schedule
too (v0.10.8 spec). The guard sits ahead of $[[ inputs.if ]], so a
consumer widening the if: input still can't pull a job onto the
schedule. renovate-self is the sole exception — the schedule is its
whole purpose. hugo-pages is the one deliberate further exception
within that rule — see its
Reference page.
- Gate jobs change-detect by default (since v0.16.0). See the
change-detection Explanation page.
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). - A bare
- echo "…"script list item containing:or other YAML-significant punctuation becomes an ambiguous plain scalar once inputs interpolate — wrap script bodies in a- |block scalar instead (bitsvelte-buildonce).
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. See Specs for the process, the status lifecycle, and a prompt template. 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. - Reference page. Add or update
docs/reference/<track>/<name>.md— purpose, the full inputs table, the jobs it produces, a minimal usage example. This is what keeps the published site from drifting out of date the way it once did (see2026-06-30-docs-diataxis-restructure.md): a component isn't done until it has a page. - Commit with Conventional Commits, MR to
main. Once the MR merges, releaser-pleaser opens or updates a Release MR with the next version and a generated changelog, derived from the Conventional Commit messages since the last tag — there is no manualCHANGELOG.mdedit. Review the Release MR; merging it cuts thevX.Y.Ztag. The repo is trunk-based — there is nodevelopbranch.
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¶
The exhaustive, dated index of every spec — status, one-line summary, and which component(s) it concerns — is the decision log. Major design decisions are also distilled into readable Explanation pages.