Multi-Project Pipelines, Downstream Triggers, Cross-Project Dependencies, and Platform-Oriented Delivery: Guided Hands-On Workflow and Core Operations
This guided workflow creates two disposable projects, triggers a downstream pipeline on a controlled ref, passes only non-secret inputs, records both pipeline identities, demonstrates a denied then authorized CI_JOB_TOKEN API request, and keeps paid cross-project artifact features optional.
Learning objectives
- Create a disposable upstream and downstream project with synthetic source and a controlled downstream release ref.
- Pass validated, non-secret downstream inputs rather than broadly forwarding variables or secrets.
- Record upstream and downstream pipeline IDs, project IDs, refs, SHAs, pipeline sources, and trigger status.
- Demonstrate one denied cross-project job-token request, then authorize the minimum target/source relationship and prove success.
- Complete a small challenge that identifies whether a failure belongs to trigger/ref, authorization, runner, artifact, or downstream configuration state.
1. Disposable two-project lab and safety boundary
Create two throwaway private projects in the same disposable namespace:
platform-lab/ch15-upstreamplatform-lab/ch15-downstream
Use synthetic files only. The projects must contain no production secrets, customer data, package publication, cloud credentials, deployment targets, privileged runners, or shared infrastructure mutations. A normal non-privileged runner is sufficient for the script jobs.
2. Preflight: record both repository identities before CI
In each local clone, record the exact SHA. Create a controlled
downstream tag lab-v1 only in the disposable project
and record what it points to.
git -C ch15-upstream rev-parse HEAD
git -C ch15-downstream rev-parse HEAD
git -C ch15-downstream tag --list lab-v1
git -C ch15-downstream rev-parse 'lab-v1^{commit}'
If the tag does not exist, create it only after reviewing the disposable downstream commit. Production pipelines should use protected/governed refs appropriate to their release process.
3. Downstream contract: typed inputs and pipeline-source guard
spec:
inputs:
upstream-project-id:
type: number
upstream-pipeline-id:
type: number
upstream-sha:
type: string
regex: '^[0-9a-f]{40}$'
release-channel:
options: [test, canary]
default: test
---
stages: [verify]
verify-upstream-contract:
stage: verify
image: alpine:3.20.3
rules:
- if: '$CI_PIPELINE_SOURCE == "pipeline"'
- when: never
before_script:
- apk add --no-cache curl jq
script:
- mkdir -p evidence
- printf 'downstream_project=%s\n' "$CI_PROJECT_ID" > evidence/downstream.txt
- printf 'downstream_pipeline=%s\n' "$CI_PIPELINE_ID" >> evidence/downstream.txt
- printf 'downstream_ref=%s\n' "$CI_COMMIT_REF_NAME" >> evidence/downstream.txt
- printf 'downstream_sha=%s\n' "$CI_COMMIT_SHA" >> evidence/downstream.txt
- printf 'pipeline_source=%s\n' "$CI_PIPELINE_SOURCE" >> evidence/downstream.txt
- printf 'upstream_project=%s\n' '$[[ inputs.upstream-project-id ]]' >> evidence/downstream.txt
- printf 'upstream_pipeline=%s\n' '$[[ inputs.upstream-pipeline-id ]]' >> evidence/downstream.txt
- printf 'upstream_sha=%s\n' '$[[ inputs.upstream-sha ]]' >> evidence/downstream.txt
- printf 'release_channel=%s\n' '$[[ inputs.release-channel ]]' >> evidence/downstream.txt
artifacts:
name: "ch15-downstream-$CI_PIPELINE_ID"
expire_in: 1 day
paths: [evidence/downstream.txt]
The job records only non-secret identity. The
rules guard proves it is intended for multi-project
orchestration rather than ordinary pushes.
4. Upstream trigger: explicit project/ref, mirror status, explicit inputs
stages: [build, orchestrate]
build-evidence:
stage: build
image: alpine:3.20.3
script:
- mkdir -p evidence
- printf 'project=%s\npipeline=%s\nref=%s\nsha=%s\n' "$CI_PROJECT_ID" "$CI_PIPELINE_ID" "$CI_COMMIT_REF_NAME" "$CI_COMMIT_SHA" > evidence/upstream.txt
artifacts:
name: "ch15-upstream-$CI_PIPELINE_ID"
expire_in: 1 day
paths: [evidence/upstream.txt]
trigger-downstream:
stage: orchestrate
inherit:
variables: false
trigger:
project: platform-lab/ch15-downstream
branch: lab-v1
strategy: mirror
inputs:
upstream-project-id: $CI_PROJECT_ID
upstream-pipeline-id: $CI_PIPELINE_ID
upstream-sha: $CI_COMMIT_SHA
release-channel: test
forward:
yaml_variables: false
pipeline_variables: false
inherit:variables:false and
trigger:forward make the dataflow explicit. The only
cross-project values are the declared non-secret inputs.
5. Validate configuration before producing side effects
Use GitLab CI Lint or the pipeline editor in both disposable projects. Confirm:
-
the downstream
spec:inputsaccepts the upstream values; -
the upstream trigger resolves the exact downstream project and
lab-v1ref; - no secret or broad variable set is forwarded;
-
the downstream job is included only when
CI_PIPELINE_SOURCEispipeline; - the trigger uses
strategy: mirror.
Configuration validation proves syntax and contract shape. It does not prove the triggering user has downstream permissions.
6. Run once and capture both pipeline records
Push the upstream lab branch and run the pipeline. Preserve, before any retry:
| Upstream | Downstream |
|---|---|
| project ID/path | project ID/path |
| pipeline ID/source | pipeline ID/source=pipeline |
| ref and SHA | requested ref=lab-v1 and actual SHA |
| trigger job ID/status | verification job ID/status |
| compiled trigger inputs/strategy | compiled input values and rule match |
Compare the downstream SHA with the preflight
lab-v1^{commit} SHA. A matching tag name alone is not
enough evidence.
7. Prove the unauthorized baseline without leaking the token
Before adding the downstream project to the upstream project's CI_JOB_TOKEN allowlist, add this temporary check to the downstream job. It asks the upstream Commit API for the exact upstream SHA. The token value is sent in the header and never printed.
set +e
http_code="$(curl --silent --show-error --output evidence/denied.json --write-out '%{http_code}' --header "JOB-TOKEN: $CI_JOB_TOKEN" "$CI_API_V4_URL/projects/$[[ inputs.upstream-project-id ]]/repository/commits/$[[ inputs.upstream-sha ]]" )"
set -e
printf 'unauthorized_http=%s\n' "$http_code" >> evidence/downstream.txt
case "$http_code" in
401|403|404) echo "expected authorization denial" ;;
*) echo "unexpected status: $http_code" >&2; exit 1 ;;
esac
Use a private disposable upstream project so public-resource
exceptions do not hide the authorization lesson. Preserve only the
HTTP status and sanitized error; never print
CI_JOB_TOKEN.
9. Replace the denial probe with an authorized, read-only API proof
curl --fail --silent --show-error --header "JOB-TOKEN: $CI_JOB_TOKEN" "$CI_API_V4_URL/projects/$[[ inputs.upstream-project-id ]]/repository/commits/$[[ inputs.upstream-sha ]]" > evidence/upstream-commit.json
jq -e --arg sha '$[[ inputs.upstream-sha ]]' '.id == $sha' evidence/upstream-commit.json >/dev/null
jq -r '{id, short_id, title, committed_date}' evidence/upstream-commit.json > evidence/upstream-commit-summary.json
The full API JSON is synthetic-project metadata, not a secret. Still, retain only what the evidence packet needs. The successful request proves the job-token source/target authorization and user permission for this endpoint; it does not prove artifact access or deployment authorization.
10. Optional Premium/Ultimate artifact path
If the disposable namespace has Premium/Ultimate, compare the Free
API-metadata proof with needs:project. Treat this as
artifact retrieval, not a dependency edge that waits for the
just-triggered pipeline.
consume-upstream-artifact:
stage: verify
needs:
- project: platform-lab/ch15-upstream
job: build-evidence
ref: glci/ch15-upstream
artifacts: true
script:
- cat evidence/upstream.txt
Important: current
needs:project selects the latest successful specified
job for the ref and does not wait for another pipeline on that ref
to finish. If exact same-run provenance is required, carry and
verify explicit producer identity instead of inferring it from a
branch.
11. Optional API trigger: orchestration from a runtime job
The YAML trigger keyword is preferred when
orchestration is configuration. An API trigger is useful when a job
computes whether/where to trigger at runtime. Using
CI_JOB_TOKEN with the pipeline trigger endpoint keeps
the created pipeline connected as a downstream pipeline.
curl --fail --silent --show-error --request POST --form "token=$CI_JOB_TOKEN" --form "ref=lab-v1" "$CI_API_V4_URL/projects/$DOWNSTREAM_PROJECT_ID/trigger/pipeline" > evidence/trigger-response.json
jq -r '{id, status, ref, sha, web_url}' evidence/trigger-response.json
Do not switch to a broad PAT merely because the API is convenient. If the job-token endpoint cannot express the required operation, redesign the authorization explicitly rather than silently widening identity.
12. Layer-selection challenge
The trigger job fails before any downstream pipeline ID exists. Which layer should you inspect first?
- If the project path/ref is wrong: trigger/ref configuration.
- If the user cannot create downstream pipelines: authorization.
- If downstream exists but no runner claims a job: runner/executor.
- If a downstream Commit API call returns 403/404: job-token target allowlist/user permission.
- If an optional artifact is stale: artifact producer/ref selection, not trigger status.
13. Cleanup and rollback
Export the evidence packet, then remove only the lab allowlist entry you added, delete the disposable tag/branches/projects if no longer needed, and confirm no long-lived trigger token or PAT was created. The job tokens expire automatically with their jobs.
Knowledge check
Why does the trigger job use no runner?
The trigger job is handled by GitLab orchestration. GitLab creates the downstream pipeline; it is not a script job executed by a runner.
What should you compare to prove the downstream ref resolved to the intended code?
Compare the downstream CI_COMMIT_SHA with the SHA independently recorded for the governed downstream ref before triggering.
Why add downstream to upstream allowlist in this lab?
The downstream job token is trying to access an upstream-owned API resource, so upstream is the target that authorizes the downstream token source.
Why preserve the denied HTTP status before fixing the allowlist?
It proves the original failure was authorization state and prevents the repair from erasing the causal evidence.
What is the paid-only part of the workflow?
Cross-project artifact conveniences such as needs:project and CI_JOB_TOKEN-authenticated Job Artifacts API downloads are Premium/Ultimate; the trigger and read-only job-token API metadata lesson is Free-compatible.
Version and compatibility note
GitLab and GitLab Runner evolve continuously. Treat version-sensitive YAML, runner/executor behavior, APIs, security features, policy controls, tiers, and deprecations as assumptions to verify against the current official GitLab documentation before production use. Preserve the exact project/ref/SHA, compiled configuration, pipeline/job IDs, runner/tool versions, and external target evidence used for any reproducible lab or incident record.
Official references and version notes
Documentation verification date: 2026-09-11.
Multi-project trigger syntax, downstream inputs/forwarding,
CI_JOB_TOKEN permissions, allowlist controls, artifact APIs, and
needs:project are version-sensitive. Re-check the
GitLab version deployed on Self-Managed or Dedicated instances
before relying on newer behavior.
- Downstream pipelines — multi-project triggers, downstream source values, trigger strategies, inputs, variable forwarding, and cross-project artifact patterns.
- CI/CD job token — token lifetime, user-derived permissions, cross-project allowlists, security, and current allowlist controls.
- Fine-grained job-token permissions — optional endpoint-level restrictions for allowlisted projects/groups.
- Trigger pipelines with the API — trigger tokens, CI_JOB_TOKEN multi-project API triggers, source semantics, and revocation guidance.
-
CI/CD YAML syntax reference
—
trigger,trigger:strategy,trigger:forward,needs:project, and current tier constraints. - Job Artifacts API — artifact download by job/ref and the current tier requirement for cross-project job-token downloads.
CI_PIPELINE_SOURCE=pipeline. By default, a trigger job
succeeds when GitLab successfully creates the downstream pipeline;
strategy: mirror makes the trigger job track downstream
status, while strategy: depend is not recommended for
new designs. Downstream inputs are preferred over broad
variables when defining a configuration contract.
CI_JOB_TOKEN exists only while a job runs; target
projects restrict cross-project token use with an allowlist, and
allowlisting does not grant project membership. Current allowlists
support up to 200 group entries and 200 project entries.
needs:project and CI_JOB_TOKEN-authenticated Job
Artifacts API downloads across projects are Premium/Ultimate
features, so this chapter's mandatory path uses Free-tier
allowlisted API metadata access and treats cross-project artifact
transfer as optional/simulated.
Keep the academy open
Support free, practical DevOps education.
Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0
Send only Ethereum/ERC-20 compatible assets to this
address.