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::Filterdiscards a rules-gated include before the Verifier sees it. Confirmed live: the lint API returnsvalid: truewhen 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 ano GitLab token configuredwarning. 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 lintstrigger: include: - local: ci/child.txtas valid at parent time and defers the rule to child-pipeline creation. glci's equivalent moment isParseChildPipelineMerged, 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. materializeArtifactIncludesneeded a marker. It extracts atrigger: include: artifact:payload to a.tmpfile — 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_generatedand 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.mdasks for a job ine2e/testdata/comprehensive/ci/jobs.ymlfor new capabilities, but this one is a rejection — a fixture exercising it cannot also be a pipeline that stays green. Covered instead byTestCLIInteg_Lint_IncludeWithoutYAMLExtension, followingTestCLIInteg_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 txtOn 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 .yamlFor 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.
Relevant issues and other links
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::Base—YAML_ALLOWLIST_EXTENSION,invalid_extension?,validate_location!Mapper::Verifier— location validation ahead of context and contentMapper::Filter— drops rules-gated includes before they are validatedMapper::LocationExpander— wildcards expand against every blob, with no extension filter