Spec: phpboyscout/cicd v0.3 — tofu-apply plan sources¶
- Repository:
gitlab.com/phpboyscout/cicd - Released as:
v0.3.0(minor — new input with a behaviour-preserving default, plus correctedrules:). - Driver:
phpboyscout/infra's releaser-pleaser tag-gated apply flow (security-baseline stack spec) needstofu-applyto apply a plan binary produced by a different pipeline — the latestmainrun — not one from its own pipeline.
Summary¶
v0.2 shipped tofu-plan / tofu-apply where apply consumes the plan
strictly via same-pipeline needs: [tofu-plan]. phpboyscout/infra
is adopting a trunk-based + releaser-pleaser flow where:
tofu-planruns on everymainpush, banking a plan artifact;- the release tag's pipeline runs
tofu-applyalone — the plan it applies was produced by the latestmainpipeline, a different pipeline entirely.
v0.2's tofu-apply cannot do that. v0.3 adds a plan_source
input that selects the retrieval path:
plan_source |
Behaviour |
|---|---|
job (default) |
Same-pipeline — needs: [<plan_job>] restores the artifact. Identical to v0.2. |
ref |
Cross-pipeline — downloads the latest artifact for <plan_job> on <plan_ref> via the GitLab jobs-artifacts API. |
v0.3 also adds a rules: block to tofu-plan and couples
tofu-apply's rules: to plan_source (see D3).
Decisions¶
D1 — plan_source selects the retrieval path; default preserves v0.2¶
New tofu-apply input:
job— the v0.2 behaviour.tofu-applydeclaresneeds: [{ job: <plan_job>, artifacts: true }]; GitLab restorestfplan.cachefrom the same pipeline. Default → existing consumers (and the v0.2 self-test) are unaffected.ref—tofu-applydeclares no planneeds:. Its script downloads the latest artifact for<plan_job>on<plan_ref>from the GitLab jobs-artifacts API and unzipstfplan.cachebefore applying.
The two paths differ in the needs: keyword and in the rules:
that decide when the apply runs (D3) — neither can be conditionally
interpolated inline. Both are selected together with the
GitLab-documented extends: + hidden-job pattern:
".tofu-apply--plan--job":
needs:
- job: $[[ inputs.plan_job ]]
artifacts: true
rules: # same-pipeline → default branch
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
when: $[[ inputs.apply_when ]]
".tofu-apply--plan--ref":
needs: [] # plan lives in another pipeline
rules: # cross-pipeline → tag
- if: $CI_COMMIT_TAG
when: $[[ inputs.apply_when ]]
tofu-apply:
extends: ".tofu-apply--plan--$[[ inputs.plan_source ]]"
# declares no needs:/rules: of its own — both come from the hidden job
# ...
$[[ inputs.plan_source ]] interpolates into the extends: target —
the same mechanism the GitLab components guide uses for cache-mode
selection. The hidden job a consumer selects fully describes how that
mode plugs into the pipeline: where the plan comes from (needs:) and
when the apply fires (rules:). See D3 for why the trigger is coupled
to the mode.
D2 — ref mode: GitLab jobs-artifacts API + CI_JOB_TOKEN¶
Superseded (v0.5.0). The
CI_JOB_TOKENauth below is wrong: a CI/CD job token authenticates the jobs-artifacts API only on GitLab Premium/Ultimate — on Free the call returns401. Theref-mode fetch is now parameterised by aplan_tokeninput (default$CI_JOB_TOKEN, overridden with a PAT on Free) and reports the HTTP status on failure. See2026-05-19-token-inputs-v0.5.md. D2's archive-path layout and "latest successful" semantics still hold.
In ref mode the script fetches the plan:
curl --silent --fail --location \
--header "JOB-TOKEN: $CI_JOB_TOKEN" \
--output /tmp/tofu-apply-plan.zip \
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/jobs/artifacts/$[[ inputs.plan_ref ]]/download?job=$[[ inputs.plan_job ]]"
unzip -o /tmp/tofu-apply-plan.zip
GET /projects/:id/jobs/artifacts/:ref/download?job=:name returns the
artifacts archive of the latest successful pipeline on :ref for the
named job. CI_JOB_TOKEN authorises same-project artifact download by
default.
The archive preserves the artifact's project-relative path, so
tfplan.cache lands at <working_directory>/tfplan.cache — exactly
where job mode's needs: restore puts it. The fetch runs from
$CI_PROJECT_DIR, before the script cds into working_directory,
so the rest of the script is mode-agnostic: cd <working_directory>;
tofu init; tofu apply tfplan.cache.
A missing artifact (no successful pipeline on the ref) makes
curl --fail error; the script echoes a clear message and exit 1s,
failing the apply loudly — the correct outcome.
"Latest" vs "latest successful": the endpoint returns the latest
successful pipeline's artifact — it can return a stale plan from an
older green run if the most recent pipeline failed. The consumer is
responsible for ensuring the latest run was green — in the
releaser-pleaser flow that guarantee is the release-MR merge gate (see
the security-baseline stack spec). ref mode trusts whatever the API
returns.
D3 — rules: — fixed on tofu-plan, mode-coupled on tofu-apply¶
v0.2's tofu-plan had no rules: — it ran on every pipeline,
including untrusted-branch pushes where the GitLab-OIDC assume-role
fails (the subject is not in the trust policy).
tofu-plan — flat rule:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
On MRs → review the plan; on the default branch → bank the
plan artifact a ref-mode apply consumes. Both contexts have OIDC
subjects the standard trust policy accepts (ref_type:mr:ref:*,
ref_type:branch:ref:main). tofu-plan does not run on tags.
tofu-apply — rule coupled to plan_source:
The apply trigger is not free to choose independently of the retrieval mode — they are the same decision:
plan_source |
rules: |
Why |
|---|---|---|
job |
if $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH |
Same-pipeline needs: only works if the plan job ran in this pipeline. plan + apply co-occur only on a branch pipeline. This is exactly v0.2's tofu-apply rule — job mode is byte-for-byte v0.2 behaviour (D1). |
ref |
if $CI_COMMIT_TAG =~ tag_pattern |
The plan was banked by a different pipeline; this one only applies it. The release tag is the apply trigger — restricted to release tags via tag_pattern (D8). |
A flat if $CI_COMMIT_TAG for both modes (an earlier draft of this
spec) is wrong: job mode on a tag pipeline would declare
needs: [tofu-plan] for a tofu-plan job that — per its own rule —
never runs on a tag, a hard pipeline-creation error. job and ref
mode never share a trigger, so the rule lives in the per-mode hidden
job alongside needs: (D1), gated further by apply_when (D4).
This makes a tag's OIDC subject (ref_type:tag:ref:*) a hard
requirement on a ref-mode consumer's IAM trust policy — flagged in
the security-baseline stack spec and the risk register below.
These are component defaults. A consumer wanting a different trigger
(e.g. branch-based ref apply) re-declares the tofu-apply job's
rules: — as the ref-mode self-test does (D6).
D4 — apply_when unchanged; gates whichever rule fires¶
apply_when (manual | on_success, default manual) is retained
as the when: of the per-mode rule (D1):
apply_when: on_success— apply runs automatically once its trigger fires (the tag pipeline starts, or the default-branch pipeline's earlier stages pass).apply_when: manual— a human clicks apply in the GitLab UI.
D5 — Inputs surface (tofu-apply, v0.3 delta)¶
| Input | Type | Default | Notes |
|---|---|---|---|
plan_source |
string | job |
job | ref (D1) |
plan_ref |
string | "" |
git ref whose latest <plan_job> artifact to fetch; required when plan_source = ref |
plan_job |
string | tofu-plan |
job name — the needs: job (job mode) or the ?job= query param (ref mode) |
tag_pattern |
string | ^v[0-9]+\.[0-9]+\.[0-9]+$ |
RE2 pattern gating ref-mode apply; default = strict semver release tags (D8, v0.3.1) |
image_version, stage, working_directory, role_arn,
aws_region, aud, apply_when unchanged from v0.2.
tofu-plan gains no new inputs — only the rules: of D3.
D6 — Self-test¶
The cicd self-test runs each component inside a child pipeline
(tests/<name>/.gitlab-ci.yml, triggered from the root pipeline).
That harness shapes the v0.3 self-tests: a child pipeline created by
trigger: always reports $CI_PIPELINE_SOURCE == "parent_pipeline"
and carries no $CI_COMMIT_BRANCH when the parent is an MR pipeline.
The component rules: (D3) are keyed on exactly those variables — so
they cannot be exercised as written through the parent/child
harness (an unmatched rule yields an empty child pipeline, a trigger
error).
Each self-test child therefore overrides the component job's
rules: to an unconditional when: on_success, so the job runs in
the child regardless of the masked pipeline source. This tests
component behaviour — OIDC wiring, state-backend auth, the
extends: mode selection, the needs: / artifact handoff, the
ref-mode fetch. The rules: themselves are trivial declarative
config; they get real validation when infra consumes the components
directly (Phase E), where there is no parent/child indirection.
jobmode —tests/tofu-apply/.gitlab-ci.yml:tofu-plan+tofu-apply(defaultplan_source), bothrules:overridden — the full plan→apply pair runs in every self-test child.refmode — newtests/tofu-apply/ref.gitlab-ci.yml. A hermetic cross-pipeline success fixture is not reproducible inside thecicdself-test (it needs a plan artifact banked by a prior pipeline), so theref-mode test asserts the failure path:plan_source: refwith aplan_refthat has no banked artifact → thecurl404s → the jobexit 1s.allow_failure: { exit_codes: 1 }makes a clean exit-1 the expected outcome and fails the pipeline on any other code. Proves theextends:selection,needs: [], and the fetch wiring. Theref-mode success path gets its real exercise inphpboyscout/infraPhase E — the same option-(b) pragmatism as the v0.2 spec.tofu-plan—tests/tofu-plan/.gitlab-ci.yml:rules:overridden likewise.
The root .gitlab-ci.yml triggers all three on MR (narrowed by
changes:) + any branch / tag; the children always carry a job, so
there is no empty-child-pipeline error.
D7 — Versioning¶
New input + corrected rules → v0.3.0. plan_source defaults to
job, and job mode's rule is identical to v0.2's tofu-apply rule,
so a v0.2 tofu-apply consumer is behaviour-unchanged; the only
behaviour delta is tofu-plan gaining a rules: block, and v0.2 had
no real consumers (infra had not yet wired the components). Pre-1.0
caveat from v0.1 still applies.
v0.3.1 — adds tag_pattern and tightens the ref-mode rule (D8).
Released as a patch: the headline is a safety fix to ref mode; the
tag_pattern input is the knob that delivers it. cicd still had no
real consumers, so the narrowed default carries no migration cost.
D8 — ref-mode apply is restricted to release tags (tag_pattern)¶
(Shipped in v0.3.1.)
v0.3.0's ref-mode rule was if: $CI_COMMIT_TAG — it fired on any
tag. In the tag-gated model that is a footgun: a prerelease tag
(v1.2.0-rc.1), a stray hand-pushed tag, or a branch tag would
trigger a real tofu apply.
v0.3.1 adds a tag_pattern input (string, default
^v[0-9]+\.[0-9]+\.[0-9]+$); the ref-mode rule becomes:
The default accepts strict semver release tags (v0.0.0, v1.23.4)
and rejects prereleases (-rc.1, -beta.1), build metadata
(+build), partials (v1.2), and non-v tags. It aligns with
releaser-pleaser, which tags normal releases vX.Y.Z and
prereleases vX.Y.Z-<pre> — so the default means "apply on real
releases only".
tag_pattern is configurable — a consumer that wants prerelease
applies (e.g. to a staging environment) widens the pattern. It is
ignored in job mode (no tag is involved). The strict default ships
as the component behaviour, so a consumer that sets nothing is
protected (safety by default).
The ref-mode self-test (D6) overrides rules: and so does not
exercise tag_pattern; the pattern gets its real validation in
phpboyscout/infra Phase E.
Open questions¶
- OQ1 —
refmode and pipeline success. The jobs-artifacts API returns the latest successful pipeline's artifact, not necessarily the latest pipeline's. v0.3 relies on the consumer's release-MR merge gate to guarantee the latestmainpipeline was green. A future v0.3.x could have the component itself query pipeline status before trusting the artifact. Tentative: defer — the consumer-side gate is the right layer for it. - OQ2 — multi-artifact / wrong job. If
<plan_job>produced artifacts in several jobs (parallel matrix), the API returns the first match. Not a concern for the singletofu-planjob; noted for completeness.
Component delta summary¶
templates/tofu-plan.yml — add rules: (D3). No input change.
templates/tofu-apply.yml:
- add
plan_source,plan_refinputs (D5); - add the
.tofu-apply--plan--job/.tofu-apply--plan--refhidden jobs — each carrying the mode'sneeds:andrules:— and theextends:selection (D1, D3); - script gains the
ref-modecurlfetch (D2); tofu-applydeclares noneeds:/rules:of its own.
templates/tofu-apply.yml (v0.3.1) — add the tag_pattern input; the
.tofu-apply--plan--ref rule matches $CI_COMMIT_TAG against it (D8).
Risk register¶
| Risk | Mitigation |
|---|---|
ref-mode apply fetches a stale main plan (a feature MR merged after the plan was banked) |
Documented in the security-baseline stack spec as an accepted edge case — the missed change applies on the next release. The release-MR merge gate bounds it. |
| Tag OIDC subject not trusted → assume-role fails on apply | Consumer's IAM trust policy must include ref_type:tag:ref:*. Called out in the security-baseline stack spec; infra/bootstrap adds it via automation_subject_filters. |
extends: input interpolation unsupported |
The GitLab components guide documents extends: '....$[[ inputs.x ]]'; verified pattern. Lint-checked before tag. |
job mode apply declares needs: for a tofu-plan job absent from the pipeline |
D3 — the rule is coupled to plan_source; job mode runs only on the default branch, where tofu-plan also runs. |
Component rules: not exercisable via the parent/child self-test harness ($CI_PIPELINE_SOURCE masked to parent_pipeline) |
D6 — each self-test child overrides the component job's rules: to when: on_success; the rules: are validated by the direct consumer (infra Phase E). |
curl --fail on a missing artifact leaves a confusing error |
The ref-mode self-test asserts exactly this path; the script echoes a clear "no banked plan artifact" message before exit 1. |
| jobs-artifacts API auth | CI_JOB_TOKEN authenticates the jobs-artifacts API only on Premium/Ultimate, not Free — D2's assumption was wrong. The token-inputs v0.5 spec adds a plan_token input (default $CI_JOB_TOKEN) the consumer overrides with a PAT. |
A prerelease or stray tag triggers a real tofu apply |
D8 — the ref-mode rule matches tag_pattern (default: strict semver release tags); prereleases, build metadata, and arbitrary tags do not match. |
Implementation plan¶
- Spec lands — this file, status
approved. templates/tofu-plan.yml— add the D3rules:.templates/tofu-apply.yml—plan_source/plan_refinputs, the hidden-jobextends:pattern (needs:+rules:), theref-modecurlfetch.tests/— newtests/tofu-apply/ref.gitlab-ci.yml(ref-mode failure-path); each self-test child overrides the componentrules:towhen: on_success; addself-test:tofu-apply:refto the root.gitlab-ci.yml(D6).- CHANGELOG
[0.3.0], README; mergedevelop → main, tagv0.3.0. - Consumer (
phpboyscout/infraPhase E) pinstofu-plan/tofu-apply@v0.3.0. - v0.3.1 —
tag_patterninput + tightenedref-mode rule (D8); CHANGELOG[0.3.1]; mergedevelop → main, tagv0.3.1.infrathen bumpstofu-apply@v0.3.0→@v0.3.1.
Follow-ups¶
- v0.3.x — optional component-side pipeline-status check for
refmode (OQ1). environment:integration — carried over from the v0.2 spec's follow-ups; still deferred.