Chapter 19Lesson 02~310 minutes

Parent-Child Pipelines, Multi-Project Pipelines, Trigger Tokens, and Pipeline Composition: Guided Hands-On Workflow and Core Operations

Build a disposable parent-child pipeline first, prove its control-plane state, then optionally cross a project boundary with explicit inputs and least-privilege job-token authorization.

Hands-onChild pipelinetrigger:strategyInputsJob-token allowlistInspection

Learning objectives

  • Validate a local child configuration before triggering it.
  • Create a tiny parent-child pipeline with typed input and strategy: mirror.
  • Prove parent/child source, project, ref, SHA, and status relationships through API evidence.
  • Optionally create a disposable multi-project trigger and inspect the target trust boundary.
  • Diagnose and repair one blocked CI_JOB_TOKEN cross-project trigger without exposing the token.
Availability baseline (verified 2026-08-22 against current GitLab 19.3 documentation). Parent-child pipelines, multi-project pipelines, trigger jobs, trigger:strategy: mirror, downstream inputs, pipeline trigger tokens, the pipeline triggers API, CI_JOB_TOKEN cross-project access controls, and the job-token scope API are available on GitLab Free/Premium/Ultimate across GitLab.com, Self-Managed, and Dedicated. The mandatory path uses one disposable Free project and a local child configuration. A second disposable project is optional. Fetching upstream artifacts with needs:pipeline:job or needs:project is currently Premium/Ultimate, so cross-pipeline artifact retrieval is taught with a fixture/read-only extension rather than required for completion.

1. Scenario and preflight

Mandatory path: one disposable GitLab Free project/branch named ch19/parent-child-lab. Optional extension: a second disposable Free project named ch19-downstream-lab. Jobs print only safe identifiers and tiny synthetic values. No cloud, registry, environment deployment, secret, package publication, or runner registration is required.

Object Minimum requirement Trust note
Parent project Developer for branch/pipeline work. Contains both parent and child configs in mandatory path.
Runner Any tiny eligible runner, or CI Lint + supplied fixture if compute is unavailable. Child jobs execute repository-controlled code under project runner policy.
Optional downstream project User must be able to start pipelines there. Own configuration, variables, protected refs, and runners form a separate boundary.
Job-token allowlist mutation Maintainer/Owner on target project. Only for optional authorization drill; capture and remove exact source entry.
Secrets None. Never print CI_JOB_TOKEN or create a real trigger token in the mandatory path.

2. Inspect current state before adding any trigger

PROJECT_ID="12345678"
BRANCH="ch19/parent-child-lab"

glab api "projects/$PROJECT_ID" --jq '{id,path_with_namespace,default_branch,visibility}'
glab api "projects/$PROJECT_ID/pipelines?ref=$BRANCH" --paginate   --jq '.[] | {id,ref,sha,status,source}'

If the project already has downstream pipeline rules, do not overwrite them. Use a disposable branch and two uniquely named CI files.

3. Create the smallest useful parent and child configuration

Add the following child file at .gitlab/ci/ch19-child.yml:

spec:
  inputs:
    channel:
      description: "Synthetic downstream channel"
      options: [lab, staging]
      default: lab
---
child_identity:
  script:
    - test "$CI_PIPELINE_SOURCE" = "parent_pipeline"
    - printf 'child_project=%s\n' "$CI_PROJECT_PATH"
    - printf 'child_ref=%s\n' "$CI_COMMIT_REF_NAME"
    - printf 'child_sha=%s\n' "$CI_COMMIT_SHA"
    - printf 'channel=%s\n' "$[[ inputs.channel ]]"

Then add/merge this parent fragment into the disposable branch's .gitlab-ci.yml:

stages: [inspect, compose, verify]

parent_identity:
  stage: inspect
  script:
    - printf 'parent_project=%s\n' "$CI_PROJECT_PATH"
    - printf 'parent_ref=%s\n' "$CI_COMMIT_REF_NAME"
    - printf 'parent_sha=%s\n' "$CI_COMMIT_SHA"

child_pipeline:
  stage: compose
  trigger:
    include:
      - local: .gitlab/ci/ch19-child.yml
        inputs:
          channel: lab
    strategy: mirror

parent_after_child:
  stage: verify
  script:
    - echo "child pipeline completed successfully before this job"

4. Validate before committing

Use Build > Pipeline editor > Validate (or the current CI Lint interface) against the disposable branch content. Validation should prove both files parse after includes/inputs are resolved. A valid YAML document is not enough; GitLab must accept the CI schema and input contract.

If runner quota is unavailable, you can still complete the configuration and API-state exercises from fixtures, but the live source/SHA assertions require one tiny pipeline run.

5. Commit and predict before pushing

Before the push, write these predictions:

Prediction Expected
Parent source Usually push for this branch push.
Child source Exactly parent_pipeline.
Project/ref/SHA Parent and child should match because parent-child stays in the same project/ref/commit.
Trigger status With strategy: mirror, trigger waits and mirrors child status.
Final parent job Runs only after the child completes successfully.
git switch -c ch19/parent-child-lab
# Add only the two synthetic CI files shown above.
git add .gitlab-ci.yml .gitlab/ci/ch19-child.yml
git commit -m "lab: compose parent and child pipeline"
git push -u origin ch19/parent-child-lab

6. Inspect the graph with the current trigger-jobs API

Capture the parent pipeline ID from the UI or project pipelines API:

PARENT_PIPELINE_ID="9001"

glab api "projects/$PROJECT_ID/pipelines/$PARENT_PIPELINE_ID"   --jq '{id,project_id,ref,sha,status,source}'

glab api "projects/$PROJECT_ID/pipelines/$PARENT_PIPELINE_ID/trigger_jobs"   --jq '.[] | {id,name,status,downstream_pipeline:(.downstream_pipeline|{id,project_id,ref,sha,status})}'

The endpoint name matters: in GitLab 19.2 the dedicated /trigger_jobs route was introduced and the older /bridges route was deprecated.

7. Prove child identity independently

CHILD_PIPELINE_ID="9002"

glab api "projects/$PROJECT_ID/pipelines/$CHILD_PIPELINE_ID"   --jq '{id,project_id,ref,sha,status,source}'

glab api "projects/$PROJECT_ID/pipelines?source=parent_pipeline&ref=ch19/parent-child-lab" --paginate   --jq '.[] | {id,ref,sha,status,source}'

Compare the parent and child JSON—not just the graph arrows. The child should have the same project_id, ref, and SHA; its source should be parent_pipeline.

8. Prove status causality instead of assuming it

Temporarily change the child script to end with exit 7 on the disposable branch. Validate, push, and observe:

  • child job fails;
  • child pipeline fails;
  • the child_pipeline trigger job fails because it uses strategy: mirror;
  • parent_after_child does not run under normal stage semantics.

Then remove the deliberate failure and push the repair. Keep both pipeline IDs as evidence; do not retry away the original failure.

9. Optional Free extension: compose a second disposable project with trigger:project

If you create ch19-downstream-lab, give it a tiny config that accepts one typed input:

spec:
  inputs:
    release_channel:
      options: [lab, staging]
      default: lab
---
downstream_identity:
  script:
    - test "$CI_PIPELINE_SOURCE" = "pipeline"
    - printf 'downstream_project=%s\n' "$CI_PROJECT_PATH"
    - printf 'downstream_sha=%s\n' "$CI_COMMIT_SHA"
    - printf 'release_channel=%s\n' "$[[ inputs.release_channel ]]"

In the parent project, add a trigger job using the downstream project's full path:

downstream_project:
  stage: compose
  trigger:
    project: your-namespace/ch19-downstream-lab
    branch: main
    inputs:
      release_channel: lab
    strategy: mirror

The downstream SHA belongs to the selected downstream ref, not to the upstream commit. Compare project/ref/SHA explicitly.

10. Optional authorization drill: intentionally blocked CI_JOB_TOKEN API trigger

Only perform this against the disposable second project. Start with the target project's default allowlist state. Add a job in Project A that calls the triggers endpoint without exposing the token:

job_token_trigger_probe:
  script:
    - |
      curl --fail-with-body --request POST         --header "JOB-TOKEN: $CI_JOB_TOKEN"         --form ref=main         "$CI_API_V4_URL/projects/$TARGET_PROJECT_ID/trigger/pipeline"

With Project A absent from Project B's allowlist, a private target commonly returns a not-found/authorization-style response. Preserve the HTTP status/body. Do not print the token.

11. Repair with least privilege, not “allow everything”

As Maintainer/Owner of the disposable target, first inspect:

TARGET_PROJECT_ID="23456789"
SOURCE_PROJECT_ID="12345678"

glab api "projects/$TARGET_PROJECT_ID/job_token_scope" --jq '{inbound_enabled}'
glab api "projects/$TARGET_PROJECT_ID/job_token_scope/allowlist" --paginate   --jq '.[] | {id,path_with_namespace}'

If—and only if—the lab needs the job-token API trigger, add exactly Project A to Project B's inbound allowlist through Settings > CI/CD > Job token permissions or the documented job-token scope API. Do not disable the allowlist globally. Re-run the probe and independently inspect the new downstream pipeline.

12. Inspect downstream source and actor

glab api "projects/$TARGET_PROJECT_ID/pipelines?source=pipeline" --paginate   --jq '.[] | {id,ref,sha,status,source,user:.user.username}'

A downstream pipeline created with the YAML multi-project trigger or with CI_JOB_TOKEN should report source=pipeline. A pipeline created with a persistent pipeline trigger token instead reports source=trigger.

13. Trigger-token syntax only—no real token required

# Syntax fixture only. DO NOT paste a real token into source/history.
curl --request POST   --form token=TRIGGER_TOKEN_VALUE_DO_NOT_USE   --form ref=main   "https://gitlab.example.invalid/api/v4/projects/23456789/trigger/pipeline"

If an external integration truly requires a trigger token, create it through the target project's official settings, store it in the integration's secret store, never log it, and revoke it immediately after an ephemeral lab. The mandatory chapter never requires one.

14. Challenge: choose the surface before writing YAML

For each scenario, choose one: ordinary job/monolith, parent-child, multi-project YAML trigger, CI_JOB_TOKEN API call, or pipeline trigger token.

Scenario Best starting choice Reason
Monorepo has three independent component test suites Parent-child Same project/ref/SHA; separate component configuration and graphs.
Application project triggers separately owned deployment project Multi-project trigger Ownership/configuration/runner boundary is real and should remain visible.
GitLab job must start another private GitLab project through an API integration CI_JOB_TOKEN Short-lived identity plus explicit allowlist/permissions.
External legacy scheduler with no GitLab job context must start a pipeline Pipeline trigger token Purpose-built API credential; must be stored/revoked carefully.
Five jobs merely need ordering in one repository Ordinary DAG/stages Do not create downstream pipelines just to draw more boxes.

15. Cleanup and prove cleanup

If you added Project A to Project B's job-token allowlist, remove that exact entry before deleting anything else. Verify it is gone. If you created a trigger token in the optional extension, revoke it first. Then remove synthetic CI files/branch. Delete the second project only if it was created solely for this lab and you explicitly intend destructive cleanup.

# Verify target allowlist after removing the exact disposable source entry.
glab api "projects/$TARGET_PROJECT_ID/job_token_scope/allowlist" --paginate   --jq '.[] | {id,path_with_namespace}'

git switch main
git ls-remote --heads origin refs/heads/ch19/parent-child-lab
# Delete only the disposable branch after preserving pipeline evidence.
git push origin --delete ch19/parent-child-lab
git branch -D ch19/parent-child-lab

Knowledge check

Why does the mandatory child pipeline need no second project?

Why inspect /trigger_jobs instead of blaming runners when a trigger job is pending?

What must be true before a private target accepts a cross-project CI_JOB_TOKEN?

What proves strategy: mirror worked?

Why is a trigger token shown only as syntax in the required lab?

Summary

You built parent-child composition first, verified it through current GitLab APIs, then optionally crossed a project boundary with a YAML trigger and a controlled CI_JOB_TOKEN authorization drill. The important evidence is not “another pipeline ran”; it is the exact project/ref/SHA/source, trigger-job strategy, user/permission boundary, and cleanup state.

Official references

Next lesson

Choose composition architecture deliberately

Lesson 3 turns the mechanics into policy: when decomposition helps, when it only adds latency/fragility, and how token, status, input, variable, artifact, and ownership choices change maintainability and risk.

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
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this address.