Skip to content

Downstream pipelines: making repos talk to each other

Merging a frontend change should run E2E tests that live in a different repo, owned by a different team. Trigger jobs, multi-project vs parent-child pipelines, mirroring the downstream status, and a variables allowlist that doubles as a secret boundary.

6 min read
  • gitlab
  • ci-cd
  • devops

Parts 5 and 6 put a single repo’s pipeline fully under control: rules decide which jobs run, workflow decides which pipelines exist at all. The next problem doesn’t live in your repo. Merging a change to my-app should run the E2E suite, and that suite lives in group/qa-suite, owned by the QA team, with its own pipeline, its own dependencies, its own runners. Copying the tests into your repo means maintaining two copies forever.

What you actually want is for my-app’s pipeline to start qa-suite’s pipeline, pass it a little context, and care about the result. GitLab calls these downstream pipelines, and they come in two flavors that look almost identical in YAML and behave differently.

Two ways to trigger a pipeline

A trigger job (the UI calls it a bridge) starts a downstream pipeline instead of running a script. Pointing it at another project gives you a multi-project pipeline:

run-e2e:
  stage: e2e
  trigger:
    project: group/qa-suite
    branch: main

branch is optional and defaults to the downstream project’s default branch. The triggered pipeline runs entirely in the other project’s world: its .gitlab-ci.yml, its runners, its CI/CD settings and secrets.

Pointing the trigger at a file instead gives you a parent-child pipeline:

build-child:
  stage: build
  trigger:
    include: ci/child-pipeline.yml

Same project, same ref, same permissions: the child is a second pipeline carved out of your own repo, which is useful for splitting a giant config or generating pipeline YAML on the fly. The dividing line is ownership. If the config you want to run lives in your repo, use trigger:include; if it belongs to another team in another project, use trigger:project. For the E2E problem it’s the latter.

One constraint to know early: trigger jobs accept only a limited set of keywords: trigger, variables, rules, when, needs, stage, allow_failure, and a few others. There’s no script, so a bridge can’t “prepare” anything before triggering; anything the downstream needs has to already exist or travel as a variable.

Waiting for the verdict

By default a trigger job is marked successful as soon as the downstream pipeline is created, not when it finishes. For fire-and-forget triggers that’s fine. For a merge gate it’s useless: the bridge goes green while the E2E suite is still installing browsers.

strategy makes the bridge wait and adopt the downstream result:

run-e2e:
  stage: e2e
  trigger:
    project: group/qa-suite
    branch: main
    strategy: depend

depend is the long-standing value and what you’ll find in most existing configs. GitLab 18.2 added strategy: mirror, which the docs now recommend instead: it reflects the downstream status exactly, where depend has some rough edges (it shows running while the downstream is merely waiting on a manual job, for example). On an older instance, depend is what you have, and it’s fine for the common cases.

Two details worth knowing regardless of which value you use. A blocking manual job in the downstream pipeline holds the bridge open until someone runs it, while optional manual jobs are ignored. And a downstream job that fails with allow_failure: true still counts as a successful downstream pipeline, so the bridge shows success too.

Variables cross the boundary: control which ones

Variables declared on the trigger job are forwarded to the downstream pipeline. So is your entire global variables: block, because every job in a pipeline inherits the default variables, the trigger job included, and whatever the trigger job holds crosses the boundary.

Read that again from the QA team’s perspective: everything in another repo’s global variables: block lands in their pipeline, unannounced. At best it’s noise. At worst a token someone parked in the global block is now readable in a project with different access rules, or a variable name collides with one the child defines and silently overrides it.

inherit:variables: false turns the firehose into an allowlist:

run-e2e:
  stage: e2e
  inherit:
    variables: false
  variables:
    UPSTREAM_PROJECT_SLUG: my-app
    UPSTREAM_BRANCH: $CI_COMMIT_BRANCH
  trigger:
    project: group/qa-suite
    branch: main
    strategy: depend

With inheritance off, the trigger job holds only what it declares, so only those two variables reach qa-suite. inherit:variables also accepts a list of names if you’d rather allow specific globals through. Either way the bridge job becomes the documented interface between the two repos: the downstream can rely on exactly these variables and nothing else, which keeps the coupling between the teams as narrow as the YAML that expresses it.

(The reverse concern, variables someone typed into a manual pipeline run flowing downstream, is off by default; trigger:forward controls it if you ever need to change that.)

What the child sees

In a pipeline triggered from another project, every job sees $CI_PIPELINE_SOURCE == "pipeline". In a parent-child pipeline the value is parent_pipeline. That one variable lets the QA repo distinguish “an upstream app wants E2E” from its own branch pushes, and it works in both directions:

e2e:
  rules:
    - if: $CI_PIPELINE_SOURCE == "pipeline"
  script:
    - npx playwright test --project "$UPSTREAM_PROJECT_SLUG"
 
pages:
  rules:
    - if: $CI_PIPELINE_SOURCE == "pipeline"
      when: never
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
  script:
    - ./scripts/publish-report-site.sh

The first rule runs the suite only on triggered pipelines. The second is the mirror image and it’s easy to forget: the QA repo has jobs of its own (here a pages-style job publishing a report site) that make no sense on a triggered run. Without the when: never guard, every merge in every upstream app would also redeploy the QA team’s report site. Rules semantics are part 5; the only new ingredient is the pipeline source value.

One QA repo, many apps

We didn’t build this for one app. Several frontends trigger the same qa-suite, and the routing is that single UPSTREAM_PROJECT_SLUG variable: the Playwright config defines one project per upstream app, and the child’s E2E job selects it with --project "$UPSTREAM_PROJECT_SLUG". Each app’s bridge job is identical except for two lines, and the QA team adds support for a new app by adding a Playwright project with a matching name.

It’s a clean fan-in, and it has exactly one load-bearing assumption: the slug string in each app’s .gitlab-ci.yml matches a project name in qa-suite’s Playwright config. Two repos, one string, no compiler between them.

The rename nobody noticed

That assumption failed us in the most boring way possible. During a cleanup in the QA repo, one app’s Playwright project was renamed — the kind of change that looks purely internal. The upstream app kept sending the old slug.

Nothing turned red. Our runner script mapped the slug to a test subset and fell back to a minimal shared smoke set when the slug didn’t match anything: a default that felt sensible when we wrote it, on the theory that running something beats running nothing. So the child pipeline ran a handful of generic checks that touched none of that app’s flows, passed, and the bridge dutifully mirrored the green back upstream. For weeks, that app’s merges got E2E theater instead of E2E coverage, and every dashboard agreed things were fine.

The fix was to delete the fallback. An unknown slug now fails the child’s first job immediately, printing the list of known slugs, which turns a rename in one repo into a loud failure in the other — the only place the mismatch is even observable. We also started treating slug renames as what they are: a change to a two-repo contract, done in both places in one coordinated pass, with the valid slugs listed in the QA repo’s README as the reference.

Soft-gating the bridge

Not every upstream wants E2E blocking every merge: the suite is slow, and browsers have moods. The bridge job composes with the tools from part 5:

run-e2e:
  stage: e2e
  when: manual
  allow_failure: true
  inherit:
    variables: false
  variables:
    UPSTREAM_PROJECT_SLUG: my-app
  trigger:
    project: group/qa-suite
    branch: main
    strategy: depend

when: manual makes E2E opt-in: a button in the pipeline view when the change warrants it. allow_failure: true keeps the manual job from blocking the pipeline, and if someone runs it and it fails, the pipeline shows a warning instead of failing, so the signal stays visible without blocking anyone. Each team picks its own posture: strict gate (neither keyword), opt-in signal (both), or always-on-but-non-blocking (allow_failure alone). One caveat specific to bridges: unlike ordinary manual jobs, you can’t type extra variables into a manual trigger job before running it, so anything configurable has to be in the YAML.

Where this leaves you

my-app can now start qa-suite’s pipeline, wait for its verdict, and pass exactly the variables it declares, and the child knows when it’s being triggered and behaves accordingly. Repos talk.

What this doesn’t fix is repetition between repos: those several frontends triggering qa-suite also share some three hundred lines of near-identical pipeline YAML, copy-pasted and quietly drifting apart. Deduplicating that (one CI config serving ten repos through include) is part 8.

References