3 Commits

Author SHA1 Message Date
k3nny 1339ab4149 fix(linter): 🐛 yaml parser with escape in regex
ci / vet, staticcheck, test, build (push) Successful in 1m54s
release / Build and publish release (push) Successful in 1m10s
2026-06-12 01:04:35 +02:00
k3nny fef1536e1b feat(cli): ruff-style output, implicit context defaults, --list-vars
ci / vet, staticcheck, test, build (push) Successful in 2m16s
release / Build and publish release (push) Successful in 1m6s
- Finding format now follows file:line: RULEID [severity] message,
  matching ruff and other modern linters (GL003 [error] job "x": ...)
- glint check and glint graph default to --branch main --source push
  when no context flag is given; rules:if: is always evaluated
- --list-vars flag on both commands prints sorted KEY=VALUE of all
  collected variables (YAML, workflow-rule union, effective context)
- CHANGELOG [Unreleased] promoted to [0.2.11]; README badge updated;
  ROADMAP marks newly shipped items

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 00:36:32 +02:00
k3nny 46a1cf3c08 feat: add go lint
ci / vet, staticcheck, test, build (push) Successful in 2m3s
release / Build and publish release (push) Successful in 1m9s
2026-06-11 23:56:09 +02:00
26 changed files with 413 additions and 47 deletions
+40
View File
@@ -0,0 +1,40 @@
name: ci
on:
push:
branches:
- '**'
pull_request:
jobs:
ci:
name: vet, staticcheck, test, build
runs-on: ubuntu-latest
container:
image: golang:1.26-alpine
steps:
- name: Install tools
run: apk add --no-cache git
- name: Checkout
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
SERVER_URL: ${{ github.server_url }}
REPO: ${{ github.repository }}
REF: ${{ github.ref_name }}
run: |
git clone --depth 1 --branch "$REF" \
"$(echo "$SERVER_URL" | sed "s|https://|https://oauth2:${TOKEN}@|")/${REPO}.git" .
- name: vet
run: go vet ./...
- name: staticcheck
run: go tool staticcheck ./...
- name: test
run: go test ./...
- name: build
run: go build ./cmd/glint/...
+10 -2
View File
@@ -5,13 +5,21 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
This project uses [Semantic Versioning](https://semver.org).
## [Unreleased]
## [0.2.11] - 2026-06-12
### Added
- **Ruff-style finding output** — findings now follow the `file:line: RULEID [severity] message` format (e.g. `.gitlab-ci.yml:14: GL004 [error] job "deploy": stage "production" is not defined in 'stages'`), matching the output convention used by [ruff](https://docs.astral.sh/ruff/) and other modern linters. Job-scoped findings prefix the message with `job "name": `; pipeline-level findings omit the job prefix. Severity is lowercase inside brackets (`[error]`, `[warning]`).
- **Implicit default context (`--branch main --source push`)** — when `glint check` or `glint graph` is invoked without any of `--branch`, `--tag`, `--source`, or `--var`, the context now defaults to `--branch main --source push` so that `rules:if:` expressions are always evaluated. Previously the context was empty and no rule evaluation occurred. Any explicit context flag bypasses the defaults entirely.
- **`--list-vars` debug flag** — available on both `glint check` and `glint graph`; prints all pipeline-level variables collected from the root file and every included file (sorted `KEY=VALUE`) to stderr, then continues normally. When a context is active, also prints the effective merged variable set (pipeline defaults + workflow-rule variables + CLI flags). Useful for diagnosing GL032 false positives.
- **Included-file variables now visible to all lint rules** — `variables:` blocks declared in included files (local, remote, project, and component includes) are now merged into the pipeline's variable namespace before linting. This eliminates false-positive GL032 warnings for variables declared in shared CI templates. Root-pipeline variables take precedence over included-file variables when the same key appears in both (matching GitLab's own override behaviour).
- **Variable reference validation (GL032)** — glint now warns when a `rules:if:` expression references a variable (`$VAR` or `${VAR}`) that is not declared anywhere in the pipeline YAML: pipeline-level `variables:`, the job's own `variables:`, or any `workflow:rules:variables:` block. Predefined GitLab CI variable namespaces (`CI_*`, `GITLAB_*`, `FF_*`, `RUNNER_*`, `TRIGGER_*`, `CHAT_*`) are exempt. Because variables can also be set in GitLab CI/CD project settings (invisible to glint), the finding is a `[WARNING]` rather than an error. Each undeclared variable is reported at most once per job to keep the output concise.
- **Structured rule IDs** — every finding now carries a stable `GL###` identifier (e.g. `GL003`) that appears in the output between the location and the message: `[ERROR] job "deploy" (file.yml:14) GL003: missing required field 'script'`. IDs are assigned per check function across 31 rules (GL001GL031) and are stable across versions. The `linter.Finding` struct exposes the ID as a `Rule string` field for programmatic consumers. The README lint rules table is updated with ID columns.
- **Structured rule IDs** — every finding now carries a stable `GL###` identifier (e.g. `GL003`) that appears in the output alongside the location and message: `.gitlab-ci.yml:14: GL003 [error] job "deploy": missing required field 'script'`. IDs are assigned per check function across 31 rules (GL001GL031) and are stable across versions. The `linter.Finding` struct exposes the ID as a `Rule string` field for programmatic consumers. The README lint rules table is updated with ID columns.
- **`workflow:rules:variables:` now propagate to job rule evaluation** — when a `workflow:rules:` entry matches, any `variables:` it defines are injected into the evaluation context so job `rules:if:` expressions can reference them. Pipeline-level `variables:` are also available as defaults (lower priority). Variable priority order, highest first: `--var` CLI overrides → `--branch`/`--tag`/`--source` shortcuts → workflow-rule variables → pipeline-level variable defaults. This means `$DEPLOY_TARGET == "production"` in a job rule correctly evaluates when a workflow rule sets `DEPLOY_TARGET: production` for the matching branch. The `glint graph tree` command benefits from the same enrichment.
+12 -8
View File
@@ -6,7 +6,7 @@
<p align="center">
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-blue.svg" alt="License"></a>
<a href="CHANGELOG.md"><img src="https://img.shields.io/badge/release-v0.2.0-blue.svg" alt="Release"></a>
<a href="CHANGELOG.md"><img src="https://img.shields.io/badge/release-v0.2.11-blue.svg" alt="Release"></a>
</p>
> **Disclaimer:** This tool was built through iterative AI-assisted development with [Claude](https://claude.ai). It is experimental, incomplete, and not intended for production use. Coverage of GitLab CI keywords is best-effort and may lag behind GitLab's evolving spec. Use it at your own discretion — no correctness guarantees are made. Contributions and bug reports are welcome.
@@ -216,8 +216,12 @@ Rules without an `if:` clause always match.
### Example output
```
# Clean pipeline, no context
OK: .gitlab-ci.yml — no issues found (5 jobs, 3 stages)
# Clean pipeline (implicit default: --branch main --source push)
Context: branch=main, source=push
Active (5): build, deploy-staging, test, ...
OK: .gitlab-ci.yml — no issues found (5 job(s), 3 stage(s))
# With --branch develop context
Context: branch=develop, source=push
@@ -225,7 +229,7 @@ Context: branch=develop, source=push
Active (3): build, deploy-staging, test
Skipped (2): deploy-prod, release-notes
OK: .gitlab-ci.yml — no issues found (5 jobs, 3 stages)
OK: .gitlab-ci.yml — no issues found (5 job(s), 3 stage(s))
# With --tag v1.0.0 context
Context: tag=v1.0.0, source=push
@@ -233,12 +237,12 @@ Context: tag=v1.0.0, source=push
Active (4): build, deploy-prod, release-notes, test
Skipped (1): deploy-staging
OK: .gitlab-ci.yml — no issues found (5 jobs, 3 stages)
OK: .gitlab-ci.yml — no issues found (5 job(s), 3 stage(s))
# Pipeline with issues
[ERROR] job "deploy": stage "production" is not defined in 'stages'
[ERROR] job "test": needs unknown job "build-app"
[WARNING] job "old-job": 'only'/'except' are deprecated; prefer 'rules'
.gitlab-ci.yml:14: GL004 [error] job "deploy": stage "production" is not defined in 'stages'
.gitlab-ci.yml:22: GL027 [error] job "test": needs unknown job "build-app"
.gitlab-ci.yml:31: GL007 [warning] job "old-job": 'only'/'except' are deprecated; prefer 'rules'
3 finding(s): 2 error(s)
```
+11 -5
View File
@@ -4,7 +4,7 @@ This document tracks planned improvements to `glint`. Items are grouped by theme
---
## Context-aware validation — ✓ single-context shipped in v0.2.0; expression evaluator hardened post-v0.2.0
## Context-aware validation — ✓ single-context shipped in v0.2.0; expression evaluator hardened post-v0.2.0; implicit defaults and --list-vars shipped v0.2.11
Single-context simulation is fully implemented. Pass `--branch`, `--tag`, `--source`, or `--var` to either `glint check` or `glint graph`; jobs are evaluated and shown as active / manual / skipped.
@@ -26,6 +26,10 @@ glint graph tree --branch main .gitlab-ci.yml # tree annotated with [skipped]
-**Expression evaluator: bare `true` / `false` keywords** — treated as the strings `"true"` / `"false"` matching GitLab CI's own behaviour; `$GATEWAY_ENABLED == true` now evaluates correctly.
-**Expression evaluator: integer literals**`$COUNT == 4`, `$ENABLED == 1`, `$DISABLED == 0` compare as decimal strings.
~~**Implicit default context**~~ — ✓ shipped v0.2.11; `glint check` and `glint graph` default to `--branch main --source push` when no context flag is given, so `rules:if:` expressions are always evaluated out of the box.
~~**`--list-vars` debug flag**~~ — ✓ shipped v0.2.11; prints sorted `KEY=VALUE` of all collected variables (pipeline YAML + included files + workflow-rule union + effective context) to stderr.
**Remaining work**
- **Multi-context simulation** — run multiple contexts in one invocation and print a comparison table:
@@ -41,7 +45,7 @@ glint graph tree --branch main .gitlab-ci.yml # tree annotated with [skipped]
The current rule set covers the most common sources of broken pipelines. These are the gaps most likely to matter in practice.
- **Variable reference validation** — warn when a job references `$VAR` (or `${VAR}`) that is not declared anywhere in `variables:`, `default.variables`, or the job itself
- ~~**Variable reference validation (GL032)**~~ — ✓ shipped v0.2.11; warns when a `rules:if:` expression references `$VAR` / `${VAR}` not declared anywhere in pipeline YAML; predefined GitLab namespaces (`CI_*`, `GITLAB_*`, …) exempt; variables from included files are also considered
- **`services:` validation** — map form requires `name`; `alias` must be a valid DNS label
- **`rules:changes` / `rules:exists`** — warn on glob patterns that can never match (e.g. absolute paths, double `**` on unsupported versions)
- **`timeout` format** — must be a duration string GitLab understands (`1h 30m`, `90 minutes`, etc.)
@@ -90,9 +94,11 @@ The SVG renderer and terminal tree cover the basic layout. These would bring it
---
## Findings quality — ✓ file and line numbers shipped post-v0.2.0
## Findings quality — ✓ file and line numbers shipped post-v0.2.0; ruff-style format shipped v0.2.11
~~**File and line numbers on findings**~~ — ✓ shipped post-v0.2.0; every `[ERROR]` / `[WARNING]` now includes the source file and exact line of the job key (e.g. `job "deploy" (src/deploy.yml:14): …`). Works across local includes, remote project templates, and fetched component templates.
~~**File and line numbers on findings**~~ — ✓ shipped post-v0.2.0; every finding includes the source file and exact line of the job key. Works across local includes, remote project templates, and fetched component templates.
~~**Ruff-style output format**~~ — ✓ shipped v0.2.11; findings follow `file:line: RULEID [severity] message` matching the convention used by ruff and other modern linters.
**Remaining improvements**
@@ -125,7 +131,7 @@ The SVG renderer and terminal tree cover the basic layout. These would bring it
## Reliability and developer experience
- **Structured rule IDs** — assign a stable short ID to every rule (e.g. `GS001`) so suppression, documentation, and SARIF output are stable across versions
- ~~**Structured rule IDs**~~✓ shipped post-v0.2.0; GL001GL031 assigned; GL032 added v0.2.11
- **`--explain <rule-id>`** — print the rule description, rationale, and an example fix
- ~~**Semantic versioning and first release**~~ — shipped as `v0.1.0` (2026-06-07)
- ~~**Subcommand CLI**~~ — shipped as `v0.2.0` (2026-06-11); `glint check` / `glint graph [mode]` with ruff-style `--help`
+13 -4
View File
@@ -73,23 +73,32 @@ tasks:
ignore_error: false
- cmd: ./{{.BINARY}} check --branch feat/x testdata/workflow_vars.yml
ignore_error: false
- cmd: ./{{.BINARY}} check testdata/workflow_escape.yml
ignore_error: false
- cmd: ./{{.BINARY}} check testdata/variable_refs.yml
ignore_error: false
- cmd: ./{{.BINARY}} check samba-testdata/.gitlab-ci.yml
- cmd: ./{{.BINARY}} check testdata/variable_refs_included.yml
ignore_error: false
- cmd: ./{{.BINARY}} check samba-testdata/.gitlab-ci-coverage.yml
- cmd: ./{{.BINARY}} check testdata/samba/.gitlab-ci.yml
ignore_error: false
- cmd: ./{{.BINARY}} check samba-testdata/.gitlab-ci-private.yml
- cmd: ./{{.BINARY}} check testdata/samba/.gitlab-ci-coverage.yml
ignore_error: false
- cmd: ./{{.BINARY}} check testdata/samba/.gitlab-ci-private.yml
ignore_error: false
lint-go:
desc: Run go vet on all packages
cmd: "{{.GO}} vet ./..."
lint-static:
desc: Run staticcheck on all packages
cmd: "{{.GO}} tool staticcheck ./..."
ci:
desc: Full CI check — vet, test, build, validate
desc: Full CI check — vet, staticcheck, test, build, validate
cmds:
- task: lint-go
- task: lint-static
- task: test
- task: build
- task: validate
+116 -4
View File
@@ -64,6 +64,7 @@ func cmdCheck(args []string) {
branch := fs.String("branch", "", "simulate a branch push (sets CI_COMMIT_BRANCH, …)")
tag := fs.String("tag", "", "simulate a tag push (sets CI_COMMIT_TAG, …)")
source := fs.String("source", "", "set CI_PIPELINE_SOURCE")
listVars := fs.Bool("list-vars", false, "print all collected pipeline variables (from root and included files) to stderr, then continue")
var vars multiFlag
fs.Var(&vars, "var", "set a CI variable as KEY=VALUE; repeatable")
fs.Usage = func() {
@@ -90,6 +91,7 @@ Options:
--branch <NAME>
Simulate a branch push. Populates: CI_COMMIT_BRANCH,
CI_COMMIT_REF_NAME, CI_COMMIT_REF_SLUG, CI_PIPELINE_SOURCE=push.
[default: main]
--tag <NAME>
Simulate a tag push. Populates: CI_COMMIT_TAG, CI_COMMIT_REF_NAME,
@@ -97,18 +99,30 @@ Options:
--source <EVENT>
Override CI_PIPELINE_SOURCE.
[possible values: push, merge_request_event, schedule, web, api]
[default: push] [possible values: push, merge_request_event, schedule, web, api]
--var <KEY=VALUE>
Set or override a CI variable. Takes precedence over --branch, --tag,
and --source. Repeatable.
--list-vars
Print all pipeline-level variables collected from the root file and
every included file (sorted KEY=VALUE) to stderr, then continue
normally. Useful for debugging variable resolution and GL032 findings.
-h, --help
Print help
Note: when none of --branch, --tag, --source, or --var are given, glint
defaults to --branch main --source push so that rules:if: expressions are
always evaluated.
Examples:
glint check .gitlab-ci.yml
glint check --branch main .gitlab-ci.yml
glint check --branch develop .gitlab-ci.yml
glint check --tag v1.0.0 .gitlab-ci.yml
glint check --source merge_request_event .gitlab-ci.yml
glint check --list-vars .gitlab-ci.yml
GITLAB_TOKEN=glpat-xxxx glint check .gitlab-ci.yml
glint check --token glpat-xxxx --gitlab-url https://gitlab.example.com .gitlab-ci.yml
glint check --branch main --var DEPLOY_ENV=production .gitlab-ci.yml
@@ -116,6 +130,12 @@ Examples:
}
_ = fs.Parse(args)
// Apply implicit defaults when no context flag is given at all.
if *branch == "" && *tag == "" && *source == "" && len(vars) == 0 {
*branch = "main"
*source = "push"
}
if fs.NArg() != 1 {
fs.Usage()
os.Exit(2)
@@ -148,6 +168,11 @@ Examples:
ctx := cicontext.New(*branch, *tag, *source, vars)
if !ctx.IsEmpty() {
enrichContext(ctx, p)
}
if *listVars {
printVars(p, ctx)
}
if !ctx.IsEmpty() {
printContext(p, ctx)
}
@@ -222,6 +247,7 @@ Options:
evaluated state ([skipped] or [manual]; no tag means active).
Populates: CI_COMMIT_BRANCH, CI_COMMIT_REF_NAME, CI_COMMIT_REF_SLUG,
CI_PIPELINE_SOURCE=push.
[default: main]
--tag <NAME>
Simulate a tag push. Populates: CI_COMMIT_TAG, CI_COMMIT_REF_NAME,
@@ -229,18 +255,29 @@ Options:
--source <EVENT>
Override CI_PIPELINE_SOURCE.
[possible values: push, merge_request_event, schedule, web, api]
[default: push] [possible values: push, merge_request_event, schedule, web, api]
--var <KEY=VALUE>
Set or override a CI variable. Repeatable.
--list-vars
Print all pipeline-level variables collected from the root file and
every included file (sorted KEY=VALUE) to stderr, then continue
normally. Useful for debugging variable resolution.
-h, --help
Print help
Note: when none of --branch, --tag, --source, or --var are given, glint
defaults to --branch main --source push so that rules:if: expressions are
always evaluated.
Examples:
glint graph .gitlab-ci.yml
glint graph tree .gitlab-ci.yml
glint graph tree --branch main .gitlab-ci.yml
glint graph tree --branch develop .gitlab-ci.yml
glint graph tree --tag v1.0.0 .gitlab-ci.yml
glint graph tree --list-vars .gitlab-ci.yml
glint graph includes .gitlab-ci.yml > includes.mmd
glint graph pipeline .gitlab-ci.yml
glint graph pipeline --out /tmp/graphs .gitlab-ci.yml
@@ -250,10 +287,17 @@ Examples:
branch := fs.String("branch", "", "simulate a branch push (sets CI_COMMIT_BRANCH, …)")
tag := fs.String("tag", "", "simulate a tag push (sets CI_COMMIT_TAG, …)")
source := fs.String("source", "", "set CI_PIPELINE_SOURCE")
listVars := fs.Bool("list-vars", false, "print all collected pipeline variables to stderr, then continue")
var vars multiFlag
fs.Var(&vars, "var", "set a CI variable as KEY=VALUE; repeatable")
_ = fs.Parse(args)
// Apply implicit defaults when no context flag is given at all.
if *branch == "" && *tag == "" && *source == "" && len(vars) == 0 {
*branch = "main"
*source = "push"
}
if fs.NArg() != 1 {
fs.Usage()
os.Exit(2)
@@ -276,6 +320,9 @@ Examples:
if !ctx.IsEmpty() {
enrichContext(ctx, p)
}
if *listVars {
printVars(p, ctx)
}
switch mode {
case "default":
@@ -304,6 +351,71 @@ Examples:
}
}
// printVars prints the collected variable namespaces to stderr:
// 1. Pipeline variables — declared in variables: blocks across the root file
// and all included files (merged by ResolveIncludes).
// 2. Workflow-rule variables — union of variables: from every workflow:rules
// entry; any one of them may be injected at runtime.
// 3. Effective context variables — only when ctx is non-empty; shows the
// fully merged set visible to job rules:if: after enrichContext.
func printVars(p *model.Pipeline, ctx *cicontext.Context) {
fmt.Fprintln(os.Stderr, "Pipeline variables (YAML, root + includes):")
printVarMap(p.Variables)
if p.Workflow != nil {
union := map[string]any{}
for _, rule := range p.Workflow.Rules {
for k, v := range rule.Variables {
union[k] = v
}
}
if len(union) > 0 {
fmt.Fprintln(os.Stderr, "Workflow-rule variables (union across all rules):")
printVarMap(union)
}
}
if !ctx.IsEmpty() {
fmt.Fprintln(os.Stderr, "Effective context variables (after workflow + CLI flags):")
keys := make([]string, 0, len(ctx.Vars))
for k := range ctx.Vars {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
fmt.Fprintf(os.Stderr, " %s=%s\n", k, ctx.Vars[k])
}
}
}
func printVarMap(m map[string]any) {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
if len(keys) == 0 {
fmt.Fprintln(os.Stderr, " (none)")
return
}
for _, k := range keys {
fmt.Fprintf(os.Stderr, " %s=%s\n", k, varValueString(m[k]))
}
}
func varValueString(v any) string {
switch val := v.(type) {
case string:
return val
case map[string]any:
if s, ok := val["value"].(string); ok {
return s
}
return "(complex)"
}
return "(complex)"
}
// enrichContext injects pipeline-level variable defaults and then
// workflow-rule-generated variables into ctx before job evaluation.
// Injection respects pinned variables (--branch/--tag/--source/--var always win).
+11 -1
View File
@@ -2,4 +2,14 @@ module git.k3nny.fr/glint
go 1.26.4
require gopkg.in/yaml.v3 v3.0.1 // indirect
require (
github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c // indirect
golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 // indirect
golang.org/x/mod v0.31.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/tools v0.40.1-0.20260108161641-ca281cf95054 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
honnef.co/go/tools v0.7.0 // indirect
)
tool honnef.co/go/tools/cmd/staticcheck
+13
View File
@@ -1,3 +1,16 @@
github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs=
github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ=
golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 h1:1P7xPZEwZMoBoz0Yze5Nx2/4pxj6nw9ZqHWXqP0iRgQ=
golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/tools v0.40.1-0.20260108161641-ca281cf95054 h1:CHVDrNHx9ZoOrNN9kKWYIbT5Rj+WF2rlwPkhbQQ5V4U=
golang.org/x/tools v0.40.1-0.20260108161641-ca281cf95054/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU=
honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc=
+1 -3
View File
@@ -55,9 +55,7 @@ func (b *treeBuilder) nextID() string {
func (b *treeBuilder) buildChildren(parent *treeNode, rawIncludes []any) {
for _, entry := range rawIncludes {
for _, child := range b.parseEntry(entry) {
parent.children = append(parent.children, child)
}
parent.children = append(parent.children, b.parseEntry(entry)...)
}
}
+3 -3
View File
@@ -352,7 +352,7 @@ func checkEnvironment(name string, job model.Job) []Finding {
return nil
}
var findings []Finding
envName, _ := m["name"]
envName := m["name"]
_, hasURL := m["url"]
if (envName == nil || envName == "") && hasURL {
findings = append(findings, Finding{
@@ -391,7 +391,7 @@ func checkArtifacts(name string, job model.Job) []Finding {
})
}
if _, hasExposeAs := m["expose_as"]; hasExposeAs {
paths, _ := m["paths"]
paths := m["paths"]
if paths == nil {
findings = append(findings, Finding{
Severity: Error,
@@ -484,7 +484,7 @@ func checkImage(name string, job model.Job) []Finding {
if !ok {
return nil // String form is valid.
}
imgName, _ := m["name"]
imgName := m["name"]
if imgName == nil || imgName == "" {
return []Finding{{
Severity: Error,
+13 -10
View File
@@ -24,25 +24,28 @@ type Finding struct {
}
func (f Finding) String() string {
loc := ""
var loc string
if f.File != "" {
if f.Line > 0 {
loc = fmt.Sprintf(" (%s:%d)", f.File, f.Line)
loc = fmt.Sprintf("%s:%d: ", f.File, f.Line)
} else {
loc = fmt.Sprintf(" (%s)", f.File)
loc = fmt.Sprintf("%s: ", f.File)
}
}
ruleStr := ""
rule := ""
if f.Rule != "" {
ruleStr = " " + f.Rule
rule = f.Rule + " "
}
sev := "[" + strings.ToLower(string(f.Severity)) + "]"
msg := f.Message
if f.Job != "" {
return fmt.Sprintf("[%s] job %q%s%s: %s", f.Severity, f.Job, loc, ruleStr, f.Message)
msg = fmt.Sprintf("job %q: %s", f.Job, f.Message)
}
if loc != "" || ruleStr != "" {
return fmt.Sprintf("[%s]%s%s: %s", f.Severity, loc, ruleStr, f.Message)
}
return fmt.Sprintf("[%s] %s", f.Severity, f.Message)
return fmt.Sprintf("%s%s%s %s", loc, rule, sev, msg)
}
// Lint runs all rules against p and returns findings sorted by job name.
+4 -4
View File
@@ -14,7 +14,7 @@ import (
// pipeline that is valid on GitLab) produces no Error findings.
// These files exercise local include resolution and multi-level extends chains.
func TestSambaCI(t *testing.T) {
entryPoint := "../../samba-testdata/.gitlab-ci.yml"
entryPoint := "../../testdata/samba/.gitlab-ci.yml"
p, err := model.Parse(entryPoint)
if err != nil {
@@ -51,9 +51,9 @@ func TestSambaCIEntryFiles(t *testing.T) {
name string
path string
}{
{"default", "../../samba-testdata/.gitlab-ci.yml"},
{"coverage", "../../samba-testdata/.gitlab-ci-coverage.yml"},
{"private", "../../samba-testdata/.gitlab-ci-private.yml"},
{"default", "../../testdata/samba/.gitlab-ci.yml"},
{"coverage", "../../testdata/samba/.gitlab-ci-coverage.yml"},
{"private", "../../testdata/samba/.gitlab-ci-private.yml"},
}
for _, tc := range entryPoints {
+62
View File
@@ -24,6 +24,8 @@ func Parse(path string) (*Pipeline, error) {
// ParseBytes parses YAML from an in-memory byte slice.
func ParseBytes(data []byte) (*Pipeline, error) {
data = sanitizeYAMLEscapes(data)
// First pass: parse into a yaml.Node document to extract job keys with
// their exact source line numbers (key nodes carry the line, value nodes
// carry the body we decode into Job / map[string]any).
@@ -75,3 +77,63 @@ func ParseBytes(data []byte) (*Pipeline, error) {
return p, nil
}
// sanitizeYAMLEscapes rewrites double-quoted YAML strings, replacing the \/
// escape sequence (unrecognised by gopkg.in/yaml.v3) with \\/ so that the
// parser produces a literal backslash+slash — preserving regex patterns like
// /^us\// that appear in GitLab CI if: expressions.
func sanitizeYAMLEscapes(data []byte) []byte {
type state int
const (
stOutside state = iota
stSingleQ
stDoubleQ
stEscape
)
out := make([]byte, 0, len(data))
s := stOutside
for i := 0; i < len(data); i++ {
b := data[i]
switch s {
case stOutside:
out = append(out, b)
switch b {
case '"':
s = stDoubleQ
case '\'':
s = stSingleQ
}
case stSingleQ:
out = append(out, b)
if b == '\'' {
if i+1 < len(data) && data[i+1] == '\'' {
// '' inside a single-quoted string is an escaped single-quote
out = append(out, data[i+1])
i++
} else {
s = stOutside
}
}
case stDoubleQ:
out = append(out, b)
switch b {
case '\\':
s = stEscape
case '"':
s = stOutside
}
case stEscape:
if b == '/' {
// \/ is not recognised by yaml.v3; rewrite as \\/ which
// the parser resolves to a literal backslash + slash.
out = append(out, '\\', '/')
} else {
out = append(out, b)
}
s = stDoubleQ
}
}
return out
}
+15 -2
View File
@@ -326,8 +326,9 @@ func includeFiles(entry map[string]any) []string {
return nil
}
// mergeIncluded copies jobs and stages from src into dst.
// dst (the main pipeline) always wins when a key already exists.
// mergeIncluded copies jobs, stages, and variables from src into dst.
// dst (the main pipeline) always wins when a key already exists — this matches
// GitLab's precedence rule where root-pipeline values override included templates.
func mergeIncluded(dst, src *model.Pipeline) {
stageSet := make(map[string]bool, len(dst.Stages))
for _, s := range dst.Stages {
@@ -348,4 +349,16 @@ func mergeIncluded(dst, src *model.Pipeline) {
}
}
}
// Merge pipeline-level variables: dst wins on conflict (root overrides includes).
if len(src.Variables) > 0 {
if dst.Variables == nil {
dst.Variables = make(map[string]any, len(src.Variables))
}
for k, v := range src.Variables {
if _, exists := dst.Variables[k]; !exists {
dst.Variables[k] = v
}
}
}
}
+25
View File
@@ -0,0 +1,25 @@
---
# variable_refs_included.yml
# GL032 must NOT fire for variables declared in an included file.
# The included file (variable_refs_included_template.yml) declares TEMPLATE_VAR.
# Expected: exits 0 (no errors; no GL032 warnings).
stages:
- build
include:
- local: /variable_refs_included_template.yml
build:
stage: build
script: make build
rules:
# TEMPLATE_VAR comes from the included file — GL032 must not fire.
- if: '$TEMPLATE_VAR == "enabled"'
when: on_success
# ROOT_VAR is declared in this file — GL032 must not fire.
- if: '$ROOT_VAR == "yes"'
when: manual
variables:
ROOT_VAR: "yes"
+6
View File
@@ -0,0 +1,6 @@
---
# Included by variable_refs_included.yml — declares a pipeline-level variable
# that the parent pipeline's jobs reference in rules:if: expressions.
variables:
TEMPLATE_VAR: "enabled"
+57
View File
@@ -0,0 +1,57 @@
---
# workflow_vars.yml
# Exercises workflow:rules:variables: injection.
# The matching workflow rule sets DEPLOY_TARGET; job rules use it.
#
# Expected behaviour per context:
# (no context) → all jobs active (no context evaluation)
# --branch main → deploy-prod active, deploy-staging skipped
# --branch develop → deploy-prod skipped, deploy-staging active
# --branch feat/x → deploy-prod skipped, deploy-staging skipped (manual)
stages:
- build
- deploy
variables:
DEPLOY_TARGET:
value: ""
description: "Deployment target — set by workflow rules"
workflow:
rules:
- if: "
$WORKFLOW = 'gitflow' &&
$CI_PIPELINE_SOURCE == /(push|web)/ &&
$CI_COMMIT_BRANCH =~ /^us\//
"
variables:
DEPLOY_TARGET: production
BUILD: true
TEST: true
- if: '$CI_COMMIT_BRANCH == "develop"'
variables:
DEPLOY_TARGET: staging
- when: always
build:
stage: build
script: make build
rules:
- when: always
deploy-prod:
stage: deploy
script: make deploy ENV=production
rules:
- if: '$DEPLOY_TARGET == "production"'
when: on_success
- when: never
deploy-staging:
stage: deploy
script: make deploy ENV=staging
rules:
- if: '$DEPLOY_TARGET == "staging"'
when: on_success
- when: never