Chapter 15Lesson 02~200 minutes

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.

Multi-project pipelinesCI_JOB_TOKENDownstream triggersAuthorizationPlatform delivery

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-upstream
  • platform-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.

Mandatory path: Free-compatible trigger + downstream inputs + job-token Commit API access. Optional: Premium/Ultimate cross-project artifacts are discussed later, not required.

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:inputs accepts the upstream values;
  • the upstream trigger resolves the exact downstream project and lab-v1 ref;
  • no secret or broad variable set is forwarded;
  • the downstream job is included only when CI_PIPELINE_SOURCE is pipeline;
  • 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.

8. Add the narrow allowlist relationship

In the disposable upstream project, open Settings → CI/CD → Job token permissions and add only platform-lab/ch15-downstream to the CI/CD job token allowlist. Do not add the whole organization unless the exercise explicitly requires group-wide access.

This changes upstream authorization state. It does not create membership. The user who triggered the downstream job must already have sufficient upstream access for the requested endpoint.

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?

What should you compare to prove the downstream ref resolved to the intended code?

Why add downstream to upstream allowlist in this lab?

Why preserve the denied HTTP status before fixing the allowlist?

What is the paid-only part of the workflow?

Next lesson

Configuration, design choices, and tradeoffs

Choose trigger/API style, ref policy, status coupling, forwarding, artifact strategy, ownership, and rollback.

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.
Current behavior used by this chapter: Multi-project downstream pipelines are available across GitLab offerings. The user who starts the upstream pipeline must also be authorized to start a pipeline in the downstream project. Jobs in a multi-project downstream pipeline see 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.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0 Send only Ethereum/ERC-20 compatible assets to this address.