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.
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_TOKENcross-project trigger without exposing the token.
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_pipelinetrigger job fails because it usesstrategy: mirror; -
parent_after_childdoes 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?
Parent-child composition is entirely inside one project and is Free-compatible; it is enough to teach configuration hierarchy, source identity, and status propagation.
Why inspect /trigger_jobs instead of blaming
runners when a trigger job is pending?
Trigger jobs do not use runners. Pending trigger jobs usually mean GitLab cannot create the downstream pipeline because of config/ref/permission problems.
What must be true before a private target accepts a
cross-project CI_JOB_TOKEN?
The source project/group normally must be on the target allowlist, and the pipeline user must already have the required target permissions.
What proves strategy: mirror worked?
A deliberate child failure causes the child pipeline and upstream trigger job to fail, preventing later normal-stage jobs until the failure is repaired.
Why is a trigger token shown only as syntax in the required lab?
It is a persistent credential. The core composition mechanisms can be learned without creating or handling a long-lived secret.
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
- GitLab Docs — Downstream pipelines
- GitLab Docs — Pipeline architectures
- GitLab Docs — CI/CD YAML trigger reference
- GitLab Docs — Trigger pipelines with the API
- GitLab Docs — CI/CD job token
- GitLab Docs — Job token scope API
- GitLab Docs — Fine-grained job-token permissions
- GitLab Docs — Pipelines API
- GitLab Docs — Jobs API / trigger jobs
- GitLab Docs — CI/CD inputs
- GitLab Docs — Troubleshooting downstream pipelines
- GitLab Docs — Job artifacts troubleshooting
- GitLab Docs — Pipeline security
- GitLab Docs — Roles and permissions
- GitLab 19.3 — What’s new
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.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this
address.