fix(config): Reject include paths without a YAML extension

What this MR does and why?

GitLab accepts an include: location only when its last path segment ends in .yml/.yaml. glci never checked, so a config that cannot run on GitLab reported green locally — the wrong direction for a compatibility tool.

The check runs where GitLab's Mapper runs it: after LocationExpander and VariablesExpander, and over every location before any is fetched, matching Verifier's first loop. That ordering is the useful part — ci/child.txt now reports its extension as the cause instead of surfacing later as a missing file or a YAML decode error, and a bad extension on the second include beats a network round-trip for the first.

The predicate is YAML_ALLOWLIST_EXTENSION = /.+\.(yml|yaml)$/i over ::File.basename. Two consequences are easy to miss and both are pinned by tests: a file named .yml is rejected, because .+ demands a character before the dot; and a remote URL's query string is part of the basename, so …/f.yml?ref_type=heads — what Open raw gives you — is rejected. filepath.Ext reproduces neither, so the predicate works on the basename directly.

Both resolution paths are covered. IncludeResolver.resolveIncludes is the offline route. resolveLocalIncludes needs it too: it inlines local includes before the config is POSTed to the Lint API, so without a check there a non-YAML include still slipped through under GLCI_PREFER_API.

The issue's list of include types is wrong in both directions

The issue says "local/remote/project/component". Checked against the Rails source and the lint API:

Type Checked Why
local, project, artifact yes inherit Base#validate_location!
remote yes super, then address validity
template yes super, then a .gitlab-ci.yml suffix rule this MR does not implement
component no File::Component overrides validate_location! to do only the string check

component: is exempt — its location is FQDN/project/name@version, so applying the rule would break every component include. template: is checked and the issue omits it. artifact: is checked by GitLab, but glci has no top-level include: artifact: fetcher, so such an entry already falls through to unsupported include entry. Implemented set: local, project, remote, template.

Four things are deliberately not rejected

Each is a config GitLab accepts, so rejecting it is the worse failure — the pipeline works upstream and the only workaround would be editing it, which is the opposite of the point:

  • component:, per the table above.
  • Anything carrying rules:Mapper::Filter discards a rules-gated include before the Verifier sees it. Confirmed live: the lint API returns valid: true when the rule cannot match and the extension error once it can. glci does not evaluate include rules yet, so it cannot tell the two apart.
  • A wildcard pattern itself — only its matches are checked. Directories among them are skipped, since GitLab's wildcard search returns blobs and never trees.
  • A location still holding an unexpanded $VAR — glci has no instance/group/project variables at include time, so a location GitLab resolves can arrive here literal. Blaming its extension would send you to rename a file that does not exist.

Settled by 20 POST /projects/:id/ci/lint probes with dry_run: true, including local: "*" over a mixed directory (GitLab expands to every blob, then rejects the first non-YAML) and local: ci/$F (the error names the expanded path).

Notes for review

  • A project: include with a bad extension is now a hard error even with no token, where it used to be skipped with a no GitLab token configured warning. Deliberate: GitLab validates a location before it looks at content or access, which is exactly the failure the issue asks to surface locally.
  • trigger: include: is not given its own check. GitLab lints trigger: include: - local: ci/child.txt as valid at parent time and defers the rule to child-pipeline creation. glci's equivalent moment is ParseChildPipelineMerged, which already routes through the resolver — so child pipelines get the same behaviour at the same moment, for free.
  • A wildcard rejection names the pattern and the match (local include "ci/*" matched "ci/notes.md"). The match is nowhere in the user's config, and the fix is to constrain the glob, not to rename the file it found.
  • Remote messages drop the query, fragment and any userinfo password. This check rejects such a URL before it is fetched, so one carrying ?private_token=… that previously worked silently would now always print it.
  • materializeArtifactIncludes needed a marker. It extracts a trigger: include: artifact: payload to a .tmp file — deliberately not .yml, so a sibling glob in the same trigger cannot sweep it up — and rewrites it into a local include that reaches the resolver. It is marked _glci_generated and skipped, and that key is stripped before the remaining entries are re-marshalled so it cannot reach the Lint API. No unit test parsed that entry, so only the Docker tier would have caught the regression; an assertion was added.
  • On the comprehensive fixture: CLAUDE.md asks for a job in e2e/testdata/comprehensive/ci/jobs.yml for new capabilities, but this one is a rejection — a fixture exercising it cannot also be a pipeline that stays green. Covered instead by TestCLIInteg_Lint_IncludeWithoutYAMLExtension, following TestCLIInteg_Lint_UnknownHookKey.

Filed separately rather than widening this MR: the user-written trigger: include: artifact: path; template:'s .gitlab-ci.yml suffix rule; the two glob predicates in pkg/config that disagree (containsGlob counts *?[, GitLab counts only *), which belongs with !166 (merged); and a pre-existing gap where resolveLocalIncludes has no containment check, so on the API-first route a .. path is read and POSTed to GitLab — this MR narrows it to .yml/.yaml but does not close it.

Steps to reproduce

# .gitlab-ci.yml
include:
  - local: ci/child.txt

root_job:
  script: echo root
# ci/child.txt — valid YAML, wrong extension
job_from_txt:
  script: echo txt

On main at 46f1bd3, glci lint reports CI configuration is valid: 2 jobs, 5 stages and exits 0, and glci jobs lists job_from_txt. GitLab rejects the identical config with Included file `ci/child.txt` does not have YAML extension!.

After:

$ glci lint
CI configuration is invalid: parsing CI config: resolving includes: local include "ci/child.txt": path must end in .yml or .yaml

For the wildcard direction, put ci/jobs.yml and ci/notes.md side by side and include local: 'ci/*' — it now fails naming both the pattern and the match, while local: 'ci/*.yml' beside the same .md still resolves.

Closes #124 (closed)

  • #112 (closed) — cross-project targets extract only .yml/.yaml; that assumption is now enforced rather than incidental
  • #123 (closed) / !166 (merged) — rewrites glob expansion in the same two files; whichever lands second needs a small rebase
  • External::File::BaseYAML_ALLOWLIST_EXTENSION, invalid_extension?, validate_location!
  • Mapper::Verifier — location validation ahead of context and content
  • Mapper::Filter — drops rules-gated includes before they are validated
  • Mapper::LocationExpander — wildcards expand against every blob, with no extension filter
Edited by Paweł Farys

Merge request reports

Loading
Loading