Skip to content

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 corrected rules:).
  • Driver: phpboyscout/infra's releaser-pleaser tag-gated apply flow (security-baseline stack spec) needs tofu-apply to apply a plan binary produced by a different pipeline — the latest main run — 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-plan runs on every main push, banking a plan artifact;
  • the release tag's pipeline runs tofu-apply alone — the plan it applies was produced by the latest main pipeline, 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:

plan_source:
  type: string
  default: "job"
  options: [job, ref]
  • job — the v0.2 behaviour. tofu-apply declares needs: [{ job: <plan_job>, artifacts: true }]; GitLab restores tfplan.cache from the same pipeline. Default → existing consumers (and the v0.2 self-test) are unaffected.
  • reftofu-apply declares no plan needs:. Its script downloads the latest artifact for <plan_job> on <plan_ref> from the GitLab jobs-artifacts API and unzips tfplan.cache before 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_TOKEN auth below is wrong: a CI/CD job token authenticates the jobs-artifacts API only on GitLab Premium/Ultimate — on Free the call returns 401. The ref-mode fetch is now parameterised by a plan_token input (default $CI_JOB_TOKEN, overridden with a PAT on Free) and reports the HTTP status on failure. See 2026-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.

  • job modetests/tofu-apply/.gitlab-ci.yml: tofu-plan + tofu-apply (default plan_source), both rules: overridden — the full plan→apply pair runs in every self-test child.
  • ref mode — new tests/tofu-apply/ref.gitlab-ci.yml. A hermetic cross-pipeline success fixture is not reproducible inside the cicd self-test (it needs a plan artifact banked by a prior pipeline), so the ref-mode test asserts the failure path: plan_source: ref with a plan_ref that has no banked artifact → the curl 404s → the job exit 1s. allow_failure: { exit_codes: 1 } makes a clean exit-1 the expected outcome and fails the pipeline on any other code. Proves the extends: selection, needs: [], and the fetch wiring. The ref-mode success path gets its real exercise in phpboyscout/infra Phase E — the same option-(b) pragmatism as the v0.2 spec.
  • tofu-plantests/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:

- if: '$CI_COMMIT_TAG =~ /$[[ inputs.tag_pattern ]]/'
  when: $[[ inputs.apply_when ]]

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 — ref mode 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 latest main pipeline 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 single tofu-plan job; noted for completeness.

Component delta summary

templates/tofu-plan.yml — add rules: (D3). No input change.

templates/tofu-apply.yml:

  • add plan_source, plan_ref inputs (D5);
  • add the .tofu-apply--plan--job / .tofu-apply--plan--ref hidden jobs — each carrying the mode's needs: and rules: — and the extends: selection (D1, D3);
  • script gains the ref-mode curl fetch (D2);
  • tofu-apply declares no needs: / 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

  1. Spec lands — this file, status approved.
  2. templates/tofu-plan.yml — add the D3 rules:.
  3. templates/tofu-apply.ymlplan_source / plan_ref inputs, the hidden-job extends: pattern (needs: + rules:), the ref-mode curl fetch.
  4. tests/ — new tests/tofu-apply/ref.gitlab-ci.yml (ref-mode failure-path); each self-test child overrides the component rules: to when: on_success; add self-test:tofu-apply:ref to the root .gitlab-ci.yml (D6).
  5. CHANGELOG [0.3.0], README; merge develop → main, tag v0.3.0.
  6. Consumer (phpboyscout/infra Phase E) pins tofu-plan / tofu-apply @v0.3.0.
  7. v0.3.1tag_pattern input + tightened ref-mode rule (D8); CHANGELOG [0.3.1]; merge develop → main, tag v0.3.1. infra then bumps tofu-apply @v0.3.0@v0.3.1.

Follow-ups

  • v0.3.x — optional component-side pipeline-status check for ref mode (OQ1).
  • environment: integration — carried over from the v0.2 spec's follow-ups; still deferred.