Cross-Project Pipelines
glci supports both child pipelines (trigger: include:) and cross-project pipelines (trigger: project:) locally. Child pipelines share the parent’s Docker infrastructure. Cross-project triggers resolve the target project from a local directory or by cloning it from GitLab.
Child pipelines (trigger: include:)#
Child pipelines work the same as production GitLab CI parent-child pipelines. All include formats (string shorthand, local: map, artifact-sourced includes (artifact:/job:, see below), forward:, and per-include inputs:) are supported. A trigger: include: may list up to 3 files, which are merged into the child pipeline (matching GitLab).
A child pipeline’s config is ordinary CI YAML, so it may carry its own include: block — and those includes resolve exactly as they do in a top-level .gitlab-ci.yml, include: project: and include: component: included. A child that pulls shared job templates out of a central templates project therefore runs locally with no changes:
# child.yml
include:
- project: my-group/my-templates
ref: v1.0.0
file: templates.yml
job:
extends: .template-job
The remote fetch uses the same GitLab token as the top-level parse (see What requires a GitLab token) and the same on-disk include cache, so a template fetched once is reused by every child pipeline in the run.
When no token is available the include is skipped, and glci says so twice: in the trigger job’s output (glci log <pipeline-id> <trigger-job>, and inline during glci run) and in the daemon log. Look for:
skipping project include my-group/my-templates: no GitLab token configured
What happens next depends on whether anything referenced the skipped file:
- If a job
extends:a definition the include would have provided, the parse fails and the trigger job fails withextends target ".template-job" not found— matching GitLab, which rejects a config whoseextends:target is missing. - If nothing references it, the child pipeline runs without the jobs that file defined. The warning above is the only sign, so treat it as an error in disguise.
Note that downstream parsing is stricter than glci lint/glci jobs here: those warn about a missing extends: target and carry on, because a top-level parse is often run deliberately offline. A downstream parse cannot afford that — a job stripped of its extends: keeps no script:, so it is dropped from the pipeline and the trigger job passes with nothing to show for it. So glci lint -f child.yml may succeed on a file the child parse rejects; the parse error names the target it could not find.
A downstream config that GitLab rejects outright — a rules: if: that does not parse, for example — fails the trigger job with CI configuration is invalid: and the offending key (jobs:<name>:rules:rule if invalid expression syntax) in that job’s log, and no downstream pipeline is spawned, since GitLab creates none of its jobs. As on GitLab this fails the trigger job whatever its strategy:, so the parent fails unless the trigger job has allow_failure: true. With allow_failure: true the run stays green and reports no error at all: GitLab drops the bridge with downstream_pipeline_creation_failed, and a failed bridge that is allowed to fail counts as success with warnings. The reason is still in that trigger job’s own log, and in a nested pipeline (parent triggers child triggers grandchild) the tolerated refusal stops there rather than sinking the pipelines above it. glci lint -f child.yml reports the same error.
Where include paths resolve from#
trigger: include: paths — and any include: local: inside the child’s own config — resolve from the repository root, exactly as GitLab reads them out of the repository. Leading slashes are stripped, so ci/child.yml and /ci/child.yml name the same file.
Include resolution does not depend on which directory you invoke glci from, so a CI file in a subdirectory names paths the way GitLab reads them:
# apps/web/.gitlab-ci.yml, run as: glci run --file apps/web/.gitlab-ci.yml
child:
trigger:
include: ci/child.yml # <repo-root>/ci/child.yml, as on GitLab
A path that climbs out of the repository (../outside.yml) is rejected. An absolute filesystem path is not a special case: its leading slash is stripped like any other, so /etc/passwd is looked up as <repo-root>/etc/passwd and simply isn’t found — a path outside the repository can never be read, because GitLab could not read one either.
A path reached through a symbolic link is rejected too, even when the link points at a file inside the repository. That matches GitLab rather than merely being cautious: GitLab reads includes out of the git object database, where a symlink is a blob holding the target’s path. So it never traverses through one — link/ci.yml where link is a symlink answers Local file 'link/ci.yml' does not exist! — and never follows one, reading the link’s own text instead, which gives Invalid configuration format for a path where a mapping was expected. The rule is about the link, not about whether it is committed — glci reads your working tree, so an untracked link is refused the same way. Reference the file directly rather than through a link; GitLab’s own documentation recommends include:local in place of symbolic links for this reason.
Wildcards in include paths#
A * makes a path a wildcard, matched the way GitLab matches it. This applies to trigger: include: and to include: local: alike — they share one implementation.
| Form | Matches | Example |
|---|---|---|
* | any characters except /, so it stays inside one directory | ci/*.yml matches ci/a.yml, not ci/one/b.yml |
** | any characters, / included | ci/**.yml matches ci/a.yml and ci/one/two/c.yml |
**/ | any run of directories, or none | ci/**/*.yml matches ci/a.yml as well as ci/one/two/c.yml |
Because **/ can match zero directories, a root-level **/*.yml picks up files at the top of the repository too, not only nested ones.
A * is the only thing that makes a path a wildcard. ?, [, ], { and } are literal parts of a filename, so ci/[ab].yml names one file called exactly that rather than matching ci/a.yml and ci/b.yml. This differs from exists: and changes:, which are shell globs where ? and [ are metacharacters — see workflow rules.
Matched files merge in path order, byte-sorted the way git lists them, so a later file overrides an earlier one on a conflicting key.
Wildcards resolve against the project’s non-ignored files. A gitignored fragment is not includable, and neither is anything inside a submodule — GitLab resolves includes out of the repository and would not see either.
A wildcard that matches nothing is silently ignored, as on GitLab. A path with no * in it is a plain filename, so if it does not exist you get an error rather than silence.
The 3-file limit counts include entries, before expansion — again as GitLab counts it — so one ci/*.yml entry may merge more than three files, and a ** entry may merge a great many.
A wildcard expands against every matching file, not only the YAML ones, and each match must then end in .yml/.yaml — so ci/* fails when ci/ holds a README.md, while ci/*.yml beside the same file merges cleanly. Directories are skipped, matching GitLab, whose wildcard search returns files only. The pattern itself is never checked; GitLab expands before it validates.
For a trigger: include:, that check happens when the child pipeline is created rather than at glci lint time, which is how GitLab sequences it too — so the parent lints clean and the trigger job is where a bad extension surfaces.
Variables in a child’s include paths#
$VAR / ${VAR} references in a child’s include: entries (project:, ref:, file:, local:) expand against the variables the trigger job forwards downstream — the parent’s global variables: merged with the trigger job’s own variables: — the same set GitLab expands them against. So this picks up v2.0.0:
# .gitlab-ci.yml
child:
variables:
TEMPLATES_REF: v2.0.0
trigger:
include: child.yml
# child.yml
include:
- project: my-group/my-templates
ref: $TEMPLATES_REF
file: templates.yml
Underneath the forwarded set sit the predefined CI_* variables and any API-fetched instance/group/project variables, so ref: $CI_DEFAULT_BRANCH resolves too. The forwarded set overrides the child’s own global variables: block, matching GitLab.
glci’s own layers (--env, --env-file, preset env:, .glci.env) sit on top of all of it and still override a forwarded value. Note they apply whatever forward: says — they are glci’s local override for a run, not a simulated GitLab variable source — so --env can steer which file a child includes even when forward: pipeline_variables is false and that variable is absent from the child’s runtime environment. Setting forward: yaml_variables: false withholds the forwarded set from the expansion, leaving the predefined and glci layers in place.
glci-specific behavior#
- The child shares the parent’s mock server and runner containers.
- The child’s submodules are served too: it inherits what the parent served, and glci walks the repository again when the child’s jobs ask for more.
- Child pipeline events are bridged to the parent’s event bus, so the TUI and
glci logdisplay them inline. - Nesting is capped at 2 levels deep (matching GitLab). Exceeding this produces:
child pipeline nesting depth 3 exceeds maximum 2. strategy: depend(ormirror) makes the trigger job fail if the child fails. Without a strategy the trigger job is fire-and-forget and passes once the child is created.allow_failure: trueon a trigger job is honoured, so a failing child understrategy: dependneed not sink the parent pipeline. A trigger job runs no script and so never reports an exit code, which meansallow_failure:exit_codesnever applies to one. See Tolerated failures.- The child receives
CI_PIPELINE_SOURCE=parent_pipelineandCI_PARENT_PIPELINE_ID, and its own freshly generatedCI_PIPELINE_ID/CI_PIPELINE_IID/CI_PIPELINE_URL/CI_PIPELINE_CREATED_AT(not inherited from the parent). - Stopping the parent pipeline (
glci stop) automatically cancels in-flight child pipeline jobs via the Job-Status cancel protocol.
Variable forwarding#
forward: controls what the parent passes to the downstream pipeline. The defaults match GitLab’s FORWARD_DEFAULTS:
| Forward setting | Default | What it passes |
|---|---|---|
yaml_variables | true | The trigger job’s YAML variables: the parent’s global variables: merged with the trigger job’s own variables: (job overrides global) |
pipeline_variables | false | Variables passed via --env (CLI/manual) and --env-file/dotenv (dependency) variables |
Because yaml_variables defaults to true, a trigger job with no forward: block still forwards the parent’s global variables and the trigger job’s own variables:. Set yaml_variables: false to suppress both.
Forwarded yaml_variables values are expanded in the context of the trigger job (the upstream pipeline) before being forwarded, so a value like SERVICE_PROJECT_URL: $CI_PROJECT_URL resolves to the parent project’s URL — matching GitLab. Use $$ to pass a literal dollar sign. If an expanded value references a masked variable, the forwarded copy stays masked.
Trigger inputs: are not forwarded as variables — like GitLab, they are used only for spec:inputs interpolation of the included config.
Dynamic child pipelines (trigger: include: artifact:)#
A child pipeline can be generated at runtime by a job and passed to the trigger as an artifact, matching GitLab’s dynamic child pipelines. A generator job writes the child pipeline YAML to a file and exposes it as an artifact; the trigger job references it with artifact: and job::
generate-child-pipeline:
stage: generate
script:
- ./scripts/generate.sh > generated-pipeline.yml
artifacts:
paths:
- generated-pipeline.yml
trigger-child-pipeline:
stage: trigger
needs:
- job: generate-child-pipeline
artifacts: true
trigger:
include:
- artifact: generated-pipeline.yml
job: generate-child-pipeline
strategy: depend
glci resolves the artifact from the generator job’s output and runs it as the child pipeline, with full support for strategy: depend, variable forwarding, and per-include inputs: — the same as a static local: include. Notes:
- The generator job must run before the trigger job. Like GitLab, glci resolves it by name within the same pipeline, so put it in an earlier stage (as above) or add
needs:. If it hasn’t produced the artifact yet, the trigger job fails withartifact include "…": job "…" produced no artifacts. - The
artifact:path is resolved inside the generator job’s artifact archive (the same path you listed underartifacts:paths:); nested paths likedir/child.ymlwork. artifact:requires ajob:; omitting it is a config error.- An empty artifact file, or one larger than 5 MiB, is rejected with a clear error.
- Artifact-sourced includes can be mixed with plain
local:includes (still up to 3 files total).
Cross-project pipelines (trigger: project:)#
Cross-project triggers (trigger: project:) work the same as production GitLab CI, but glci needs to know where the target project’s source code lives locally. A target config GitLab rejects outright fails the trigger job exactly as a child’s does, described under Child pipelines above; lint it with glci lint -f pointed at the target’s CI file.
Mapping projects to local directories#
Use --project-dir to map a project path to a local checkout:
glci run --project-dir group/other-project=../other-project
Or configure the mapping permanently in .glciconfig.toml:
[projects."group/other-project"]
dir = "../other-project"
branch = "main" # optional: override which branch to use
Relative paths in dir are resolved against the parent project’s working directory. The target directory must be a git repository.
Resolution order#
When glci encounters a trigger: project: job, it resolves the target in this order:
- CLI
--project-dirmapping – highest priority - Config
[projects]section in.glciconfig.tomlor~/.glci/config.toml(project-level overrides global) - Git clone from GitLab – if a token is available, glci clones the target project as a bare repo (works for public repos without a token)
- Error with instructions – if none of the above succeed, glci prints a message showing how to configure the mapping
Submodules in a triggered target#
The target’s own git submodules are served from the mock, read from
the commit the target is running and resolved against the target’s project path — ../submod
next to mygroup/target is mygroup/submod, not a sibling of the triggering project. A target
resolved by cloning has no checkout to take objects from, so map its submodules with
--project-dir when they cannot be cloned from your GitLab instance.
Includes in a cloned target#
A target resolved by cloning from GitLab has no local checkout, so glci extracts the target’s CI configuration out of the bare repo into a private directory and parses it there. The whole tree of .yml/.yaml files is extracted, not just .gitlab-ci.yml, so the target’s own include: local: entries resolve from its repository root the way GitLab resolves them:
# .gitlab-ci.yml in the target project
include:
- local: ci/build.yml # resolves — ci/build.yml is extracted too
Only YAML is extracted, because that is the only thing an include: can name (GitLab rejects an include whose path has any other extension). Two consequences:
rules: exists:in a cloned target is evaluated against that extracted tree, so it only ever sees the target’s YAML files. Map the project locally (--project-dir, or[projects]) when a target’s job selection depends on non-YAML files.- A target with more than 64 MiB of YAML is refused with a message pointing at
--project-dir. Extraction runs per cross-project trigger job, and a repository that large belongs on disk rather than in a temp directory.
The target’s own .gitattributes is not applied: export-ignore hides files from git archive, but GitLab reads includes straight out of the repository, so glci does too.
The target’s config is read from its repository root as .gitlab-ci.yml; a target whose project settings point ci_config_path somewhere else needs a local mapping.
What requires a GitLab token#
| Feature | Token required | Works offline |
|---|---|---|
trigger: include: (child pipeline) | No – unless the child’s own config uses include: project:/component: | Yes (same caveat) |
trigger: project: with --project-dir | No | Yes |
trigger: project: with [projects] config | No | Yes |
trigger: project: (auto-clone from GitLab) | Yes (or public repo) | No |
include: project: in YAML | Yes | No |
include: component: in YAML | Yes | No |
include: local: in YAML | No | Yes |
include: remote: (HTTP URL) | No | Needs network |
include: template: | No | Needs network |
Cross-project variables#
Cross-project pipelines get fresh CI variables derived from the target project’s git state rather than inheriting the parent’s. This means CI_PROJECT_NAME, CI_COMMIT_SHA, CI_COMMIT_REF_NAME, and other git-derived variables reflect the target project.
The target pipeline receives CI_PIPELINE_SOURCE=pipeline (distinct from child pipelines which use parent_pipeline).
The cross-project pipeline also gets CI_REGISTRY, CI_REGISTRY_USER, and CI_REGISTRY_IMAGE pointing at the shared embedded registry, so jobs in the triggered project can build and push images just like the parent. CI_REGISTRY_IMAGE reflects the target project’s path. CI_REGISTRY_PASSWORD is injected per job (the job token), the same way it works in the parent pipeline.
Variable forwarding uses the same forward: rules and defaults as child pipelines (see Variable forwarding above): yaml_variables (default true) forwards the parent’s global variables: merged with the trigger job’s own variables:; pipeline_variables (default false) forwards CLI/dotenv variables. The target’s own include: entries expand against that forwarded set too, exactly as a child pipeline’s do (see Variables in a child’s include paths). Forwarded values are expanded in the trigger job’s context ($CI_PROJECT_URL resolves to the parent project’s URL, not the target’s), and trigger inputs: are used only for spec interpolation, not forwarded as variables.
Dirty mode for cross-project targets#
When dirty mode is enabled (the default), glci overlays uncommitted and untracked files from local project directories onto the bare repo sent to the runner. This lets you test changes across multiple projects without committing first.
Multi-project setups#
For monorepos, standard child pipelines with different include paths work as expected. You can run a specific trigger job with glci run trigger-frontend.
For separate repositories that form a pipeline chain, map them all in .glciconfig.toml:
[projects."myorg/shared-lib"]
dir = "../shared-lib"
[projects."myorg/deploy-tools"]
dir = "../deploy-tools"
branch = "main"
include: project: in YAML#
include: project: works the same as production GitLab CI – it fetches YAML files from other projects via the GitLab Repository Files API at parse time. This always requires a GitLab token (see the token table above).
Nested includes within the fetched file that use local: are automatically rewritten to project: includes targeting the same remote project, matching GitLab’s behavior.
It applies to every config glci parses, not just the top-level .gitlab-ci.yml: a child pipeline’s config and a cross-project trigger’s target config resolve include: project: the same way, against the same GitLab instance and token. Fetched files are cached under ~/.glci/cache/includes and shared across all of them (a ref naming a complete version, such as 1.2.3, is cached indefinitely; everything else for an hour, including a partial 1.2, which names different content as new versions are published).
include: component: in YAML#
include: component: fetches CI/CD components from GitLab via the API at parse time. This always requires a GitLab token.
Version selectors#
Components are referenced with a version suffix after @. Besides an exact reference, GitLab recognises two selector shapes:
include:
- component: gitlab.com/mygroup/myproject/mycomp@2.3
| Selector | Example | Resolves to |
|---|---|---|
@~latest | gitlab.com/mygroup/myproject/mycomp@~latest | Newest published semver version |
@N | gitlab.com/mygroup/myproject/mycomp@2 | Newest published 2.*.* (e.g. 2.5.1) |
@N.M | gitlab.com/mygroup/myproject/mycomp@2.3 | Newest published 2.3.* (e.g. 2.3.7) |
| anything else | …/mycomp@1.2.3, @main, @0f1e2d3 | That tag, branch or commit, read literally |
Candidates come from published releases, so an unreleased tag is never selected. Pre-releases (e.g. 1.0.0-rc1) are excluded, and a published version always wins over a tag or branch of the same name.
A partial version that matches no published release is still tried as a literal tag or branch name, matching GitLab’s fallback — so a project whose 2.3 line has nothing published but does carry a 2.3 tag still resolves @2.3 to that tag.
@~N and @~N.M are not valid. ~latest is the only selector GitLab spells with a tilde; earlier glci versions resolved @~2, which GitLab rejects with content not found. Drop the tilde and use @2.
Resolution queries the GitLab Releases API, so a selector needs a token and network access. An exact reference also needs a token but skips that lookup.
One divergence to know about: glci reads the project’s published releases, while GitLab reads the versions published to the CI/CD Catalog. Not every release becomes a catalog version — GitLab checks each release’s commit for a README.md and at least one discoverable component, and requires the project to have a description — and a project that has releases but was never published as a catalog resource has no catalog versions at all. In each of those cases glci resolves a selector that GitLab refuses, so a selector working locally is not proof the component is consumable on GitLab. Pin an exact version when the component project is not a published catalog resource.
$[[ component.reference ]]#
A component can pin its own nested includes to the reference it was included at:
spec:
component:
- reference
---
include:
- component: gitlab.com/mygroup/myproject/child@$[[ component.reference ]]
glci substitutes the reference exactly as GitLab does, with the same two conditions:
- The component has to opt in. The value only exists when the file’s
spec:header listsreferenceundercomponent:, and that header has to be the first document of a multi-document file (the---above is part of the requirement). A file with no header is not interpolated at all, on GitLab or here: the literal string travels on as a git ref, and the include it pins fails as a missing file (glci reports the fetch, e.g.HTTP 404). A file with a header whosecomponent:list omitsreferenceis a hard error instead,unknown interpolation provided, because the header decides what the expression may read. - The value is the reference as written, not the version it resolves to. A component included at
@~latestinterpolates~latest, so a child pinned this way resolves against its own published versions, not the parent’s.
Two more details follow from GitLab’s implementation. The expression is only substituted in the body, so one written inside the spec: header keeps its literal text. And a component’s nested local: includes inherit its reference, while a nested project:, remote: or template: include does not: those files have no component context. Nor does a .gitlab-ci.yml, which is not part of any component. A file with no context that opts in anyway fails with unknown interpolation provided, rather than passing a placeholder to the API as a ref and reporting a missing file at a nonsense reference. A file that opts in with the wrong key (component: [version], say) fails the same way.
component.name, component.sha and component.version are not implemented yet. They are left literal.
Troubleshooting#
If a trigger job fails, check the daemon logs for resolution and parsing errors:
glci log <pipeline-id> trigger-job-name
tail -f ~/.glci/daemon.log