Skip to content

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:

include:
  - component: gitlab.com/phpboyscout/cicd/<name>@vX.Y.Z

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_version and stage are 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:
inputs:
  plan_token: $MY_ARTIFACT_PAT     # consumer's variable, any name
  • 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 unconditional rules: [{ 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 a ref-mode apply (v0.3 spec D3). A job that runs where assume-role will fail is a bug.
  • Scheduled pipelines are for renovate-self only. Every other component carries, as the first rule, a schedule guard:
rules:
  - if: '$CI_PIPELINE_SOURCE == "schedule"'
    when: never
  # …the job's real rules follow…

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; $VAR is 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: or rules:. When a component has modes needing different needs: / rules:, put each mode's wiring in a hidden job and select it with extends: ".<name>--$[[ inputs.mode ]]" (v0.3 spec D1).
  • A boolean input does not interpolate into rules: when:. Use a string input with options: 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 (bit svelte-build once).

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

  1. 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 an approved spec.
  2. Write the template per the conventions above.
  3. Self-test. Add tests/<name>/fixture/ — the minimal artefact the component operates on — and tests/<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.
  4. 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 (see 2026-06-30-docs-diataxis-restructure.md): a component isn't done until it has a page.
  5. 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 manual CHANGELOG.md edit. Review the Release MR; merging it cuts the vX.Y.Z tag. The repo is trunk-based — there is no develop branch.

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.