19 Commits

Author SHA1 Message Date
k3nny 54b5850835 feat(linter): GL033 static dead-rules detection
ci / vet, staticcheck, test, build (push) Successful in 2m12s
release / Build and publish release (push) Successful in 1m7s
Add rule GL033 that warns when every rule in a job's rules: block has
an explicit when: never, making the job permanently excluded from any
pipeline run. This is a pure static check — no if: evaluation or context
required. Only rules with literal when: never trigger it; rules with no
when: (defaults to on_success), when: manual, when: always, or
when: on_failure are treated as reachable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 09:13:19 +02:00
k3nny 5fee51ec7d fix(cli): consistent output format, sorted findings, version flag
ci / vet, staticcheck, test, build (push) Successful in 2m9s
release / Build and publish release (push) Successful in 1m13s
- Workflow rules now use strict if: evaluation (parse failure → skip rule,
  not match); fixes premature matching that blocked later rules and injected
  wrong variables into the context
- Single = accepted as alias for == in rules:if: expressions
- File/Line preserved through extends: resolution (lost during YAML
  encode/decode round-trip in the resolver)
- Findings sorted by (File, Line, Rule) so same-file issues group together
- All warnings use ruff-style path: [warning] message format (includes,
  extends chains, workflow non-start)
- Add --version / -v flag; version shown at top of every --help output
- Build injects version via ldflags using git describe

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 00:13:51 +02:00
k3nny cbed44b1e9 feat(cli): variable expansion, scalar bool/int support, precedence fix
ci / vet, staticcheck, test, build (push) Successful in 2m15s
release / Build and publish release (push) Successful in 1m13s
- Add $VAR / ${VAR} expansion in effective context (ctx.ExpandVars):
  iterates up to 10 passes to resolve transitive chains; circular
  references are left as-is after the limit.
- Handle non-string YAML scalars (bool, int, float64) in
  ExtractStringVars and varValueString via new ScalarString helper;
  values like BUILD: true no longer render as "(complex)" or get
  silently dropped from the effective context.
- Variable precedence (GitLab spec): pipeline defaults < workflow-rule
  vars < CLI --var flags; implemented correctly in enrichContext;
  expansion applied after all sources are merged.
- Update README, CHANGELOG, ROADMAP for v0.2.13.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 12:32:47 +02:00
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
k3nny 18c8fc82c9 feat(linter): add GL032 variable reference validation in rules:if:
release / Build and publish release (push) Successful in 1m11s
Warn when a $VAR or ${VAR} reference in a rules:if: expression is not
declared in pipeline variables:, the job's own variables:, or any
workflow:rules:variables: block. Predefined GitLab CI namespaces (CI_*,
GITLAB_*, FF_*, RUNNER_*, TRIGGER_*, CHAT_*) are always exempt.

Each undeclared variable is reported at most once per job. The finding
is a WARNING (not an error) because variables may also be set in GitLab
CI/CD project settings, which are invisible to glint at lint time.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 23:13:25 +02:00
k3nny f48bf02152 feat(linter): add structured rule IDs (GL001–GL031)
Every Finding now carries a stable Rule string field with a GL### code.
The ID appears in output between the source location and the message:

  [ERROR] job "deploy" (ci.yml:14) GL003: missing required field 'script'
  [WARNING] (ci.yml) GL001: no stages defined

Rules:
  GL001 no-stages          GL002 workflow-when       GL003 missing-script
  GL004 unknown-stage      GL005 only-rules-conflict GL006 except-rules-conflict
  GL007 deprecated-only    GL008 invalid-when        GL009 delayed-no-start-in
  GL010 start-in-no-delayed GL011 invalid-parallel   GL012 invalid-retry
  GL013 invalid-retry-when GL014 invalid-allow-failure GL015 invalid-interruptible
  GL016 trigger-with-script GL017 invalid-trigger    GL018 invalid-coverage
  GL019 invalid-release    GL020 invalid-environment GL021 invalid-artifacts
  GL022 pages-public       GL023 invalid-cache       GL024 invalid-rules-when
  GL025 invalid-image      GL026 invalid-inherit     GL027 needs-unknown
  GL028 needs-stage-order  GL029 needs-cycle         GL030 unknown-dependency
  GL031 dependency-stage

Changes:
- internal/linter/rules.go: new file with all 31 constants + doc comments
- linter.Finding: add Rule string field; String() inserts it before the
  message colon when non-empty; format unchanged when Rule == ""
- All Finding{} literals in linter.go, keywords.go, needs.go,
  dependencies.go updated with the correct Rule: constant
- README.md lint rules table: new ID column added to all four sections
- CHANGELOG.md: entry in [Unreleased]

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 22:56:24 +02:00
k3nny 6d0aefca5b docs(docs): update ROADMAP with post-v0.2.0 shipped items
Mark as done:
- include: remote: URL fetching
- workflow:rules:variables: propagation
- Expression evaluator: multi-line, \${VAR}, regex flags, variable regex
  RHS, bare true/false/integer literals
- File and line numbers on findings
- needs: optional: true downgraded to warning
- extends: missing script downgraded to warning
- glint graph includes jobs-per-file

Add new remaining items:
- rules:if: static reachability analysis (future)
- Findings quality section broken out from lint coverage

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 22:38:45 +02:00
k3nny de6a526560 feat(linter): propagate workflow:rules:variables: into job rule evaluation
workflow:rules: can define variables: on matching rules (GitLab CI 15.0+).
These variables are now injected into the evaluation context before job
rules:if: expressions are evaluated, making patterns like:

  workflow:
    rules:
      - if: '$CI_COMMIT_BRANCH == "main"'
        variables:
          DEPLOY_TARGET: production
  deploy:
    rules:
      - if: '$DEPLOY_TARGET == "production"'

work correctly with glint check --branch main.

Changes:
- model.Rule: add Variables map[string]any field (yaml:"variables")
- cicontext.Context: add pinned map tracking which vars must not be
  overwritten; New() pins all shortcut and --var variables; add
  Inject(key, value) which writes only when key is not pinned
- cicontext.ExtractStringVars: shared helper that converts map[string]any
  variable blocks (plain string or {value:...} form) to map[string]string
- cicontext.EvalWorkflow: returns (bool, map[string]string) — the vars of
  the matching workflow rule alongside the runs/no-runs result
- cmd/glint/main.go: enrichContext() injects pipeline-level variable
  defaults then workflow-rule variables before printContext; applied in
  both cmdCheck and cmdGraph

Injection priority (highest wins):
  --var CLI overrides > --branch/--tag/--source shortcuts
  > workflow-rule variables > pipeline variables: defaults

Adds 15 unit tests (TestEvalWorkflow, TestContextInject,
TestExtractStringVars, TestWorkflowVarsJobEval) and a testdata fixture
(workflow_vars.yml) validated across four branch contexts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 22:35:55 +02:00
k3nny a0e2582cf1 fix(linter): support bare true/false and integer literals in rules:if:
release / Build and publish release (push) Successful in 1m15s
GitLab CI expressions allow unquoted true, false, and integers as
comparison operands (all treated as their string representations):

  $GATEWAY_ENABLED == true    (equivalent to == "true")
  $FEATURE_FLAG == false      (equivalent to == "false")
  $PARALLEL == 4              (equivalent to == "4")
  $ENABLED == 1 / == 0

Previously these fell through to permissive true because parseValue
only recognised $VAR, "${VAR}", quoted strings, and null. Added:
  - true/false keyword branch → returns "true"/"false"
  - integer literal branch (digits only) → returns decimal string

All three new forms are correctly excluded from longer identifier
prefixes (identByte boundary check). Adds 8 new unit tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 22:20:59 +02:00
k3nny e931b9d1c9 fix(linter): improve rules:if: expression evaluator
Four correctness fixes to the GitLab CI expression parser in
internal/cicontext/eval.go:

- Multi-line: \n and \r are now treated as whitespace in skipWS so
  block-scalar or folded-scalar if: values with || / && on continuation
  lines evaluate correctly instead of falling back to permissive true.
- ${VAR} curly-brace variable syntax now supported in parseValue.
- Regex flags (/pattern/i, /pattern/m, /pattern/s) are now consumed and
  translated to Go (?i)/(?m)/(?s) prefixes via applyRegexFlags.
- Variable on RHS of =~ / !~: when the right operand is $VAR, the
  variable's value is interpreted as a /regex/[flags] string via
  extractRegexFromString; non-regex values fall back to permissive true.

Adds 16 new unit tests covering all four cases and a testdata fixture
(rules_if_expr.yml) exercising multi-line, ${VAR}, and /pattern/i in a
real pipeline with context flags.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 22:08:08 +02:00
k3nny b21a7d60dc feat(resolver,graph): fetch and resolve include: remote: HTTPS URLs
release / Build and publish release (push) Successful in 1m14s
Remote includes (include: remote: https://...) were previously skipped
silently in the resolver and rendered as unexpanded leaf nodes in the
graph.

Changes:
- fetcher.FetchURL: new shared unauthenticated HTTP GET helper
- resolver: resolveRemoteInclude fetches the URL, parses YAML, sets job
  origin to the URL string, recursively resolves sub-includes, and emits
  a warning on failure (lint continues on the rest of the pipeline)
- graph: recurseRemote fetches the URL, captures direct job names, and
  recurses into sub-includes so remote nodes expand like local ones

Adds testdata/includes_remote.yml fixture.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 21:42:15 +02:00
k3nny d34c39927d fix(linter): downgrade needs optional:true missing-job to warning
release / Build and publish release (push) Successful in 1m12s
parseNeedJobNames is replaced by parseNeedEntries which preserves the
optional flag from each needs: entry. When a referenced job does not
exist and optional:true is set, the finding is now WARNING instead of
ERROR, matching GitLab CI runtime behavior (the dependency is silently
skipped when the job is absent from a conditional include).

Optional missing deps are also excluded from the cycle-detection graph
since there is no real dependency edge to trace.

Adds a fixture case in testdata/needs.yml to prevent regression.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 21:27:16 +02:00
k3nny a303f63a5e feat(linter): add file/line to findings; downgrade extends missing-script to warning
Every finding now carries the source file and exact line number of the job
key in its YAML file. Format: [ERROR] job "name" (file.yml:12): message.

Pipeline-level findings (workflow rules, no stages) reference p.SourceFile.
Cross-file include jobs (local, project, component) carry the include source
as their File, set via Pipeline.SetJobOrigin after each ParseBytes call in
the resolver.

Line numbers come from the yaml.Node key node (exact job-name line) in a
new document-level first pass in ParseBytes, replacing the previous
map[string]yaml.Node approach which only gave value-node lines.

Also: jobs that declare extends: but have no script after resolution now
emit WARNING instead of ERROR. The script may come from a base in a remote
include that was not fetched (no token, offline), making the error a false
positive in common project setups.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 21:24:18 +02:00
k3nny a962c996c1 feat(graph): show jobs per file in include dependency graph
Each node in 'glint graph includes' now lists the jobs defined directly
in that file. Jobs appear as rounded Mermaid nodes with a distinct
light-purple style, connected with dashed arrows (-.->). This visual
distinction separates ownership (file -.-> job) from the include
hierarchy (file --> included-file).

The root file's jobs are collected by re-parsing it without include
resolution; local and fetched project/component nodes populate their
job list in the existing recurse* methods.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 21:03:50 +02:00
k3nny e5f926b55f docs: 📝 add logo
release / Build and publish release (push) Successful in 1m47s
2026-06-11 20:40:39 +02:00
k3nny 8c3ce050f5 Merge pull request 'fix(model): handle YAML map forms that caused unmarshall errors' (#3) from fix/unmarshall_errors into main
Reviewed-on: #3
2026-06-11 20:31:30 +02:00
k3nny c4ab64391d fix(model): handle YAML map forms that caused unmarshall errors
Variables with value/description/options sub-keys, default.image in map
form, default.before_script / default.after_script as block scalars, and
rules.changes / rules.exists in {paths, compare_to} map form all caused
"yaml: cannot unmarshal !!map into string" because the struct fields were
typed too narrowly.

Changed types in model.Pipeline, model.DefaultConfig, and model.Rule to
accept any to match GitLab CI spec flexibility (13.7+ variable declarations,
15.3+ rules.changes map form, image map form in default block).

Adds testdata/script_multiline.yml covering all these patterns.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 20:25:53 +02:00
48 changed files with 2623 additions and 191 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/...
+78 -1
View File
@@ -5,7 +5,84 @@ 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.15] - 2026-06-13
### Added
- **Static reachability check (GL033)** — warns when every rule in a job's `rules:` block has an explicit `when: never`, making the job permanently excluded from any pipeline run. This is a purely static claim: no matter which `if:` condition evaluates to true, the outcome is always "skip"; and if no rule matches, the implicit fallback is also skip. No expression evaluation or context is required. The finding is a `WARNING` (may be intentional as a "disabled job" pattern). Only jobs where every rule has the literal `when: never` value are flagged; rules with no `when:` (default `on_success`), `when: manual`, `when: always`, or `when: on_failure` are not.
## [0.2.14] - 2026-06-13
### Added
- **`--version` / `-v` flag** — `glint --version`, `glint -v`, and `glint version` all print the compiled version string (e.g. `glint v0.2.14`). The version is also shown at the top of every `--help` output (global, `check --help`, `graph --help`). The version is injected at build time via `-ldflags "-X main.version=..."` using `git describe --tags --always --dirty`.
- **Sorted findings output** — `Lint` now returns findings sorted by `(File, Line, Rule)`. All issues from the same source file appear together in ascending line order; pipeline-level findings with no file location sort first. Previously findings were emitted in map-iteration order (non-deterministic).
### Fixed
- **Warning format consistency** — include-resolution warnings, extends-chain warnings, and the workflow non-start warning now use the same ruff-style `path: [warning] message` format as lint findings instead of the old `[WARNING] …` prefix with no file context.
- **Workflow rule permissive evaluation** — workflow `rules:if:` expressions are now evaluated in strict mode: an expression that cannot be fully parsed returns `false` (skip this rule, try the next) instead of `true` (match everything). Previously, a complex or partially-unsupported condition on the first workflow rule would match every context, blocking all subsequent rules and injecting the wrong variables. Job rules retain permissive evaluation (`true` on parse failure) to avoid silently dropping jobs.
- **Single `=` operator in `rules:if:`** — a bare `=` not followed by `=` or `~` is now accepted as an alias for `==`. This is a common mistake in GitLab CI YAML; previously it caused a parse failure and triggered the permissive fallback.
- **Source location lost through `extends:` resolution** — when a job was resolved via `extends:`, the merged definition was re-encoded and re-decoded as a fresh `model.Job` struct, which does not carry `File` or `Line` (they are not YAML keys). Those fields are now explicitly copied back from the original job before replacing it in `p.Jobs`, so extended jobs report the correct source file and line number in findings.
## [0.2.13] - 2026-06-12
### Added
- **Variable expansion (`$VAR` / `${VAR}`)** — variable values that reference other variables are now expanded in the effective context after all sources are merged. Transitive chains (`A=$B`, `B=$C`) are resolved over up to ten passes; circular references are left as-is. The expanded values are visible in `--list-vars` output under "Effective context variables" and are used when evaluating `rules:if:` expressions.
- **Non-string scalar variables (`bool`, `int`, `float64`)** — variables declared with bare `true`/`false` or integer values (e.g. `BUILD: true`, `RETRIES: 3`) are now handled correctly in all variable processing paths. Previously they were rendered as `(complex)` in `--list-vars` output and silently dropped from the effective context; they now render and inject as their string equivalents (`"true"`, `"3"`), matching GitLab CI's own behaviour where all variable values are strings.
### Fixed
- **YAML `\/` escape in double-quoted strings** — regex patterns containing `\/` (escaped forward-slash) in double-quoted `if:` expressions (e.g. `$CI_COMMIT_BRANCH =~ /^us\//`) caused a YAML parse error (`found unknown escape character`) with `gopkg.in/yaml.v3`, which does not implement this YAML 1.2 escape. The parser now preprocesses the raw bytes before unmarshalling: inside double-quoted strings, `\/` is rewritten to `\\/`, which `yaml.v3` parses as a literal backslash followed by a slash — preserving the regex intent.
## [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 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.
- **`rules:if:` expression evaluator improvements** — six correctness fixes to the GitLab CI expression parser:
- **Multi-line expressions** — newlines (`\n`, `\r`) are now treated as whitespace between tokens, so block-scalar `if:` values (e.g. `if: | ...`) and folded YAML scalars with `||`/`&&` on a continuation line are parsed correctly instead of falling back to permissive `true`.
- **`${VAR}` curly-brace variable syntax** — `${CI_COMMIT_BRANCH}` is now equivalent to `$CI_COMMIT_BRANCH` everywhere a value is expected.
- **Regex flags** — `/pattern/i`, `/pattern/m`, `/pattern/s` are now honoured; the `i` flag (case-insensitive) is translated to Go's `(?i)` prefix before compiling. Unknown flags are silently ignored.
- **Variable as regex RHS** — `$BRANCH =~ $PATTERN` where `$PATTERN` holds a `/regex/[flags]` string is now evaluated by extracting and compiling the pattern from the variable's value; if the value is empty or does not look like a regex literal the expression falls back to permissive `true`.
- **`true` / `false` keywords** — bare `true` and `false` (without quotes) are now recognised as the string values `"true"` and `"false"`, matching GitLab CI's own behaviour. `$GATEWAY_ENABLED == true` and `$FEATURE_FLAG == false` now evaluate correctly.
- **Integer literals** — bare integers (e.g. `$PARALLEL == 4`, `$ENABLED == 1`, `$DISABLED == 0`) are now parsed as their decimal string representations and compared accordingly.
- **File and line numbers on findings** — every finding now includes the source file and line where the job is defined, e.g. `[ERROR] job "deploy" (src/deploy.yml:14): …`. For jobs that come from local or fetched includes the file reflects the include source. Pipeline-level findings (workflow rules, missing stages) reference the root pipeline file.
- **`glint graph includes` shows jobs per file** — each node in the Mermaid include dependency graph now shows the jobs defined directly in that file. Jobs are rendered as rounded nodes (`(name)`) in a distinct light-purple style, connected with dashed arrows (`-.->`) to distinguish ownership from the include hierarchy (solid `-->` arrows). The root pipeline file always shows its direct jobs; local and fetched project/component nodes show theirs when the file can be read.
### Fixed
- **`include: remote:` URL includes are now fetched and merged** — glint fetches plain HTTPS URLs in `include: remote:` entries (no authentication), parses the resulting YAML, merges its jobs into the pipeline, and recursively resolves any sub-includes the remote file itself declares. Unreachable or unparseable URLs emit a `[WARNING]` and lint continues on the rest of the pipeline. The `glint graph includes` command now expands remote nodes with their jobs and sub-include tree, matching the behaviour of local and project includes.
- **`needs: optional: true` downgraded to warning** — a `needs:` entry that carries `optional: true` and references a job not present in the pipeline now emits `[WARNING]` instead of `[ERROR]`. GitLab CI silently skips such dependencies at runtime (the job is absent when its include was not triggered), so the finding was a false positive. Non-optional missing needs remain errors. Optional missing deps are also excluded from the cycle-detection graph.
- **`extends:` jobs with missing script downgraded to warning** — a job that declares `extends:` but has no `script` after resolution now emits `[WARNING]` instead of `[ERROR]`. The script may legitimately come from a base job in a remote include that could not be fetched at lint time (e.g. no token configured).
- **Variable map form now parses correctly** — `variables:` entries that use the extended `{value, description, options}` form (GitLab CI 13.7+) no longer cause `yaml: cannot unmarshal !!map into string`. Both `Pipeline.Variables` and per-job `Variables` now accept either plain strings or map-form declarations.
- **`default.image` map form now parses correctly** — `default: image: {name: ..., pull_policy: ...}` used to cause `yaml: cannot unmarshal !!map into string`; `DefaultConfig.Image` is now typed as `any` to match `Job.Image`.
- **`default.before_script` / `default.after_script` now accept both list and scalar forms** — previously `DefaultConfig.BeforeScript` and `DefaultConfig.AfterScript` were `[]string`, causing a parse error when the field was written as a block scalar string. They are now typed as `any` to match the corresponding `Job` fields.
- **`rules.changes` / `rules.exists` map form now parses correctly** — extended `changes: {paths: [...], compare_to: "..."}` syntax (GitLab CI 15.3+) used to cause `yaml: cannot unmarshal !!map into []string`.
## [0.2.0] - 2026-06-11
+76 -56
View File
@@ -1,7 +1,13 @@
# glint
<p align="center">
<img src="assets/glint-logo.png" alt="glint logo" width="220" />
</p>
[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE)
[![Release](https://img.shields.io/badge/release-v0.2.0-blue.svg)](CHANGELOG.md)
<h1 align="center">glint</h1>
<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.15-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.
@@ -19,8 +25,15 @@ A local tool to validate and lint `.gitlab-ci.yml` pipelines without needing a G
- **CI/CD catalog components** — resolves `include: component:` references from the GitLab CI/CD Catalog; public components work without a token
- **Deprecation warnings** — flags `only`/`except` usage in favour of `rules`
- **Local include resolution** — `include: local:` entries are read from disk and recursively merged before linting, so multi-file pipelines are fully validated
- **Extended variable declarations** — `variables:` entries may use the `{value, description, options}` map form (GitLab CI 13.7+); `default.image` accepts both string and map form; `rules.changes`/`rules.exists` accept both list and `{paths, compare_to}` map form
- **Graph output** — `glint graph` prints a job tree (stages → jobs) to the terminal; `glint graph includes` emits a Mermaid include dependency diagram; `glint graph pipeline` renders a GitLab CI-style PNG/SVG
- **Context simulation** — pass `--branch`, `--tag`, or `--source` to `glint check` or `glint graph` to see which jobs would be active, manual, or skipped for a specific pipeline event; evaluates `rules:if:` expressions and `only`/`except` filters
- **Variable expansion** — `$VAR` and `${VAR}` references inside variable values are expanded after all sources are merged (pipeline defaults → workflow-rule overrides → CLI flags); transitive chains resolve automatically; visible via `--list-vars`
- **Non-string variable scalars** — `BUILD: true`, `RETRIES: 3` and other bare boolean/integer variable values are handled correctly throughout: they render in `--list-vars` output and are injected into the evaluation context as their string equivalents, matching GitLab CI's behaviour
- **Static reachability (GL033)** — warns when a job's `rules:` block can never activate: if every rule has `when: never` the job is permanently excluded from any pipeline run, provable without evaluating any `if:` expressions
- **Sorted findings output** — findings are sorted by source file then line number, so all issues from the same file appear together in order; pipeline-level findings (no file) sort first
- **Consistent ruff-style warnings** — all warnings (unresolvable includes, skipped extends chains, workflow non-start) use the same `path: [warning] message` format as lint findings
- **`--version` / `-v` flag** — prints the compiled version string (e.g. `glint v0.2.14`); the version is also shown at the top of every `--help` output
See [ROADMAP.md](ROADMAP.md) for planned improvements.
@@ -193,6 +206,7 @@ glint check --branch main --var DEPLOY_ENV=production .gitlab-ci.yml
**Evaluated:**
- `rules:if:` — full expression language: `==`, `!=`, `=~`, `!~`, `&&`, `||`, `!`, `()`, `$VAR`, string literals, `null`
- `only:` / `except:` — ref keywords (`branches`, `tags`, `merge_requests`, `schedules`, …), branch name globs (`feat/*`), and `/regex/` patterns
- Variable expansion — `$VAR` / `${VAR}` references within variable values are expanded after all sources are merged; use `--list-vars` to inspect the resolved values
**Not evaluated** (no git tree at lint time): `rules:changes:`, `rules:exists:`.
Rules without an `if:` clause always match.
@@ -209,8 +223,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
@@ -218,7 +236,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
@@ -226,75 +244,77 @@ 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)
```
## Lint rules
Every finding includes a stable rule ID (e.g. `GL003`) that can be used to filter output or reference a specific check in documentation.
### Pipeline-level
| Severity | Rule |
|----------|------|
| ERROR | `workflow.rules[*].when` is not `always` or `never` |
| WARNING | No `stages` defined (GitLab falls back to default stages) |
| ID | Severity | Rule |
|----|----------|------|
| GL002 | ERROR | `workflow.rules[*].when` is not `always` or `never` |
| GL001 | WARNING | No `stages` defined (GitLab falls back to default stages) |
### Job-level — structure
| Severity | Rule |
|----------|------|
| ERROR | Job is missing required `script` (or `run`) — non-trigger, non-template jobs |
| ERROR | Job references a `stage` not declared in `stages` |
| ERROR | `only` and `rules` used together on the same job |
| ERROR | `except` and `rules` used together on the same job |
| WARNING | `only`/`except` used (deprecated, prefer `rules`) |
| ID | Severity | Rule |
|----|----------|------|
| GL003 | ERROR | Job is missing required `script` (or `run`) — non-trigger, non-template jobs |
| GL004 | ERROR | Job references a `stage` not declared in `stages` |
| GL005 | ERROR | `only` and `rules` used together on the same job |
| GL006 | ERROR | `except` and `rules` used together on the same job |
| GL007 | WARNING | `only`/`except` used (deprecated, prefer `rules`) |
### Job-level — keyword constraints
| Severity | Rule |
|----------|------|
| ERROR | `when` is not one of `on_success`, `on_failure`, `always`, `manual`, `delayed`, `never` |
| ERROR | `when: delayed` without `start_in` |
| ERROR | `start_in` set but `when` is not `delayed` |
| ERROR | `parallel` integer not in range 2200 |
| ERROR | `parallel` map form missing `matrix` key |
| ERROR | `retry` integer not in range 02 |
| ERROR | `retry.max` not in range 02 |
| ERROR | `retry.when` contains an invalid failure type |
| ERROR | `allow_failure` is not a boolean or a map with `exit_codes` |
| ERROR | `interruptible` is not a boolean |
| ERROR | `trigger` job also has `script` |
| ERROR | `trigger` map missing `project` or `include` |
| ERROR | `coverage` is not a regex pattern wrapped in `/` |
| ERROR | `release` missing required `tag_name` |
| ERROR | `environment.url` set without `environment.name` |
| ERROR | `environment.action` is not one of `start`, `stop`, `prepare`, `verify`, `access` |
| ERROR | `artifacts.when` is not `on_success`, `on_failure`, or `always` |
| ERROR | `artifacts.expose_as` set without `artifacts.paths` |
| ERROR | `cache.when` is not `on_success`, `on_failure`, or `always` |
| ERROR | `cache.policy` is not `pull`, `push`, or `pull-push` |
| ERROR | `rules[*].when` is not one of the valid `when` values |
| ERROR | `image` map form missing `name` key |
| ERROR | `inherit.default` / `inherit.variables` is not a boolean or list |
| WARNING | `pages` job `artifacts.paths` does not include `public` |
| ID | Severity | Rule |
|----|----------|------|
| GL008 | ERROR | `when` is not one of `on_success`, `on_failure`, `always`, `manual`, `delayed`, `never` |
| GL009 | ERROR | `when: delayed` without `start_in` |
| GL010 | ERROR | `start_in` set but `when` is not `delayed` |
| GL011 | ERROR | `parallel` integer not in range 2200, or map form missing `matrix` key |
| GL012 | ERROR | `retry` integer not in range 02, or `retry.max` out of range |
| GL013 | ERROR | `retry.when` contains an unrecognised failure type |
| GL014 | ERROR | `allow_failure` is not a boolean or a map with `exit_codes` |
| GL015 | ERROR | `interruptible` is not a boolean |
| GL016 | ERROR | `trigger` job also has `script` |
| GL017 | ERROR | `trigger` map missing `project` or `include` |
| GL018 | ERROR | `coverage` is not a regex pattern wrapped in `/` |
| GL019 | ERROR | `release` missing required `tag_name`, or is not a map |
| GL020 | ERROR | `environment.url` set without `environment.name`, or invalid `environment.action` |
| GL021 | ERROR | `artifacts.when` invalid, or `artifacts.expose_as` set without `artifacts.paths` |
| GL022 | WARNING | `pages` job `artifacts.paths` does not include `public` |
| GL023 | ERROR | `cache.when` or `cache.policy` has an invalid value |
| GL024 | ERROR | `rules[*].when` is not one of the valid `when` values |
| GL025 | ERROR | `image` map form missing `name` key |
| GL026 | ERROR | `inherit.default` / `inherit.variables` is not a boolean or list |
### Cross-job graph
| Severity | Rule |
|----------|------|
| ERROR | `needs:` references a job that does not exist |
| ERROR | `needs:` references a job in a later stage |
| ERROR | Circular dependency detected in `needs:` graph |
| ERROR | `dependencies:` references a job that does not exist |
| ERROR | `dependencies:` references a job in the same or a later stage |
| WARNING | `extends:` references an unknown base job (resolver warning; extends chain skipped for that job) |
| ERROR | Cycle detected in `extends:` graph |
| ID | Severity | Rule |
|----|----------|------|
| GL027 | ERROR/WARNING | `needs:` references a job that does not exist (WARNING when `optional: true`) |
| GL028 | ERROR | `needs:` references a job in a later stage |
| GL029 | ERROR | Circular dependency detected in `needs:` graph |
| GL030 | ERROR | `dependencies:` references a job that does not exist |
| GL031 | ERROR | `dependencies:` references a job in the same or a later stage |
### Expression validation
| ID | Severity | Rule |
|----|----------|------|
| GL032 | WARNING | `rules:if:` references `$VAR` not declared in `variables:` (pipeline, job, or `workflow:rules:variables:`) — may be a false positive for variables set in GitLab CI/CD project settings |
| GL033 | WARNING | Every rule in `rules:` has `when: never` — job is permanently excluded from the pipeline (statically provable without context) |
### Hidden jobs (templates)
+51 -6
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
## 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; variable expansion and scalar handling shipped v0.2.13; workflow evaluation and output fixes shipped v0.2.14
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.
@@ -16,6 +16,35 @@ glint check --source merge_request_event --var CI_MERGE_REQUEST_TARGET_BRANCH_NA
glint graph tree --branch main .gitlab-ci.yml # tree annotated with [skipped] / [manual]
```
**Shipped post-v0.2.0 (unreleased)**
-**`workflow:rules:variables:` propagation** — variables defined on the matching `workflow:rules:` entry are injected into the evaluation context before job `rules:if:` expressions are evaluated. Pipeline-level `variables:` defaults are also available. Priority chain (highest wins): `--var` > shortcuts > workflow-rule vars > pipeline defaults.
-**Expression evaluator: multi-line expressions** — newlines in block-scalar and folded YAML `if:` values are now treated as whitespace; `||` / `&&` on a continuation line evaluate correctly.
-**Expression evaluator: `${VAR}` curly-brace syntax**`${CI_COMMIT_BRANCH}` is equivalent to `$CI_COMMIT_BRANCH` everywhere.
-**Expression evaluator: regex flags**`/pattern/i`, `/pattern/m`, `/pattern/s` are now supported; `i` maps to `(?i)` in Go's regexp.
-**Expression evaluator: variable as regex RHS**`$BRANCH =~ $PATTERN` where `$PATTERN` holds a `/regex/` string is evaluated correctly.
-**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.
**Shipped in v0.2.13**
-**Variable expansion**`$VAR` / `${VAR}` references within variable values are expanded after all sources are merged; transitive chains resolve over multiple passes; visible in `--list-vars` effective-context output.
-**Non-string scalar variables**`BUILD: true`, `RETRIES: 3` and similar bare boolean/integer values now render correctly in `--list-vars` and are injected into the evaluation context as string equivalents; previously shown as `(complex)` and silently dropped.
-**YAML `\/` escape in double-quoted strings** — regex patterns like `/^us\//` in double-quoted `if:` blocks no longer cause a parse error; the raw bytes are preprocessed before YAML unmarshalling.
**Shipped in v0.2.14**
-**Workflow rule strict evaluation** — workflow `rules:if:` now uses strict mode (parse failure → skip rule, not match); fixes premature matching that blocked later rules and injected wrong variables.
-**Single `=` operator**`=` is now accepted as an alias for `==` in `rules:if:` expressions, matching common user intent.
-**Source location through `extends:` resolution**`File` and `Line` are now preserved when a job is rebuilt via extends, so findings reference the correct source location.
-**Sorted findings output** — findings are sorted by `(File, Line, Rule)`; same-file issues group together in line order.
-**Consistent warning format** — all warnings use ruff-style `path: [warning] message` format.
-**`--version` / `-v` flag** — prints compiled version; version also shown at the top of every `--help` output.
**Remaining work**
- **Multi-context simulation** — run multiple contexts in one invocation and print a comparison table:
@@ -23,7 +52,7 @@ glint graph tree --branch main .gitlab-ci.yml # tree annotated with [skipped]
glint check --context branch=main --context branch=develop --context tag=v1.0.0 .gitlab-ci.yml
```
- **Context-scoped linting** — skip `needs:`/`dependencies:` cross-checks for jobs that are statically unreachable in the given context
- **`rules:changes:` evaluation** — path glob evaluation against the local git tree (expression evaluator priority 5)
- **`rules:changes:` evaluation** — path glob evaluation against the local git tree
---
@@ -31,14 +60,15 @@ 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
- ~~**`rules:if:` static reachability (GL033)**~~ — ✓ shipped v0.2.15; warns when every rule in a job's `rules:` block has `when: never`, making the job permanently excluded from any pipeline run; no `if:` evaluation required
- **`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.)
- **`id_tokens:` / `secrets:`** — presence and required-key checks
- **`pages:publish`** — validate that the path is consistent with `artifacts.paths`
- **`inherit:` completeness** — flag when a job overrides a default field that would require `inherit: default: false` to suppress
- **Unreachable jobs** — detect jobs that can never run because every `rules:` branch evaluates to `never` (static analysis only, no variable expansion)
- **Unreachable jobs** — detect jobs that can never run because every `rules:` branch evaluates to `never` (static analysis only)
- **Duplicate stage names** — GitLab silently merges them; warn to avoid confusion
- **`cache:key:files`** — must be a list of paths, not a glob
@@ -47,7 +77,7 @@ The current rule set covers the most common sources of broken pipelines. These a
## Include resolution
- ~~**`include: local:`** full resolution~~ — ✓ shipped in v0.2.0; local files are read from disk, recursively resolved, and merged before linting
- **`include: remote:`** (URL) — fetch and merge plain HTTP/HTTPS URLs (no auth required)
- ~~**`include: remote:`** (URL)~~✓ shipped post-v0.2.0; plain HTTPS URLs are fetched (unauthenticated), parsed, and merged; sub-includes are resolved recursively; unreachable URLs emit `[WARNING]` and linting continues
- **Recursive include depth limit** — guard against include cycles across files
- **Offline mode / cache** — persist fetched remote templates to a local cache directory; `--offline` flag to skip network calls and use only cached copies
- **`include: inputs:`** — substitute CI component input values into fetched templates before merging, so component-scoped jobs get their correct `stage:` and keyword values
@@ -70,6 +100,7 @@ Right now the only output is plain-text findings. Structured output enables inte
The SVG renderer and terminal tree cover the basic layout. These would bring it closer to GitLab's full interactive view.
- ~~**Terminal job tree**~~ — ✓ shipped in v0.2.0 as `glint graph tree`; stages as branches, jobs as leaves, context-aware annotations
- ~~**`glint graph includes` shows jobs per file**~~ — ✓ shipped post-v0.2.0; each include node shows the jobs it defines as dashed-arrow rounded nodes in a distinct style
- **Multi-job connector accuracy** — draw one connector per job pair rather than one per stage pair in classic mode, so pipelines with uneven columns look correct
- **Job tooltip / detail panel** — embed a hidden `<title>` and `<desc>` per chip so SVG viewers show `stage`, `when`, `image`, and `needs` on hover
- **`when: on_failure` visual distinction** — dashed border or distinct icon for failure-path jobs
@@ -79,6 +110,20 @@ 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; ruff-style format shipped v0.2.11
~~**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**
- ~~**`needs: optional: true` false-positive errors**~~ — ✓ shipped post-v0.2.0; optional missing needs are downgraded to `[WARNING]`
- ~~**`extends:` jobs with missing script false errors**~~ — ✓ shipped post-v0.2.0; jobs using `extends:` that have no `script` after resolution emit `[WARNING]` (the script may come from an unfetchable remote base)
- **`rules:if:` static reachability** — report when a job's entire `rules:` block can never evaluate to `when: on_success` given the declared pipeline variables (pure static, no context required)
---
## CI / editor integration
- **GitLab CI template** — a `.gitlab-ci.yml` snippet that runs `glint` as a pipeline-validation job before the real pipeline executes; publishable to the GitLab CI/CD Catalog
@@ -102,7 +147,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`
+40 -7
View File
@@ -3,6 +3,8 @@ version: "3"
vars:
BINARY: glint
GO: /usr/local/go/bin/go
VERSION:
sh: git describe --tags --always --dirty 2>/dev/null || echo "dev"
tasks:
default:
@@ -12,7 +14,7 @@ tasks:
build:
desc: Build the glint binary
cmds:
- "{{.GO}} build -o {{.BINARY}} ./cmd/glint/..."
- "{{.GO}} build -ldflags \"-X main.version={{.VERSION}}\" -o {{.BINARY}} ./cmd/glint/..."
sources:
- "**/*.go"
- go.mod
@@ -41,10 +43,14 @@ tasks:
ignore_error: true
- cmd: ./{{.BINARY}} check testdata/keywords_invalid.yml
ignore_error: true
- cmd: ./{{.BINARY}} check testdata/includes_remote.yml
ignore_error: false
- cmd: ./{{.BINARY}} check testdata/includes_project.yml
ignore_error: false
- cmd: ./{{.BINARY}} check testdata/includes_component.yml
ignore_error: false
- cmd: ./{{.BINARY}} check testdata/script_multiline.yml
ignore_error: false
- cmd: ./{{.BINARY}} check testdata/context_rules.yml
ignore_error: false
- cmd: ./{{.BINARY}} check --branch main testdata/context_rules.yml
@@ -55,21 +61,48 @@ tasks:
ignore_error: false
- cmd: ./{{.BINARY}} check --tag v1.0.0 testdata/context_rules.yml
ignore_error: false
- cmd: ./{{.BINARY}} check samba-testdata/.gitlab-ci.yml
- cmd: ./{{.BINARY}} check testdata/rules_if_expr.yml
ignore_error: false
- cmd: ./{{.BINARY}} check samba-testdata/.gitlab-ci-coverage.yml
- cmd: ./{{.BINARY}} check --branch main testdata/rules_if_expr.yml
ignore_error: false
- cmd: ./{{.BINARY}} check samba-testdata/.gitlab-ci-private.yml
- cmd: ./{{.BINARY}} check --branch feat/x testdata/rules_if_expr.yml
ignore_error: false
- cmd: ./{{.BINARY}} check testdata/workflow_vars.yml
ignore_error: false
- cmd: ./{{.BINARY}} check --branch main testdata/workflow_vars.yml
ignore_error: false
- cmd: ./{{.BINARY}} check --branch develop testdata/workflow_vars.yml
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 testdata/variable_refs_included.yml
ignore_error: false
- cmd: ./{{.BINARY}} check testdata/dead_rules.yml
ignore_error: false
- cmd: ./{{.BINARY}} check testdata/samba/.gitlab-ci.yml
ignore_error: false
- 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
@@ -83,7 +116,7 @@ tasks:
- sh: git describe --tags --exact-match
msg: "Current commit is not tagged — Windows build requires a git tag"
cmds:
- "GOOS=windows GOARCH=amd64 {{.GO}} build -o {{.BINARY}}-{{.TAG}}.exe ./cmd/glint/..."
- "GOOS=windows GOARCH=amd64 {{.GO}} build -ldflags \"-X main.version={{.TAG}}\" -o {{.BINARY}}-{{.TAG}}.exe ./cmd/glint/..."
sources:
- "**/*.go"
- go.mod
@@ -99,7 +132,7 @@ tasks:
- sh: git describe --tags --exact-match
msg: "Current commit is not tagged — Linux build requires a git tag"
cmds:
- "GOOS=linux GOARCH=amd64 {{.GO}} build -o {{.BINARY}}-{{.TAG}}-linux-amd64 ./cmd/glint/..."
- "GOOS=linux GOARCH=amd64 {{.GO}} build -ldflags \"-X main.version={{.TAG}}\" -o {{.BINARY}}-{{.TAG}}-linux-amd64 ./cmd/glint/..."
sources:
- "**/*.go"
- go.mod
Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

+147 -6
View File
@@ -16,6 +16,9 @@ import (
"git.k3nny.fr/glint/internal/resolver"
)
// version is set at build time via -ldflags "-X main.version=vX.Y.Z".
var version = "dev"
const globalUsage = `glint: Lint and visualise GitLab CI pipelines locally.
Usage: glint [OPTIONS] <COMMAND>
@@ -26,6 +29,7 @@ Commands:
Options:
-h, --help Print help
-v, --version Print version
For help with a specific command, see: ` + "`glint <command> --help`" + `.
`
@@ -41,7 +45,10 @@ func main() {
case "graph":
cmdGraph(os.Args[2:])
case "-h", "--help", "help":
fmt.Fprintf(os.Stderr, "glint %s\n\n", version)
fmt.Fprint(os.Stderr, globalUsage)
case "-v", "--version", "version":
fmt.Printf("glint %s\n", version)
default:
fmt.Fprintf(os.Stderr, "glint: unknown command %q\n\n%s", os.Args[1], globalUsage)
os.Exit(2)
@@ -64,9 +71,11 @@ 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() {
fmt.Fprintf(os.Stderr, "glint %s\n\n", version)
fmt.Fprint(os.Stderr, `Lint a GitLab CI pipeline file.
Resolves local includes and extends chains, then runs all lint rules.
@@ -90,6 +99,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 +107,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 +138,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)
@@ -133,7 +161,7 @@ Examples:
rootDir := filepath.Dir(filepath.Clean(path))
warnings, _ := resolver.ResolveIncludes(p, cfg, rootDir)
for _, w := range warnings {
fmt.Fprintf(os.Stderr, "[WARNING] include %s\n", w)
fmt.Fprintf(os.Stderr, "%s: [warning] include %s\n", path, w)
}
extWarnings, err := resolver.Resolve(p)
@@ -142,10 +170,18 @@ Examples:
os.Exit(2)
}
for _, w := range extWarnings {
fmt.Fprintf(os.Stderr, "[WARNING] job %q extends unknown job %q; extends chain skipped\n", w.Job, w.Base)
fmt.Fprintf(os.Stderr, "%s: [warning] job %q extends unknown job %q; extends chain skipped\n", path, w.Job, w.Base)
}
ctx := cicontext.New(*branch, *tag, *source, vars)
if !ctx.IsEmpty() {
if !enrichContext(ctx, p) {
fmt.Fprintf(os.Stderr, "%s: [warning] workflow:rules: pipeline would not start for this context\n", path)
}
}
if *listVars {
printVars(p, ctx)
}
if !ctx.IsEmpty() {
printContext(p, ctx)
}
@@ -193,6 +229,7 @@ func cmdGraph(args []string) {
gitlabURL := fs.String("gitlab-url", "", "GitLab instance URL (overrides CI_SERVER_URL / GITLAB_URL)")
out := fs.String("out", "glint-out", "output directory for Mermaid graph files (pipeline mode)")
fs.Usage = func() {
fmt.Fprintf(os.Stderr, "glint %s\n\n", version)
fmt.Fprint(os.Stderr, `Visualise the pipeline as a job tree and/or Mermaid graph.
Usage: glint graph [MODE] [OPTIONS] <PIPELINE>
@@ -221,6 +258,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,
@@ -228,18 +266,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
@@ -249,10 +298,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)
@@ -272,6 +328,12 @@ Examples:
resolver.Resolve(p) //nolint:errcheck
ctx := cicontext.New(*branch, *tag, *source, vars)
if !ctx.IsEmpty() {
enrichContext(ctx, p)
}
if *listVars {
printVars(p, ctx)
}
switch mode {
case "default":
@@ -300,6 +362,85 @@ 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 {
if s, ok := cicontext.ScalarString(v); ok {
return s
}
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).
// Returns false when workflow:rules: would prevent the pipeline from starting.
func enrichContext(ctx *cicontext.Context, p *model.Pipeline) bool {
// Pipeline variables: injected as defaults (lowest priority).
for k, v := range cicontext.ExtractStringVars(p.Variables) {
ctx.Inject(k, v)
}
// Workflow rules: evaluate to find which rule matches, then inject its variables.
runs, ruleVars := cicontext.EvalWorkflow(p, ctx)
for k, v := range ruleVars {
ctx.Inject(k, v)
}
// Expand $VAR / ${VAR} references within variable values now that all
// sources (pipeline, workflow rules, CLI) have been merged.
ctx.ExpandVars()
return runs
}
func printContext(p *model.Pipeline, ctx *cicontext.Context) {
fmt.Printf("Context: %s\n\n", ctx.Summary())
+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=
+167 -12
View File
@@ -1,12 +1,16 @@
package cicontext
import "strings"
import (
"fmt"
"strings"
)
// Context holds the simulated CI execution environment used for context-aware
// pipeline evaluation (rules:if:, only:, except:, workflow:rules:).
// Variables are keyed by their name without the leading $.
type Context struct {
Vars map[string]string
pinned map[string]bool // vars set via --var or shortcuts; never overwritten by Inject
}
// New builds a Context from high-level shortcut values and optional KEY=VALUE
@@ -17,46 +21,55 @@ type Context struct {
// preserving the existing linting behaviour when no context flags are given.
//
// Override priority (highest wins): extraVars > branch/tag/source shortcuts.
// Both shortcut-derived and extraVar variables are pinned — they will not be
// overwritten by Inject (used for pipeline-level and workflow-rule variables).
func New(branch, tag, source string, extraVars []string) *Context {
if branch == "" && tag == "" && source == "" && len(extraVars) == 0 {
return &Context{}
}
vars := make(map[string]string)
pinned := make(map[string]bool)
pin := func(k, v string) {
vars[k] = v
pinned[k] = true
}
if branch != "" {
vars["CI_COMMIT_BRANCH"] = branch
vars["CI_COMMIT_REF_NAME"] = branch
vars["CI_COMMIT_REF_SLUG"] = slugify(branch)
pin("CI_COMMIT_BRANCH", branch)
pin("CI_COMMIT_REF_NAME", branch)
pin("CI_COMMIT_REF_SLUG", slugify(branch))
if source == "" {
source = "push"
}
}
if tag != "" {
vars["CI_COMMIT_TAG"] = tag
vars["CI_COMMIT_REF_NAME"] = tag
vars["CI_COMMIT_REF_SLUG"] = slugify(tag)
delete(vars, "CI_COMMIT_BRANCH") // tag pushes have no branch variable
pin("CI_COMMIT_TAG", tag)
pin("CI_COMMIT_REF_NAME", tag)
pin("CI_COMMIT_REF_SLUG", slugify(tag))
delete(vars, "CI_COMMIT_BRANCH")
delete(pinned, "CI_COMMIT_BRANCH")
if source == "" {
source = "push"
}
}
if source != "" {
vars["CI_PIPELINE_SOURCE"] = source
pin("CI_PIPELINE_SOURCE", source)
}
if _, ok := vars["CI_DEFAULT_BRANCH"]; !ok {
vars["CI_DEFAULT_BRANCH"] = "main"
}
// KEY=VALUE overrides win over shortcuts.
// KEY=VALUE overrides win over shortcuts and everything else.
for _, kv := range extraVars {
k, v, ok := strings.Cut(kv, "=")
if ok {
vars[k] = v
pin(k, v)
}
}
return &Context{Vars: vars}
return &Context{Vars: vars, pinned: pinned}
}
// IsEmpty reports whether no variables have been set (no context flags given).
@@ -73,6 +86,21 @@ func (c *Context) Get(key string) string {
return c.Vars[key]
}
// Inject sets key=value only if key is not already pinned (i.e. not set via
// --branch / --tag / --source / --var). Used to inject pipeline-level variable
// defaults and workflow-rule variables without overriding explicit user input.
// Calling Inject in order from lowest-priority to highest-priority source
// ensures later calls win over earlier ones.
func (c *Context) Inject(key, value string) {
if c.pinned[key] {
return
}
if c.Vars == nil {
c.Vars = make(map[string]string)
}
c.Vars[key] = value
}
// Summary returns a short human-readable description of the context for CLI output.
func (c *Context) Summary() string {
if c.IsEmpty() {
@@ -90,6 +118,133 @@ func (c *Context) Summary() string {
return strings.Join(parts, ", ")
}
// ExtractStringVars converts a map[string]any variable block (as used by
// Pipeline.Variables and Rule.Variables) to a flat map[string]string.
// Plain string values are used directly. Extended {value: ...} map form uses
// the "value" key. Scalar non-string values (bool, int, float64) are
// converted to their string representation, matching GitLab's own behaviour
// where all CI variable values are strings.
func ExtractStringVars(m map[string]any) map[string]string {
if len(m) == 0 {
return nil
}
out := make(map[string]string, len(m))
for k, v := range m {
if s, ok := ScalarString(v); ok {
out[k] = s
}
}
return out
}
// ScalarString converts a YAML-decoded CI variable value to its string
// representation. Handles plain scalars and the extended {value: ...} map
// form. Returns (s, true) on success, ("", false) for unrecognised forms.
func ScalarString(v any) (string, bool) {
switch val := v.(type) {
case string:
return val, true
case bool:
return fmt.Sprintf("%t", val), true
case int:
return fmt.Sprintf("%d", val), true
case float64:
return fmt.Sprintf("%g", val), true
case map[string]any:
inner, ok := val["value"]
if !ok {
return "", false
}
return ScalarString(inner)
}
return "", false
}
// ExpandVars expands $VAR and ${VAR} references within every value in
// ctx.Vars, using the same map as the expansion source. Iteration repeats
// (up to 10 passes) so transitive chains like A=$B, B=$C resolve fully.
// Variables that form circular references are left as-is after the limit.
func (c *Context) ExpandVars() {
if c == nil || len(c.Vars) == 0 {
return
}
for range 10 {
changed := false
for k, v := range c.Vars {
expanded := expandVarRefs(v, c.Vars)
if expanded != v {
c.Vars[k] = expanded
changed = true
}
}
if !changed {
return
}
}
}
// expandVarRefs replaces $VAR and ${VAR} occurrences in s with their values
// from vars. Unknown variables are left unchanged.
func expandVarRefs(s string, vars map[string]string) string {
if !strings.Contains(s, "$") {
return s
}
var sb strings.Builder
i := 0
for i < len(s) {
if s[i] != '$' {
sb.WriteByte(s[i])
i++
continue
}
i++ // consume '$'
if i >= len(s) {
sb.WriteByte('$')
break
}
if s[i] == '{' {
i++ // consume '{'
j := i
for j < len(s) && isIdentByte(s[j]) {
j++
}
if j < len(s) && s[j] == '}' {
name := s[i:j]
if val, ok := vars[name]; ok {
sb.WriteString(val)
} else {
sb.WriteString("${")
sb.WriteString(name)
sb.WriteByte('}')
}
i = j + 1
} else {
// Malformed ${…} — emit literally
sb.WriteString("${")
i = j
}
} else {
j := i
for j < len(s) && isIdentByte(s[j]) {
j++
}
if j > i {
name := s[i:j]
if val, ok := vars[name]; ok {
sb.WriteString(val)
} else {
sb.WriteByte('$')
sb.WriteString(name)
}
i = j
} else {
sb.WriteByte('$')
}
}
}
return sb.String()
}
// slugify converts a ref name to its GitLab slug form:
// lowercased, non-alphanumeric characters replaced with '-', leading/trailing '-' removed.
func slugify(s string) string {
+169 -19
View File
@@ -9,21 +9,35 @@ import (
// variable resolver.
//
// Supported:
// - Variable references: $VAR_NAME
// - Variable references: $VAR_NAME or ${VAR_NAME}
// - String literals: "value" or 'value'
// - Null keyword: null
// - Comparison: == != =~ !~
// - Comparison: == != =~ !~ (single = is accepted as == for user convenience)
// - Boolean: && || !
// - Grouping: ( )
// - Regex flags: /pattern/i (case-insensitive), /pattern/m, /pattern/s
// - Multi-line: newlines between tokens are treated as whitespace
// - Variable regex RHS: $VAR =~ $PATTERN when $PATTERN holds a /regex/ string
//
// Regex patterns use Go's regexp syntax, which covers the common RE2 subset
// used by GitLab CI. Unsupported or unparseable expressions fall back to true
// (permissive) so the linter never silently drops jobs it cannot evaluate.
func EvalIf(expr string, vars func(string) string) bool {
return evalIf(expr, vars, true)
}
// EvalIfStrict is like EvalIf but returns false (instead of true) when the
// expression cannot be fully parsed. Use for workflow:rules: evaluation where
// a failed parse should skip to the next rule rather than matching everything.
func EvalIfStrict(expr string, vars func(string) string) bool {
return evalIf(expr, vars, false)
}
func evalIf(expr string, vars func(string) string, permissive bool) bool {
p := &exprParser{s: strings.TrimSpace(expr), vars: vars}
result, ok := p.parseOr()
if !ok || p.pos < len(p.s) {
return true // unparseable → permissive
return permissive
}
return result
}
@@ -56,8 +70,13 @@ func (p *exprParser) consume(tok string) bool {
}
func (p *exprParser) skipWS() {
for p.pos < len(p.s) && (p.s[p.pos] == ' ' || p.s[p.pos] == '\t') {
for p.pos < len(p.s) {
b := p.s[p.pos]
if b == ' ' || b == '\t' || b == '\n' || b == '\r' {
p.pos++
continue
}
break
}
}
@@ -67,11 +86,11 @@ func (p *exprParser) skipWS() {
// and_expr → not_expr ( '&&' not_expr )*
// not_expr → '!' not_expr | primary
// primary → '(' or_expr ')' | comparison
// comparison → value ( op value | regex_op regex )? | value
// value → '$' ident | '"' … '"' | "'" … "'" | 'null'
// comparison → value ( op value | regex_op regex_rhs )?
// value → '$' '{' ident '}' | '$' ident | '"' … '"' | "'" … "'" | 'null'
// op → '==' | '!='
// regex_op → '=~' | '!~'
// regex '/' … '/'
// regex_rhs → '/' … '/' flags? | '$' ident (where ident value is '/…/flags')
func (p *exprParser) parseOr() (bool, bool) {
left, ok := p.parseAnd()
@@ -165,8 +184,11 @@ func (p *exprParser) parseComparison() (bool, bool) {
case p.consume("=~"):
p.skipWS()
pat, ok := p.parseRegexLiteral()
if !ok {
pat, patOk, permissive := p.parseRegexRHS()
if permissive {
return true, true
}
if !patOk {
return false, false
}
re, err := regexp.Compile(pat)
@@ -177,8 +199,11 @@ func (p *exprParser) parseComparison() (bool, bool) {
case p.consume("!~"):
p.skipWS()
pat, ok := p.parseRegexLiteral()
if !ok {
pat, patOk, permissive := p.parseRegexRHS()
if permissive {
return true, true
}
if !patOk {
return false, false
}
re, err := regexp.Compile(pat)
@@ -186,19 +211,66 @@ func (p *exprParser) parseComparison() (bool, bool) {
return true, true // bad pattern → permissive
}
return !re.MatchString(leftStr), true
// Single = not followed by = or ~ — accepted as == (common user mistake;
// GitLab CI only supports == but = is frequently written by accident).
case p.peek() == '=' && !p.startsWith("==") && !p.startsWith("=~"):
p.pos++ // consume '='
p.skipWS()
rightStr, ok := p.parseValue()
if !ok {
return false, false
}
return leftStr == rightStr, true
}
// No operator: variable is truthy when non-empty (defined and non-null).
return leftStr != "", true
}
// parseValue reads $VAR, "string", 'string', or null.
// null and undefined variables both produce an empty string.
// parseRegexRHS parses the right-hand side of =~ / !~ operators.
// Returns (pattern, ok, permissive):
// - /regex/flags literal → (pattern, true, false)
// - $VAR whose value is /regex/flags → (pattern, true, false)
// - $VAR whose value is empty or not a /regex/ → ("", false, true) — caller uses permissive true
// - parse error → ("", false, false)
func (p *exprParser) parseRegexRHS() (pat string, ok bool, permissive bool) {
if p.peek() == '/' {
pat, ok = p.parseRegexLiteral()
return pat, ok, false
}
if p.peek() == '$' {
varVal, varOk := p.parseValue()
if !varOk {
return "", false, false
}
pat, ok = extractRegexFromString(varVal)
if !ok {
return "", false, true // variable is not a /regex/ value → permissive
}
return pat, true, false
}
return "", false, false
}
// parseValue reads $VAR, ${VAR}, "string", 'string', null, true, false, or an
// integer literal. null and undefined variables both produce an empty string.
// true/false and integers produce their string representations (GitLab CI
// compares all values as strings).
func (p *exprParser) parseValue() (string, bool) {
p.skipWS()
if p.peek() == '$' {
p.pos++ // consume '$'
if p.peek() == '{' {
p.pos++ // consume '{'
name := p.parseIdent()
if name == "" || p.peek() != '}' {
return "", false
}
p.pos++ // consume '}'
return p.vars(name), true
}
name := p.parseIdent()
if name == "" {
return "", false
@@ -206,12 +278,18 @@ func (p *exprParser) parseValue() (string, bool) {
return p.vars(name), true
}
// null keyword — must not be a prefix of a longer identifier.
if p.startsWith("null") {
end := p.pos + 4
// Keywords and string literals must not be prefixes of longer identifiers.
for _, kw := range []struct{ tok, val string }{
{"null", ""},
{"true", "true"},
{"false", "false"},
} {
if p.startsWith(kw.tok) {
end := p.pos + len(kw.tok)
if end >= len(p.s) || !isIdentByte(p.s[end]) {
p.pos += 4
return "", true // null → empty string
p.pos += len(kw.tok)
return kw.val, true
}
}
}
@@ -219,6 +297,15 @@ func (p *exprParser) parseValue() (string, bool) {
return p.parseStringLiteral()
}
// Integer literal — returned as its decimal string for string comparison.
if p.peek() >= '0' && p.peek() <= '9' {
start := p.pos
for p.pos < len(p.s) && p.s[p.pos] >= '0' && p.s[p.pos] <= '9' {
p.pos++
}
return p.s[start:p.pos], true
}
return "", false
}
@@ -261,7 +348,8 @@ func (p *exprParser) parseRegexLiteral() (string, bool) {
b := p.s[p.pos]
if b == '/' {
p.pos++ // consume closing '/'
return sb.String(), true
flags := p.parseRegexFlags()
return applyRegexFlags(flags, sb.String()), true
}
if b == '\\' && p.pos+1 < len(p.s) {
p.pos++
@@ -275,6 +363,68 @@ func (p *exprParser) parseRegexLiteral() (string, bool) {
return "", false // unterminated regex
}
// parseRegexFlags reads zero or more regex flag letters (i, m, s) after the
// closing '/'. Unknown letters are consumed but ignored.
func (p *exprParser) parseRegexFlags() string {
start := p.pos
for p.pos < len(p.s) && isIdentByte(p.s[p.pos]) {
p.pos++
}
return p.s[start:p.pos]
}
// applyRegexFlags prepends Go regexp flag groups to pattern (e.g. (?i) for 'i').
// Unknown flags are silently ignored.
func applyRegexFlags(flags, pattern string) string {
if flags == "" {
return pattern
}
var prefix strings.Builder
for _, f := range flags {
switch f {
case 'i':
prefix.WriteString("(?i)")
case 'm':
prefix.WriteString("(?m)")
case 's':
prefix.WriteString("(?s)")
}
}
return prefix.String() + pattern
}
// extractRegexFromString parses a /pattern/flags string (typically from a CI
// variable) and returns a Go regexp pattern with flags applied.
func extractRegexFromString(s string) (string, bool) {
s = strings.TrimSpace(s)
if len(s) == 0 || s[0] != '/' {
return "", false
}
var sb strings.Builder
i := 1
for i < len(s) {
b := s[i]
if b == '/' {
i++ // past closing '/'
var flags strings.Builder
for i < len(s) && isIdentByte(s[i]) {
flags.WriteByte(s[i])
i++
}
return applyRegexFlags(flags.String(), sb.String()), true
}
if b == '\\' && i+1 < len(s) {
i++
sb.WriteByte('\\')
sb.WriteByte(s[i])
} else {
sb.WriteByte(b)
}
i++
}
return "", false // unterminated
}
func isIdentByte(b byte) bool {
return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') || b == '_'
}
+99
View File
@@ -9,6 +9,10 @@ func TestEvalIf(t *testing.T) {
"CI_COMMIT_TAG": "",
"CI_PIPELINE_SOURCE": "push",
"DEPLOY_ENV": "staging",
"BRANCH_PATTERN": "/^dev/",
"BRANCH_PATTERN_CI": "/^DEV/i",
"EMPTY_PATTERN": "",
"PLAIN_PATTERN": "develop",
}
return m[key]
}
@@ -69,9 +73,59 @@ func TestEvalIf(t *testing.T) {
{"extra spaces", ` $CI_COMMIT_BRANCH == "develop" `, true},
{"tabs", "$CI_COMMIT_BRANCH\t==\t\"develop\"", true},
// ── Multi-line expressions (newlines between tokens) ──────────────────
{"multiline or true", "$CI_COMMIT_BRANCH == \"develop\" ||\n$CI_COMMIT_TAG != null", true},
{"multiline or false", "$CI_COMMIT_BRANCH == \"main\" ||\n$CI_COMMIT_TAG != null", false},
{"multiline and true", "$CI_COMMIT_BRANCH == \"develop\" &&\n$CI_PIPELINE_SOURCE == \"push\"", true},
{"multiline and false", "$CI_COMMIT_BRANCH == \"main\" &&\n$CI_PIPELINE_SOURCE == \"push\"", false},
{"multiline with crlf", "$CI_COMMIT_BRANCH == \"develop\" ||\r\n$CI_COMMIT_TAG != null", true},
// ── ${VAR} curly-brace syntax ─────────────────────────────────────────
{"curly var eq match", `${CI_COMMIT_BRANCH} == "develop"`, true},
{"curly var eq no match", `${CI_COMMIT_BRANCH} == "main"`, false},
{"curly var truthiness", `${CI_COMMIT_BRANCH}`, true},
{"curly var falsy", `${CI_COMMIT_TAG}`, false},
{"curly var neq null", `${CI_COMMIT_BRANCH} != null`, true},
{"curly mixed", `${CI_COMMIT_BRANCH} == "develop" && $CI_PIPELINE_SOURCE == "push"`, true},
// ── Regex flags (/pattern/i etc.) ─────────────────────────────────────
{"regex flag i match", `$CI_COMMIT_BRANCH =~ /^DEV/i`, true},
{"regex flag i no match", `$CI_COMMIT_BRANCH =~ /^MAIN/i`, false},
{"regex flag i not match", `$CI_COMMIT_BRANCH !~ /^MAIN/i`, true},
{"regex no flag case sensitive", `$CI_COMMIT_BRANCH =~ /^DEV/`, false},
{"regex flag i version tag", `$CI_PIPELINE_SOURCE =~ /^PUSH$/i`, true},
// ── Variable on right side of =~ ──────────────────────────────────────
{"var regex rhs match", `$CI_COMMIT_BRANCH =~ $BRANCH_PATTERN`, true},
{"var regex rhs no match", `$CI_PIPELINE_SOURCE =~ $BRANCH_PATTERN`, false},
{"var regex rhs ci flag match", `$CI_COMMIT_BRANCH =~ $BRANCH_PATTERN_CI`, true},
{"var regex rhs empty permissive", `$CI_COMMIT_BRANCH =~ $EMPTY_PATTERN`, true},
{"var regex rhs plain permissive", `$CI_COMMIT_BRANCH =~ $PLAIN_PATTERN`, true},
{"var regex rhs not match", `$CI_COMMIT_BRANCH !~ $BRANCH_PATTERN`, false},
// ── Bare true/false keywords ─────────────────────────────────────────
// GitLab CI treats true/false as the string values "true"/"false".
{"bare true match", `$CI_PIPELINE_SOURCE == true`, false}, // "push" != "true"
{"bare false match", `$CI_COMMIT_TAG == false`, false}, // "" != "false"
{"bare true var set to true", `$DEPLOY_ENV == true`, false}, // "staging" != "true"
{"bare false neq", `$CI_COMMIT_BRANCH != false`, true}, // "develop" != "false"
{"bare true in compound", `$CI_COMMIT_BRANCH != null && $CI_COMMIT_TAG == false`, false},
// ── Integer literals ──────────────────────────────────────────────────
// Compared as decimal strings (GitLab CI converts integers to strings).
{"int eq match", `$CI_PIPELINE_SOURCE != 0`, true}, // "push" != "0"
{"int eq no match", `$CI_COMMIT_TAG == 0`, false}, // "" != "0"
{"int in compound", `$CI_COMMIT_BRANCH != null && $CI_COMMIT_BRANCH != 0`, true},
// ── Permissive fallback ───────────────────────────────────────────────
{"unparseable returns true", `this is not valid syntax %%%`, true},
{"empty expr returns true", ``, true},
// ── Single = as alias for == ──────────────────────────────────────────
{"single eq match", `$CI_COMMIT_BRANCH = "develop"`, true},
{"single eq no match", `$CI_COMMIT_BRANCH = "main"`, false},
{"single eq in compound", `$CI_COMMIT_BRANCH = "develop" && $CI_PIPELINE_SOURCE = "push"`, true},
{"single eq compound false", `$CI_COMMIT_BRANCH = "main" && $CI_PIPELINE_SOURCE = "push"`, false},
}
for _, tc := range tests {
@@ -83,3 +137,48 @@ func TestEvalIf(t *testing.T) {
})
}
}
func TestEvalIfStrict(t *testing.T) {
vars := func(key string) string {
m := map[string]string{
"CI_COMMIT_BRANCH": "develop",
"CI_PIPELINE_SOURCE": "push",
"WORKFLOW": "",
}
return m[key]
}
tests := []struct {
name string
expr string
want bool
}{
// Parseable expressions behave identically to EvalIf.
{"parseable match", `$CI_COMMIT_BRANCH == "develop"`, true},
{"parseable no match", `$CI_COMMIT_BRANCH == "main"`, false},
{"single eq match", `$CI_COMMIT_BRANCH = "develop"`, true},
// Empty expression: ruleIfMatchesStrict handles the empty→true case
// before calling EvalIfStrict, so empty falls through to false here.
{"empty expr", ``, false},
// Unparseable expressions return false (strict) instead of true (permissive).
{"unparseable returns false", `this is not valid syntax %%%`, false},
// The key workflow-rule scenario: a complex condition with an
// unevaluable sub-expression should not match (strict=false) so that
// later workflow rules can be evaluated.
{"workflow rule complex no match", `$WORKFLOW = "gitflow" && $CI_PIPELINE_SOURCE == /(push|web)/`, false},
// Compound with a bad second operand: strict returns false.
{"and with bad rhs strict false", `$CI_COMMIT_BRANCH == "develop" && !(((`, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := EvalIfStrict(tc.expr, vars)
if got != tc.want {
t.Errorf("EvalIfStrict(%q) = %v, want %v", tc.expr, got, tc.want)
}
})
}
}
+24 -9
View File
@@ -28,26 +28,34 @@ func (s JobState) String() string {
}
}
// EvalWorkflow returns false when the pipeline's workflow:rules block would
// prevent any pipeline from starting in the given context.
// Returns true when ctx is empty, when there is no workflow block, or when no
// rule is configured.
func EvalWorkflow(p *model.Pipeline, ctx *Context) bool {
// EvalWorkflow evaluates the pipeline's workflow:rules block against ctx.
// Returns (runs, ruleVars):
// - runs=false means the pipeline would not start for this context.
// - ruleVars holds any variables: defined on the matching rule; inject these
// into the context so job rules can reference them.
//
// Returns (true, nil) when ctx is empty, when there is no workflow block, or
// when no rules are configured.
func EvalWorkflow(p *model.Pipeline, ctx *Context) (bool, map[string]string) {
if ctx.IsEmpty() || p.Workflow == nil || len(p.Workflow.Rules) == 0 {
return true
return true, nil
}
vars := ctx.Get
for _, rule := range p.Workflow.Rules {
if !ruleIfMatches(rule.If, vars) {
// Workflow rules use strict evaluation: an unparseable condition is
// treated as no-match so later rules (with valid conditions or a
// bare when:) are reached. Permissive-true would cause an early rule
// with a complex/invalid condition to block all subsequent rules.
if !ruleIfMatchesStrict(rule.If, vars) {
continue
}
when := rule.When
if when == "" {
when = "always"
}
return when != "never"
return when != "never", ExtractStringVars(rule.Variables)
}
return false // no rule matched → pipeline does not run
return false, nil // no rule matched → pipeline does not run
}
// EvalJob returns the effective JobState for job in the given context.
@@ -90,6 +98,13 @@ func ruleIfMatches(ifExpr string, vars func(string) string) bool {
return EvalIf(ifExpr, vars)
}
func ruleIfMatchesStrict(ifExpr string, vars func(string) string) bool {
if ifExpr == "" {
return true // no if: condition → rule always matches
}
return EvalIfStrict(ifExpr, vars)
}
func whenToState(when string) JobState {
switch when {
case "never":
+243
View File
@@ -0,0 +1,243 @@
package cicontext
import (
"testing"
"git.k3nny.fr/glint/internal/model"
)
func TestEvalWorkflow(t *testing.T) {
makePipeline := func(rules []model.Rule) *model.Pipeline {
return &model.Pipeline{Workflow: &model.Workflow{Rules: rules}}
}
tests := []struct {
name string
rules []model.Rule
branch string
wantRuns bool
wantVars map[string]string
}{
{
name: "no workflow block",
rules: nil,
branch: "main",
wantRuns: true,
wantVars: nil,
},
{
name: "matching rule runs always",
rules: []model.Rule{
{If: `$CI_COMMIT_BRANCH == "main"`, When: "always"},
},
branch: "main",
wantRuns: true,
},
{
name: "matching rule when never",
rules: []model.Rule{
{If: `$CI_COMMIT_BRANCH == "main"`, When: "never"},
},
branch: "main",
wantRuns: false,
},
{
name: "no rule matched",
rules: []model.Rule{
{If: `$CI_COMMIT_BRANCH == "main"`},
},
branch: "develop",
wantRuns: false,
},
{
name: "matching rule with variables",
rules: []model.Rule{
{
If: `$CI_COMMIT_BRANCH == "main"`,
When: "always",
Variables: map[string]any{
"DEPLOY_TARGET": "production",
"ENVIRONMENT": "prod",
},
},
{When: "always"},
},
branch: "main",
wantRuns: true,
wantVars: map[string]string{
"DEPLOY_TARGET": "production",
"ENVIRONMENT": "prod",
},
},
{
name: "fallback rule with different variables",
rules: []model.Rule{
{If: `$CI_COMMIT_BRANCH == "main"`, Variables: map[string]any{"DEPLOY_TARGET": "production"}},
{When: "always", Variables: map[string]any{"DEPLOY_TARGET": "staging"}},
},
branch: "develop",
wantRuns: true,
wantVars: map[string]string{"DEPLOY_TARGET": "staging"},
},
{
name: "empty context always runs",
rules: []model.Rule{
{If: `$CI_COMMIT_BRANCH == "main"`, When: "never"},
},
branch: "", // no context
wantRuns: true,
wantVars: nil,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var p *model.Pipeline
if tc.rules == nil {
p = &model.Pipeline{}
} else {
p = makePipeline(tc.rules)
}
ctx := New(tc.branch, "", "", nil)
runs, vars := EvalWorkflow(p, ctx)
if runs != tc.wantRuns {
t.Errorf("EvalWorkflow runs = %v, want %v", runs, tc.wantRuns)
}
for k, want := range tc.wantVars {
if got := vars[k]; got != want {
t.Errorf("EvalWorkflow vars[%q] = %q, want %q", k, got, want)
}
}
if len(vars) != len(tc.wantVars) {
t.Errorf("EvalWorkflow returned %d vars, want %d; got %v", len(vars), len(tc.wantVars), vars)
}
})
}
}
func TestContextInject(t *testing.T) {
t.Run("inject does not overwrite pinned var", func(t *testing.T) {
ctx := New("main", "", "", []string{"DEPLOY_TARGET=override"})
ctx.Inject("DEPLOY_TARGET", "workflow-value")
if got := ctx.Get("DEPLOY_TARGET"); got != "override" {
t.Errorf("Inject overwrote pinned var: got %q, want %q", got, "override")
}
})
t.Run("inject does not overwrite shortcut var", func(t *testing.T) {
ctx := New("main", "", "", nil)
ctx.Inject("CI_COMMIT_BRANCH", "other")
if got := ctx.Get("CI_COMMIT_BRANCH"); got != "main" {
t.Errorf("Inject overwrote shortcut var: got %q, want %q", got, "main")
}
})
t.Run("inject sets new variable", func(t *testing.T) {
ctx := New("main", "", "", nil)
ctx.Inject("DEPLOY_TARGET", "production")
if got := ctx.Get("DEPLOY_TARGET"); got != "production" {
t.Errorf("Inject did not set variable: got %q", got)
}
})
t.Run("inject later call overrides earlier call", func(t *testing.T) {
ctx := New("main", "", "", nil)
ctx.Inject("DEPLOY_TARGET", "pipeline-default")
ctx.Inject("DEPLOY_TARGET", "workflow-override")
if got := ctx.Get("DEPLOY_TARGET"); got != "workflow-override" {
t.Errorf("second Inject did not win: got %q, want %q", got, "workflow-override")
}
})
}
func TestExtractStringVars(t *testing.T) {
tests := []struct {
name string
in map[string]any
want map[string]string
}{
{
name: "plain strings",
in: map[string]any{"A": "hello", "B": "world"},
want: map[string]string{"A": "hello", "B": "world"},
},
{
name: "extended value form",
in: map[string]any{
"KEY": map[string]any{"value": "extended", "description": "some desc"},
},
want: map[string]string{"KEY": "extended"},
},
{
name: "mixed forms",
in: map[string]any{
"PLAIN": "str",
"COMPLEX": map[string]any{"value": "val"},
},
want: map[string]string{"PLAIN": "str", "COMPLEX": "val"},
},
{
name: "nil map",
in: nil,
want: nil,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := ExtractStringVars(tc.in)
for k, want := range tc.want {
if got[k] != want {
t.Errorf("ExtractStringVars[%q] = %q, want %q", k, got[k], want)
}
}
if len(got) != len(tc.want) {
t.Errorf("ExtractStringVars returned %d entries, want %d", len(got), len(tc.want))
}
})
}
}
// TestWorkflowVarsJobEval verifies the end-to-end flow: workflow rule injects
// DEPLOY_TARGET, which is then used in a job's rules:if: expression.
func TestWorkflowVarsJobEval(t *testing.T) {
rules := []model.Rule{
{If: `$CI_COMMIT_BRANCH == "main"`, Variables: map[string]any{"DEPLOY_TARGET": "production"}},
{When: "always", Variables: map[string]any{"DEPLOY_TARGET": "staging"}},
}
p := &model.Pipeline{
Workflow: &model.Workflow{Rules: rules},
Jobs: map[string]model.Job{
"deploy-prod": {
Rules: []model.Rule{
{If: `$DEPLOY_TARGET == "production"`, When: "on_success"},
{When: "never"},
},
},
"deploy-staging": {
Rules: []model.Rule{
{If: `$DEPLOY_TARGET == "staging"`, When: "on_success"},
{When: "never"},
},
},
},
}
for _, tc := range []struct {
branch string
wantProd JobState
wantStaging JobState
}{
{"main", JobActive, JobSkipped},
{"develop", JobSkipped, JobActive},
} {
ctx := New(tc.branch, "", "", nil)
_, ruleVars := EvalWorkflow(p, ctx)
for k, v := range ruleVars {
ctx.Inject(k, v)
}
if got := EvalJob(p.Jobs["deploy-prod"], ctx); got != tc.wantProd {
t.Errorf("branch=%q deploy-prod = %v, want %v", tc.branch, got, tc.wantProd)
}
if got := EvalJob(p.Jobs["deploy-staging"], ctx); got != tc.wantStaging {
t.Errorf("branch=%q deploy-staging = %v, want %v", tc.branch, got, tc.wantStaging)
}
}
}
+18
View File
@@ -142,6 +142,24 @@ func (cfg GitLabConfig) FetchFile(project, filePath, ref string) ([]byte, error)
return body, nil
}
// FetchURL downloads the content at a plain HTTPS URL without authentication.
// Used for include: remote: entries which are public by definition.
func FetchURL(rawURL string) ([]byte, error) {
resp, err := http.Get(rawURL) //nolint:noctx
if err != nil {
return nil, fmt.Errorf("GET %s: %w", rawURL, err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("reading body: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GET %s: status %d", rawURL, resp.StatusCode)
}
return body, nil
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if v != "" {
+82 -8
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"git.k3nny.fr/glint/internal/fetcher"
@@ -11,6 +12,8 @@ import (
)
// Includes returns a Mermaid flowchart of the full include dependency tree.
// Each node shows the jobs defined directly in that file, connected with
// dashed arrows (solid arrows represent the include hierarchy itself).
// It recurses into project:, component:, and local: includes to expose
// transitive dependencies. Includes that cannot be fetched are shown but not expanded.
func Includes(sourcePath string, rawIncludes []any, cfg fetcher.GitLabConfig) string {
@@ -19,7 +22,12 @@ func Includes(sourcePath string, rawIncludes []any, cfg fetcher.GitLabConfig) st
cfg: cfg,
baseDir: filepath.Dir(sourcePath),
}
root := &treeNode{id: "root", label: mermaidLabel(sourcePath), class: "main"}
root := &treeNode{
id: "root",
label: mermaidLabel(sourcePath),
class: "main",
jobs: directJobs(sourcePath),
}
b.buildChildren(root, rawIncludes)
return renderTree(root)
}
@@ -29,6 +37,7 @@ type treeNode struct {
label string
class string
children []*treeNode
jobs []string // job names defined directly in this file
}
// treeBuilder accumulates state while recursively traversing include entries.
@@ -46,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)...)
}
}
@@ -105,7 +112,9 @@ func (b *treeBuilder) parseMap(m map[string]any) []*treeNode {
return []*treeNode{node}
}
if remote, ok := m["remote"].(string); ok {
return []*treeNode{{id: b.nextID(), label: "remote:<br>" + mermaidLabel(remote), class: "remote"}}
node := &treeNode{id: b.nextID(), label: "remote:<br>" + mermaidLabel(remote), class: "remote"}
b.recurseRemote(node, remote)
return []*treeNode{node}
}
if tmpl, ok := m["template"].(string); ok {
return []*treeNode{{id: b.nextID(), label: "template:<br>" + mermaidLabel(tmpl), class: "template"}}
@@ -126,7 +135,11 @@ func (b *treeBuilder) recurseLocal(node *treeNode, path string) {
return
}
p, err := model.ParseBytes(data)
if err != nil || len(p.Include) == 0 {
if err != nil {
return
}
node.jobs = jobNames(p)
if len(p.Include) == 0 {
return
}
orig := b.baseDir
@@ -147,7 +160,33 @@ func (b *treeBuilder) recurseProject(node *treeNode, project, filePath, ref stri
return
}
p, err := model.ParseBytes(data)
if err != nil || len(p.Include) == 0 {
if err != nil {
return
}
node.jobs = jobNames(p)
if len(p.Include) == 0 {
return
}
b.buildChildren(node, p.Include)
}
func (b *treeBuilder) recurseRemote(node *treeNode, rawURL string) {
key := "remote:" + rawURL
if b.visited[key] {
return
}
b.visited[key] = true
data, err := fetcher.FetchURL(rawURL)
if err != nil {
return
}
p, err := model.ParseBytes(data)
if err != nil {
return
}
node.jobs = jobNames(p)
if len(p.Include) == 0 {
return
}
b.buildChildren(node, p.Include)
@@ -172,7 +211,11 @@ func (b *treeBuilder) recurseComponent(node *treeNode, ref string) {
return
}
p, err := model.ParseBytes(data)
if err != nil || len(p.Include) == 0 {
if err != nil {
return
}
node.jobs = jobNames(p)
if len(p.Include) == 0 {
return
}
b.buildChildren(node, p.Include)
@@ -193,11 +236,19 @@ func renderTree(root *treeNode) string {
w(" classDef local fill:#428fdc,stroke:#1068bf,color:#fff")
w(" classDef remote fill:#868686,stroke:#686868,color:#fff")
w(" classDef template fill:#fc6d26,stroke:#e56b1f,color:#fff")
w(" classDef job fill:#f5f5ff,stroke:#7175a0,color:#333")
w("")
jobCounter := 0
var emit func(n *treeNode)
emit = func(n *treeNode) {
wf(" %s[\"%s\"]:::%s", n.id, n.label, n.class)
for _, jobName := range n.jobs {
jobCounter++
jobID := fmt.Sprintf("job%d", jobCounter)
wf(" %s(\"%s\"):::job", jobID, mermaidLabel(jobName))
wf(" %s -.-> %s", n.id, jobID)
}
for _, child := range n.children {
emit(child)
wf(" %s --> %s", n.id, child.id)
@@ -208,6 +259,29 @@ func renderTree(root *treeNode) string {
return sb.String()
}
// directJobs parses a single pipeline file (without include resolution) and
// returns the sorted list of job names defined directly in it.
func directJobs(path string) []string {
p, err := model.Parse(path)
if err != nil {
return nil
}
return jobNames(p)
}
// jobNames returns a sorted slice of all job names defined in p.
func jobNames(p *model.Pipeline) []string {
if len(p.Jobs) == 0 {
return nil
}
names := make([]string, 0, len(p.Jobs))
for name := range p.Jobs {
names = append(names, name)
}
sort.Strings(names)
return names
}
// parseComponentRef parses a CI/CD component reference of the form
// <host>/<project-path>/<component-name>@<version>.
func parseComponentRef(ref string) (host, project, component, version string, err error) {
+90
View File
@@ -0,0 +1,90 @@
package linter
import (
"testing"
"git.k3nny.fr/glint/internal/model"
)
func TestCheckDeadRules(t *testing.T) {
cases := []struct {
name string
rules []model.Rule
wantHit bool // whether GL033 should fire
}{
{
name: "no rules — not dead",
rules: nil,
wantHit: false,
},
{
name: "single bare when:never — dead",
rules: []model.Rule{{When: "never"}},
wantHit: true,
},
{
name: "all rules when:never with if — dead",
rules: []model.Rule{
{If: `$CI_COMMIT_BRANCH == "main"`, When: "never"},
{If: `$CI_COMMIT_BRANCH == "develop"`, When: "never"},
{When: "never"},
},
wantHit: true,
},
{
name: "first rule on_success — not dead",
rules: []model.Rule{
{If: `$CI_COMMIT_BRANCH == "main"`, When: "on_success"},
{When: "never"},
},
wantHit: false,
},
{
name: "rule with empty when (defaults to on_success) — not dead",
rules: []model.Rule{
{If: `$CI_COMMIT_BRANCH == "main"`},
{When: "never"},
},
wantHit: false,
},
{
name: "when:manual — not dead",
rules: []model.Rule{{When: "manual"}},
wantHit: false,
},
{
name: "when:always — not dead",
rules: []model.Rule{{When: "always"}},
wantHit: false,
},
{
name: "when:on_failure — not dead",
rules: []model.Rule{{When: "on_failure"}},
wantHit: false,
},
{
name: "mixed never and manual — not dead",
rules: []model.Rule{
{If: `$CI_COMMIT_BRANCH == "main"`, When: "never"},
{When: "manual"},
},
wantHit: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
job := model.Job{Rules: tc.rules}
findings := checkDeadRules("test-job", job)
hit := false
for _, f := range findings {
if f.Rule == RuleDeadRules {
hit = true
}
}
if hit != tc.wantHit {
t.Errorf("checkDeadRules: got hit=%v, want hit=%v; findings=%v", hit, tc.wantHit, findings)
}
})
}
}
+6
View File
@@ -23,7 +23,10 @@ func checkDependencies(p *model.Pipeline) []Finding {
if !exists {
findings = append(findings, Finding{
Severity: Error,
Rule: RuleUnknownDependency,
Job: name,
File: job.File,
Line: job.Line,
Message: fmt.Sprintf("'dependencies' references unknown job %q", dep),
})
continue
@@ -33,7 +36,10 @@ func checkDependencies(p *model.Pipeline) []Finding {
if depHasStage && depIdx >= jobStageIdx {
findings = append(findings, Finding{
Severity: Error,
Rule: RuleDependencyStage,
Job: name,
File: job.File,
Line: job.Line,
Message: fmt.Sprintf("'dependencies' job %q must be in an earlier stage (in %q, current job is in %q)", dep, depJob.Stage, job.Stage),
})
}
+54 -3
View File
@@ -95,6 +95,7 @@ func checkJobKeywords(name string, job model.Job) []Finding {
findings = append(findings, checkArtifacts(name, job)...)
findings = append(findings, checkCache(name, job)...)
findings = append(findings, checkRules(name, job)...)
findings = append(findings, checkDeadRules(name, job)...)
findings = append(findings, checkImage(name, job)...)
findings = append(findings, checkInherit(name, job)...)
return findings
@@ -105,6 +106,7 @@ func checkWhen(name string, job model.Job) []Finding {
if job.When != "" && !validJobWhen[job.When] {
findings = append(findings, Finding{
Severity: Error,
Rule: RuleInvalidWhen,
Job: name,
Message: fmt.Sprintf("'when' has invalid value %q; valid: on_success, on_failure, always, manual, delayed, never", job.When),
})
@@ -112,6 +114,7 @@ func checkWhen(name string, job model.Job) []Finding {
if job.When == "delayed" && job.StartIn == "" {
findings = append(findings, Finding{
Severity: Error,
Rule: RuleDelayedNoStartIn,
Job: name,
Message: "'when: delayed' requires 'start_in' (e.g. 'start_in: 30 minutes')",
})
@@ -119,6 +122,7 @@ func checkWhen(name string, job model.Job) []Finding {
if job.When != "delayed" && job.StartIn != "" {
findings = append(findings, Finding{
Severity: Error,
Rule: RuleStartInNoDelayed,
Job: name,
Message: "'start_in' is only valid when 'when: delayed'",
})
@@ -135,6 +139,7 @@ func checkParallel(name string, job model.Job) []Finding {
if v < 2 || v > 200 {
return []Finding{{
Severity: Error,
Rule: RuleInvalidParallel,
Job: name,
Message: fmt.Sprintf("'parallel' must be between 2 and 200, got %d", v),
}}
@@ -143,6 +148,7 @@ func checkParallel(name string, job model.Job) []Finding {
if _, ok := v["matrix"]; !ok {
return []Finding{{
Severity: Error,
Rule: RuleInvalidParallel,
Job: name,
Message: "'parallel' map form must have a 'matrix' key",
}}
@@ -150,6 +156,7 @@ func checkParallel(name string, job model.Job) []Finding {
default:
return []Finding{{
Severity: Error,
Rule: RuleInvalidParallel,
Job: name,
Message: "'parallel' must be an integer (2200) or a map with 'matrix'",
}}
@@ -166,6 +173,7 @@ func checkRetry(name string, job model.Job) []Finding {
if v < 0 || v > 2 {
return []Finding{{
Severity: Error,
Rule: RuleInvalidRetry,
Job: name,
Message: fmt.Sprintf("'retry' must be 0, 1, or 2; got %d", v),
}}
@@ -176,6 +184,7 @@ func checkRetry(name string, job model.Job) []Finding {
if n, ok := maxVal.(int); ok && (n < 0 || n > 2) {
findings = append(findings, Finding{
Severity: Error,
Rule: RuleInvalidRetry,
Job: name,
Message: fmt.Sprintf("'retry.max' must be 0, 1, or 2; got %d", n),
})
@@ -188,6 +197,7 @@ func checkRetry(name string, job model.Job) []Finding {
default:
return []Finding{{
Severity: Error,
Rule: RuleInvalidRetry,
Job: name,
Message: "'retry' must be an integer (02) or a map with 'max'/'when'",
}}
@@ -201,6 +211,7 @@ func validateRetryWhen(name string, val any) []Finding {
if !validRetryWhen[s] {
findings = append(findings, Finding{
Severity: Error,
Rule: RuleInvalidRetryWhen,
Job: name,
Message: fmt.Sprintf("'retry.when' has invalid value %q", s),
})
@@ -230,6 +241,7 @@ func checkAllowFailure(name string, job model.Job) []Finding {
if _, ok := v["exit_codes"]; !ok {
return []Finding{{
Severity: Error,
Rule: RuleInvalidAllowFailure,
Job: name,
Message: "'allow_failure' map form must contain 'exit_codes'",
}}
@@ -238,6 +250,7 @@ func checkAllowFailure(name string, job model.Job) []Finding {
_ = v
return []Finding{{
Severity: Error,
Rule: RuleInvalidAllowFailure,
Job: name,
Message: "'allow_failure' must be a boolean or a map with 'exit_codes'",
}}
@@ -252,6 +265,7 @@ func checkInterruptible(name string, job model.Job) []Finding {
if _, ok := job.Interruptible.(bool); !ok {
return []Finding{{
Severity: Error,
Rule: RuleInvalidInterruptible,
Job: name,
Message: "'interruptible' must be a boolean",
}}
@@ -267,6 +281,7 @@ func checkTrigger(name string, job model.Job) []Finding {
if scriptNonEmpty(job.Script) {
findings = append(findings, Finding{
Severity: Error,
Rule: RuleTriggerWithScript,
Job: name,
Message: "jobs with 'trigger' cannot use 'script'",
})
@@ -277,6 +292,7 @@ func checkTrigger(name string, job model.Job) []Finding {
if !hasProject && !hasInclude {
findings = append(findings, Finding{
Severity: Error,
Rule: RuleInvalidTrigger,
Job: name,
Message: "'trigger' map must specify 'project' or 'include'",
})
@@ -294,6 +310,7 @@ func checkCoverage(name string, job model.Job) []Finding {
if !coveragePattern.MatchString(job.Coverage) {
return []Finding{{
Severity: Error,
Rule: RuleInvalidCoverage,
Job: name,
Message: fmt.Sprintf("'coverage' must be a regex pattern wrapped in '/' (e.g. '/\\d+\\.?\\d*%%/'), got %q", job.Coverage),
}}
@@ -309,6 +326,7 @@ func checkRelease(name string, job model.Job) []Finding {
if !ok {
return []Finding{{
Severity: Error,
Rule: RuleInvalidRelease,
Job: name,
Message: "'release' must be a map",
}}
@@ -317,6 +335,7 @@ func checkRelease(name string, job model.Job) []Finding {
if !exists || tagName == "" || tagName == nil {
return []Finding{{
Severity: Error,
Rule: RuleInvalidRelease,
Job: name,
Message: "'release' requires 'tag_name'",
}}
@@ -334,11 +353,12 @@ 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{
Severity: Error,
Rule: RuleInvalidEnvironment,
Job: name,
Message: "'environment.url' requires 'environment.name' to be set",
})
@@ -346,6 +366,7 @@ func checkEnvironment(name string, job model.Job) []Finding {
if action, ok := m["action"].(string); ok && !validEnvironmentAction[action] {
findings = append(findings, Finding{
Severity: Error,
Rule: RuleInvalidEnvironment,
Job: name,
Message: fmt.Sprintf("'environment.action' has invalid value %q; valid: start, stop, prepare, verify, access", action),
})
@@ -365,15 +386,17 @@ func checkArtifacts(name string, job model.Job) []Finding {
if w, ok := m["when"].(string); ok && !validArtifactsWhen[w] {
findings = append(findings, Finding{
Severity: Error,
Rule: RuleInvalidArtifacts,
Job: name,
Message: fmt.Sprintf("'artifacts.when' has invalid value %q; valid: on_success, on_failure, always", w),
})
}
if _, hasExposeAs := m["expose_as"]; hasExposeAs {
paths, _ := m["paths"]
paths := m["paths"]
if paths == nil {
findings = append(findings, Finding{
Severity: Error,
Rule: RuleInvalidArtifacts,
Job: name,
Message: "'artifacts.expose_as' requires 'artifacts.paths'",
})
@@ -392,6 +415,7 @@ func checkArtifacts(name string, job model.Job) []Finding {
if !found {
findings = append(findings, Finding{
Severity: Warning,
Rule: RulePagesPublic,
Job: name,
Message: "the 'pages' job should include 'public' in 'artifacts.paths' for GitLab Pages to deploy",
})
@@ -421,6 +445,7 @@ func checkCache(name string, job model.Job) []Finding {
if w, ok := m["when"].(string); ok && !validCacheWhen[w] {
findings = append(findings, Finding{
Severity: Error,
Rule: RuleInvalidCache,
Job: name,
Message: fmt.Sprintf("'cache.when' has invalid value %q; valid: on_success, on_failure, always", w),
})
@@ -428,6 +453,7 @@ func checkCache(name string, job model.Job) []Finding {
if p, ok := m["policy"].(string); ok && !validCachePolicy[p] {
findings = append(findings, Finding{
Severity: Error,
Rule: RuleInvalidCache,
Job: name,
Message: fmt.Sprintf("'cache.policy' has invalid value %q; valid: pull, push, pull-push", p),
})
@@ -442,6 +468,7 @@ func checkRules(name string, job model.Job) []Finding {
if rule.When != "" && !validRuleWhen[rule.When] {
findings = append(findings, Finding{
Severity: Error,
Rule: RuleInvalidRulesWhen,
Job: name,
Message: fmt.Sprintf("rules[%d].when has invalid value %q; valid: on_success, on_failure, always, manual, delayed, never", i, rule.When),
})
@@ -450,6 +477,28 @@ func checkRules(name string, job model.Job) []Finding {
return findings
}
// checkDeadRules reports when every rule in a job's rules: block has an
// explicit when: never, making the job permanently unreachable. This is a
// provably-correct static claim: no matter which if: condition matches, the
// outcome is always "never"; and if no rule matches, the implicit fallback is
// also skip. No if: evaluation is required.
func checkDeadRules(name string, job model.Job) []Finding {
if len(job.Rules) == 0 {
return nil
}
for _, r := range job.Rules {
if r.When != "never" {
return nil
}
}
return []Finding{{
Severity: Warning,
Rule: RuleDeadRules,
Job: name,
Message: "rules: block can never activate; every rule has 'when: never' — job is permanently excluded from the pipeline",
}}
}
func checkImage(name string, job model.Job) []Finding {
if job.Image == nil {
return nil
@@ -458,10 +507,11 @@ 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,
Rule: RuleInvalidImage,
Job: name,
Message: "'image' map form requires a 'name' key",
}}
@@ -489,6 +539,7 @@ func checkInherit(name string, job model.Job) []Finding {
default:
findings = append(findings, Finding{
Severity: Error,
Rule: RuleInvalidInherit,
Job: name,
Message: fmt.Sprintf("'inherit.%s' must be a boolean or a list of names", key),
})
+61 -5
View File
@@ -1,7 +1,9 @@
package linter
import (
"cmp"
"fmt"
"slices"
"strings"
"git.k3nny.fr/glint/internal/model"
@@ -16,18 +18,40 @@ const (
type Finding struct {
Severity Severity
Rule string // stable rule ID, e.g. "GL003" — see rules.go
Job string // empty for pipeline-level findings
File string // source file where the finding originates
Line int // line number in File (0 = unknown)
Message string
}
func (f Finding) String() string {
if f.Job != "" {
return fmt.Sprintf("[%s] job %q: %s", f.Severity, f.Job, f.Message)
var loc string
if f.File != "" {
if f.Line > 0 {
loc = fmt.Sprintf("%s:%d: ", f.File, f.Line)
} else {
loc = fmt.Sprintf("%s: ", f.File)
}
return fmt.Sprintf("[%s] %s", f.Severity, f.Message)
}
// Lint runs all rules against p and returns findings sorted by job name.
rule := ""
if f.Rule != "" {
rule = f.Rule + " "
}
sev := "[" + strings.ToLower(string(f.Severity)) + "]"
msg := f.Message
if f.Job != "" {
msg = fmt.Sprintf("job %q: %s", f.Job, f.Message)
}
return fmt.Sprintf("%s%s%s %s", loc, rule, sev, msg)
}
// Lint runs all rules against p and returns findings sorted by (File, Line, Rule).
// Findings with no File (pipeline-level) sort before file-scoped ones.
func Lint(p *model.Pipeline) []Finding {
var findings []Finding
findings = append(findings, checkStages(p)...)
@@ -35,6 +59,16 @@ func Lint(p *model.Pipeline) []Finding {
findings = append(findings, checkJobs(p)...)
findings = append(findings, checkNeeds(p)...)
findings = append(findings, checkDependencies(p)...)
findings = append(findings, checkVariableRefs(p)...)
slices.SortStableFunc(findings, func(a, b Finding) int {
if c := cmp.Compare(a.File, b.File); c != 0 {
return c
}
if c := cmp.Compare(a.Line, b.Line); c != 0 {
return c
}
return cmp.Compare(a.Rule, b.Rule)
})
return findings
}
@@ -43,6 +77,8 @@ func checkStages(p *model.Pipeline) []Finding {
if len(p.Stages) == 0 {
findings = append(findings, Finding{
Severity: Warning,
Rule: RuleNoStages,
File: p.SourceFile,
Message: "no stages defined; GitLab will use default stages (build, test, deploy)",
})
}
@@ -58,6 +94,8 @@ func checkWorkflow(p *model.Pipeline) []Finding {
if rule.When != "" && !validWorkflowRuleWhen[rule.When] {
findings = append(findings, Finding{
Severity: Error,
Rule: RuleWorkflowWhen,
File: p.SourceFile,
Message: fmt.Sprintf("workflow.rules[%d].when has invalid value %q; valid: always, never", i, rule.When),
})
}
@@ -88,10 +126,17 @@ func checkJob(name string, job model.Job, stageSet map[string]bool) []Finding {
// After extends resolution, a job with no script/run is an error.
// Exceptions: trigger jobs, pages jobs (use pages: keyword), and template jobs.
// When the job has extends:, the script may come from a base that couldn't be
// fetched (e.g. a remote include without a token), so downgrade to warning.
hasScript := scriptNonEmpty(job.Script) || job.Run != nil
if !isTemplate && !isTrigger && job.Pages == nil && !hasScript {
sev := Error
if job.Extends != nil {
sev = Warning
}
findings = append(findings, Finding{
Severity: Error,
Severity: sev,
Rule: RuleMissingScript,
Job: name,
Message: "missing required field 'script' (or 'run')",
})
@@ -103,6 +148,7 @@ func checkJob(name string, job model.Job, stageSet map[string]bool) []Finding {
if job.Stage != "" && !strings.Contains(job.Stage, "$[[") && len(stageSet) > 0 && !stageSet[job.Stage] {
findings = append(findings, Finding{
Severity: Error,
Rule: RuleUnknownStage,
Job: name,
Message: fmt.Sprintf("stage %q is not defined in 'stages'", job.Stage),
})
@@ -112,6 +158,7 @@ func checkJob(name string, job model.Job, stageSet map[string]bool) []Finding {
if job.Only != nil && len(job.Rules) > 0 {
findings = append(findings, Finding{
Severity: Error,
Rule: RuleOnlyRulesConflict,
Job: name,
Message: "'only' and 'rules' cannot be used together",
})
@@ -121,6 +168,7 @@ func checkJob(name string, job model.Job, stageSet map[string]bool) []Finding {
if job.Except != nil && len(job.Rules) > 0 {
findings = append(findings, Finding{
Severity: Error,
Rule: RuleExceptRulesConflict,
Job: name,
Message: "'except' and 'rules' cannot be used together",
})
@@ -130,6 +178,7 @@ func checkJob(name string, job model.Job, stageSet map[string]bool) []Finding {
if job.Only != nil || job.Except != nil {
findings = append(findings, Finding{
Severity: Warning,
Rule: RuleDeprecatedOnly,
Job: name,
Message: "'only'/'except' are deprecated; prefer 'rules'",
})
@@ -137,6 +186,13 @@ func checkJob(name string, job model.Job, stageSet map[string]bool) []Finding {
findings = append(findings, checkJobKeywords(name, job)...)
// Attach source location to every job-scoped finding collected above.
for i := range findings {
if findings[i].Job != "" && findings[i].File == "" {
findings[i].File = job.File
findings[i].Line = job.Line
}
}
return findings
}
+45 -19
View File
@@ -6,6 +6,12 @@ import (
"git.k3nny.fr/glint/internal/model"
)
// needEntry is a parsed element from a job's needs: list.
type needEntry struct {
job string
optional bool // true when the needs entry carries optional: true
}
func checkNeeds(p *model.Pipeline) []Finding {
var findings []Finding
@@ -15,7 +21,7 @@ func checkNeeds(p *model.Pipeline) []Finding {
stageIndex[s] = i
}
// needsGraph maps each job to the list of jobs it depends on.
// needsGraph maps each job to the jobs it depends on (existing jobs only).
// Used for cycle detection after individual checks.
needsGraph := make(map[string][]string)
@@ -24,32 +30,47 @@ func checkNeeds(p *model.Pipeline) []Finding {
continue
}
neededNames := parseNeedJobNames(job.Needs)
needsGraph[name] = neededNames
entries := parseNeedEntries(job.Needs)
jobStageIdx, jobHasStage := stageIndex[job.Stage]
for _, needed := range neededNames {
neededJob, exists := p.Jobs[needed]
for _, entry := range entries {
neededJob, exists := p.Jobs[entry.job]
if !exists {
// optional: true means GitLab CI will silently skip the
// dependency when the job is absent (e.g. from a conditional
// include). Downgrade to warning so users are informed without
// failing the lint.
sev := Error
if entry.optional {
sev = Warning
}
findings = append(findings, Finding{
Severity: Error,
Severity: sev,
Rule: RuleNeedsUnknown,
Job: name,
Message: fmt.Sprintf("needs unknown job %q", needed),
File: job.File,
Line: job.Line,
Message: fmt.Sprintf("needs unknown job %q", entry.job),
})
continue
}
// Add to the cycle-detection graph only when the dep exists.
needsGraph[name] = append(needsGraph[name], entry.job)
// A job cannot need a job in a later stage.
if len(p.Stages) > 0 && jobHasStage && neededJob.Stage != "" {
neededStageIdx, neededHasStage := stageIndex[neededJob.Stage]
if neededHasStage && neededStageIdx > jobStageIdx {
findings = append(findings, Finding{
Severity: Error,
Rule: RuleNeedsStageOrder,
Job: name,
File: job.File,
Line: job.Line,
Message: fmt.Sprintf(
"needs %q which is in a later stage (%q after %q)",
needed, neededJob.Stage, job.Stage,
entry.job, neededJob.Stage, job.Stage,
),
})
}
@@ -57,32 +78,33 @@ func checkNeeds(p *model.Pipeline) []Finding {
}
}
findings = append(findings, detectNeedsCycles(needsGraph)...)
findings = append(findings, detectNeedsCycles(needsGraph, p.Jobs)...)
return findings
}
// parseNeedJobNames extracts job names from a needs: list.
// Each element is either a plain string or a map with a "job" key.
// Cross-pipeline needs (maps with a "pipeline" key) are skipped.
func parseNeedJobNames(needs []any) []string {
var names []string
// parseNeedEntries extracts needs entries from a needs: list, preserving the
// optional flag. Each element is a plain string (job name) or a map with a
// "job" key. Cross-pipeline needs (maps with a "pipeline" key) are skipped.
func parseNeedEntries(needs []any) []needEntry {
var entries []needEntry
for _, n := range needs {
switch v := n.(type) {
case string:
names = append(names, v)
entries = append(entries, needEntry{job: v})
case map[string]any:
if _, crossPipeline := v["pipeline"]; crossPipeline {
continue
}
if job, ok := v["job"].(string); ok {
names = append(names, job)
optional, _ := v["optional"].(bool)
entries = append(entries, needEntry{job: job, optional: optional})
}
}
}
return names
return entries
}
func detectNeedsCycles(graph map[string][]string) []Finding {
func detectNeedsCycles(graph map[string][]string, jobs map[string]model.Job) []Finding {
const (
unvisited = 0
visiting = 1
@@ -101,9 +123,13 @@ func detectNeedsCycles(graph map[string][]string) []Finding {
case visiting:
if !reported[name] {
reported[name] = true
j := jobs[name]
findings = append(findings, Finding{
Severity: Error,
Rule: RuleNeedsCycle,
Job: name,
File: j.File,
Line: j.Line,
Message: fmt.Sprintf("circular dependency in needs: %v → %s", path, name),
})
}
+121
View File
@@ -0,0 +1,121 @@
package linter
// Rule ID constants. Each ID is stable across versions and uniquely identifies
// one lint check. Use these when filtering output, writing suppression rules,
// or referencing a check in documentation.
//
// Prefix GL = Glint / GitLab CI lint.
// Numbering is sequential by category; gaps may appear as rules are added.
const (
// ── Pipeline-level ──────────────────────────────────────────────────────
// GL001: no stages: block defined; GitLab falls back to default stages.
RuleNoStages = "GL001"
// GL002: workflow.rules[n].when has an invalid value (only always/never allowed).
RuleWorkflowWhen = "GL002"
// ── Job structure ────────────────────────────────────────────────────────
// GL003: job is missing a required script: (or run:) field.
RuleMissingScript = "GL003"
// GL004: job references a stage not declared in stages:.
RuleUnknownStage = "GL004"
// GL005: only: and rules: used together on the same job.
RuleOnlyRulesConflict = "GL005"
// GL006: except: and rules: used together on the same job.
RuleExceptRulesConflict = "GL006"
// GL007: only:/except: used (deprecated; prefer rules:).
RuleDeprecatedOnly = "GL007"
// ── Keyword constraints ──────────────────────────────────────────────────
// GL008: when: has an invalid value.
RuleInvalidWhen = "GL008"
// GL009: when: delayed without start_in:.
RuleDelayedNoStartIn = "GL009"
// GL010: start_in: set when when: is not delayed.
RuleStartInNoDelayed = "GL010"
// GL011: parallel: value is invalid (integer out of range or map missing matrix:).
RuleInvalidParallel = "GL011"
// GL012: retry: integer is out of range 02, or retry: is neither int nor map.
RuleInvalidRetry = "GL012"
// GL013: retry.when: contains an unrecognised failure type.
RuleInvalidRetryWhen = "GL013"
// GL014: allow_failure: is not a boolean or a map with exit_codes:.
RuleInvalidAllowFailure = "GL014"
// GL015: interruptible: is not a boolean.
RuleInvalidInterruptible = "GL015"
// GL016: trigger: job also defines script: (mutually exclusive).
RuleTriggerWithScript = "GL016"
// GL017: trigger: map does not specify project: or include:.
RuleInvalidTrigger = "GL017"
// GL018: coverage: is not a regex pattern wrapped in /.
RuleInvalidCoverage = "GL018"
// GL019: release: is missing required tag_name:, or is not a map.
RuleInvalidRelease = "GL019"
// GL020: environment: has an invalid url/action configuration.
RuleInvalidEnvironment = "GL020"
// GL021: artifacts: has an invalid when/expose_as configuration.
RuleInvalidArtifacts = "GL021"
// GL022: pages job artifacts.paths does not include public/.
RulePagesPublic = "GL022"
// GL023: cache: has an invalid when/policy value.
RuleInvalidCache = "GL023"
// GL024: rules[n].when has an invalid value.
RuleInvalidRulesWhen = "GL024"
// GL025: image: map form is missing a name: key.
RuleInvalidImage = "GL025"
// GL026: inherit.default or inherit.variables is not a boolean or list.
RuleInvalidInherit = "GL026"
// ── Cross-job graph ──────────────────────────────────────────────────────
// GL027: needs: references a job that does not exist in the pipeline.
RuleNeedsUnknown = "GL027"
// GL028: needs: references a job in a later stage than the current job.
RuleNeedsStageOrder = "GL028"
// GL029: circular dependency detected in the needs: graph.
RuleNeedsCycle = "GL029"
// GL030: dependencies: references a job that does not exist.
RuleUnknownDependency = "GL030"
// GL031: dependencies: references a job in the same or a later stage.
RuleDependencyStage = "GL031"
// ── Expression validation ────────────────────────────────────────────────
// GL032: rules:if: references a variable not declared in pipeline variables:,
// the job's own variables:, or any workflow:rules:variables: block.
// May be a false positive for variables set in GitLab CI/CD project settings.
RuleUndeclaredVariable = "GL032"
// GL033: every rule in a job's rules: block has when: never, so the job
// can never be included in any pipeline run.
RuleDeadRules = "GL033"
)
+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 {
+158
View File
@@ -0,0 +1,158 @@
package linter
import (
"fmt"
"strings"
"git.k3nny.fr/glint/internal/model"
)
// predefinedVarPrefixes lists GitLab-maintained variable namespaces that are
// always available without an explicit declaration in variables: blocks.
var predefinedVarPrefixes = []string{
"CI_", // most predefined CI variables (CI_COMMIT_BRANCH, CI_JOB_ID, …)
"GITLAB_", // user/project metadata (GITLAB_USER_ID, GITLAB_FEATURES, …)
"FF_", // GitLab feature flags
"RUNNER_", // runner-level variables
"TRIGGER_", // trigger token variables passed from upstream pipelines
"CHAT_", // ChatOps variables
}
func isPredefinedVar(name string) bool {
for _, prefix := range predefinedVarPrefixes {
if strings.HasPrefix(name, prefix) {
return true
}
}
return false
}
// extractIfVars returns every variable name referenced in a rules:if: expression.
// String literals are skipped so that dollar signs inside quoted values are
// not mistaken for variable references.
func extractIfVars(expr string) []string {
var names []string
i := 0
for i < len(expr) {
switch expr[i] {
case '"', '\'':
quote := expr[i]
i++
for i < len(expr) && expr[i] != quote {
if expr[i] == '\\' {
i++ // skip escaped character
}
i++
}
if i < len(expr) {
i++ // consume closing quote
}
case '$':
i++ // consume '$'
if i < len(expr) && expr[i] == '{' {
i++ // consume '{'
start := i
for i < len(expr) && isVarNameByte(expr[i]) {
i++
}
if i < len(expr) && expr[i] == '}' && i > start {
names = append(names, expr[start:i])
i++ // consume '}'
}
} else {
start := i
for i < len(expr) && isVarNameByte(expr[i]) {
i++
}
if i > start {
names = append(names, expr[start:i])
}
}
default:
i++
}
}
return names
}
func isVarNameByte(b byte) bool {
return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') || b == '_'
}
// checkVariableRefs warns when a rules:if: expression references a variable that
// is not declared in pipeline variables:, the job's own variables:, or any
// workflow:rules:variables: block. Predefined GitLab CI variables (CI_*, GITLAB_*,
// …) are always exempt. Variables set in GitLab CI/CD project settings are
// invisible to glint, so the finding is a WARNING rather than an error.
func checkVariableRefs(p *model.Pipeline) []Finding {
pipelineVars := make(map[string]bool, len(p.Variables))
for k := range p.Variables {
pipelineVars[k] = true
}
// Union of all variables any workflow rule might inject into the context.
workflowRuleVars := make(map[string]bool)
if p.Workflow != nil {
for _, rule := range p.Workflow.Rules {
for k := range rule.Variables {
workflowRuleVars[k] = true
}
}
}
var findings []Finding
// Check workflow rules:if: expressions.
if p.Workflow != nil {
seen := make(map[string]bool)
for i, rule := range p.Workflow.Rules {
if rule.If == "" {
continue
}
for _, varName := range extractIfVars(rule.If) {
if isPredefinedVar(varName) || pipelineVars[varName] || seen[varName] {
continue
}
seen[varName] = true
findings = append(findings, Finding{
Severity: Warning,
Rule: RuleUndeclaredVariable,
File: p.SourceFile,
Message: fmt.Sprintf("workflow.rules[%d].if: $%s is not declared in pipeline variables:", i, varName),
})
}
}
}
// Check each job's rules:if: expressions.
for name, job := range p.Jobs {
jobVars := make(map[string]bool, len(job.Variables))
for k := range job.Variables {
jobVars[k] = true
}
// Deduplicate per (job, varName): report each undeclared variable once per job.
seen := make(map[string]bool)
for i, rule := range job.Rules {
if rule.If == "" {
continue
}
for _, varName := range extractIfVars(rule.If) {
if isPredefinedVar(varName) || pipelineVars[varName] || jobVars[varName] || workflowRuleVars[varName] || seen[varName] {
continue
}
seen[varName] = true
findings = append(findings, Finding{
Severity: Warning,
Rule: RuleUndeclaredVariable,
Job: name,
File: job.File,
Line: job.Line,
Message: fmt.Sprintf("rules[%d].if: $%s is not declared in pipeline or job variables:", i, varName),
})
}
}
}
return findings
}
+214
View File
@@ -0,0 +1,214 @@
package linter
import (
"testing"
"git.k3nny.fr/glint/internal/model"
)
func TestExtractIfVars(t *testing.T) {
cases := []struct {
name string
expr string
want []string
}{
{
name: "simple variable",
expr: `$MY_VAR == "value"`,
want: []string{"MY_VAR"},
},
{
name: "curly brace syntax",
expr: `${MY_VAR} != null`,
want: []string{"MY_VAR"},
},
{
name: "multiple variables",
expr: `$BRANCH == "main" && $DEPLOY_ENV == "prod"`,
want: []string{"BRANCH", "DEPLOY_ENV"},
},
{
name: "dollar sign inside string literal is skipped",
expr: `$REAL_VAR == "$not_a_var"`,
want: []string{"REAL_VAR"},
},
{
name: "no variables",
expr: `"main" == "main"`,
want: nil,
},
{
name: "regex rhs variable",
expr: `$BRANCH =~ $PATTERN`,
want: []string{"BRANCH", "PATTERN"},
},
{
name: "multiline expression",
expr: "$BRANCH == \"main\" ||\n$BRANCH == \"develop\"",
want: []string{"BRANCH", "BRANCH"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := extractIfVars(tc.expr)
if len(got) != len(tc.want) {
t.Fatalf("extractIfVars(%q) = %v, want %v", tc.expr, got, tc.want)
}
for i, v := range got {
if v != tc.want[i] {
t.Errorf("[%d] got %q, want %q", i, v, tc.want[i])
}
}
})
}
}
func TestIsPredefinedVar(t *testing.T) {
cases := []struct {
input string
want bool
}{
{"CI_COMMIT_BRANCH", true},
{"CI_JOB_TOKEN", true},
{"GITLAB_USER_ID", true},
{"GITLAB_FEATURES", true},
{"FF_SOME_FLAG", true},
{"RUNNER_ID", true},
{"TRIGGER_PAYLOAD", true},
{"CHAT_INPUT", true},
{"MY_CUSTOM_VAR", false},
{"DEPLOY_ENV", false},
{"FEATURE_ENABLED", false},
}
for _, tc := range cases {
t.Run(tc.input, func(t *testing.T) {
if got := isPredefinedVar(tc.input); got != tc.want {
t.Errorf("isPredefinedVar(%q) = %v, want %v", tc.input, got, tc.want)
}
})
}
}
func TestCheckVariableRefs(t *testing.T) {
cases := []struct {
name string
pipeline *model.Pipeline
wantWarnings int
}{
{
name: "declared pipeline variable — no warning",
pipeline: &model.Pipeline{
Variables: map[string]any{"MY_VAR": "value"},
Jobs: map[string]model.Job{
"job-a": {Rules: []model.Rule{{If: `$MY_VAR == "value"`}}},
},
},
wantWarnings: 0,
},
{
name: "predefined CI variable — no warning",
pipeline: &model.Pipeline{
Jobs: map[string]model.Job{
"job-a": {Rules: []model.Rule{{If: `$CI_COMMIT_BRANCH == "main"`}}},
},
},
wantWarnings: 0,
},
{
name: "undeclared variable — one warning",
pipeline: &model.Pipeline{
Jobs: map[string]model.Job{
"job-a": {Rules: []model.Rule{{If: `$UNDEFINED_VAR == "yes"`}}},
},
},
wantWarnings: 1,
},
{
name: "same undeclared var in multiple rules — one warning per job",
pipeline: &model.Pipeline{
Jobs: map[string]model.Job{
"job-a": {
Rules: []model.Rule{
{If: `$UNDEFINED_VAR == "yes"`},
{If: `$UNDEFINED_VAR == "no"`},
},
},
},
},
wantWarnings: 1,
},
{
name: "job-level variable — no warning",
pipeline: &model.Pipeline{
Jobs: map[string]model.Job{
"job-a": {
Variables: map[string]any{"LOCAL_VAR": "value"},
Rules: []model.Rule{{If: `$LOCAL_VAR == "value"`}},
},
},
},
wantWarnings: 0,
},
{
name: "workflow rule variable available to job rules — no warning",
pipeline: &model.Pipeline{
Workflow: &model.Workflow{
Rules: []model.Rule{
{
If: `$CI_COMMIT_BRANCH == "main"`,
Variables: map[string]any{"DEPLOY_ENV": "production"},
},
},
},
Jobs: map[string]model.Job{
"job-a": {Rules: []model.Rule{{If: `$DEPLOY_ENV == "production"`}}},
},
},
wantWarnings: 0,
},
{
name: "undeclared variable in workflow rules:if",
pipeline: &model.Pipeline{
Workflow: &model.Workflow{
Rules: []model.Rule{{If: `$UNDECLARED == "main"`}},
},
Jobs: map[string]model.Job{},
},
wantWarnings: 1,
},
{
name: "two jobs each with a different undeclared variable — two warnings",
pipeline: &model.Pipeline{
Jobs: map[string]model.Job{
"job-a": {Rules: []model.Rule{{If: `$UNDEF_A == "x"`}}},
"job-b": {Rules: []model.Rule{{If: `$UNDEF_B == "y"`}}},
},
},
wantWarnings: 2,
},
{
name: "no rules — no warnings",
pipeline: &model.Pipeline{
Jobs: map[string]model.Job{
"job-a": {Script: "echo ok"},
},
},
wantWarnings: 0,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
findings := checkVariableRefs(tc.pipeline)
count := 0
for _, f := range findings {
if f.Severity == Warning && f.Rule == RuleUndeclaredVariable {
count++
}
}
if count != tc.wantWarnings {
t.Errorf("got %d GL032 warnings, want %d; findings: %v", count, tc.wantWarnings, findings)
}
})
}
}
+92 -7
View File
@@ -13,14 +13,24 @@ func Parse(path string) (*Pipeline, error) {
if err != nil {
return nil, fmt.Errorf("reading file: %w", err)
}
return ParseBytes(data)
p, err := ParseBytes(data)
if err != nil {
return nil, err
}
p.SourceFile = path
p.SetJobOrigin(path)
return p, nil
}
// ParseBytes parses YAML from an in-memory byte slice.
func ParseBytes(data []byte) (*Pipeline, error) {
// First pass: decode into a raw map to extract job keys.
var raw map[string]yaml.Node
if err := yaml.Unmarshal(data, &raw); err != nil {
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).
var doc yaml.Node
if err := yaml.Unmarshal(data, &doc); err != nil {
return nil, fmt.Errorf("parsing YAML: %w", err)
}
@@ -32,23 +42,98 @@ func ParseBytes(data []byte) (*Pipeline, error) {
p.Jobs = make(map[string]Job)
p.RawJobs = make(map[string]map[string]any)
for key, node := range raw {
if doc.Kind != yaml.DocumentNode || len(doc.Content) == 0 {
return p, nil
}
root := doc.Content[0]
if root.Kind != yaml.MappingNode {
return p, nil
}
// Walk root mapping in key/value pairs.
for i := 0; i+1 < len(root.Content); i += 2 {
keyNode := root.Content[i]
valNode := root.Content[i+1]
key := keyNode.Value
if ReservedKeys[key] {
continue
}
var rawMap map[string]any
if err := node.Decode(&rawMap); err != nil {
if err := valNode.Decode(&rawMap); err != nil {
return nil, fmt.Errorf("parsing raw job %q: %w", key, err)
}
p.RawJobs[key] = rawMap
var j Job
if err := node.Decode(&j); err != nil {
if err := valNode.Decode(&j); err != nil {
return nil, fmt.Errorf("parsing job %q: %w", key, err)
}
j.Name = key
j.Line = keyNode.Line // exact line of the job name key
p.Jobs[key] = j
}
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
}
+22 -7
View File
@@ -3,8 +3,9 @@ package model
// Pipeline represents the top-level structure of a .gitlab-ci.yml file.
// Unknown top-level keys are collected into Jobs.
type Pipeline struct {
SourceFile string // path of the root pipeline file; set by Parse
Stages []string `yaml:"stages"`
Variables map[string]string `yaml:"variables"`
Variables map[string]any `yaml:"variables"` // string or {value,description,options} map
Default *DefaultConfig `yaml:"default"`
Include []any `yaml:"include"`
Workflow *Workflow `yaml:"workflow"`
@@ -13,10 +14,21 @@ type Pipeline struct {
RawJobs map[string]map[string]any `yaml:"-"` // pre-resolution raw maps, used by the resolver
}
// SetJobOrigin sets the File field on all jobs that don't already have one.
// Called after ParseBytes to record which file each job came from.
func (p *Pipeline) SetJobOrigin(file string) {
for name, j := range p.Jobs {
if j.File == "" {
j.File = file
}
p.Jobs[name] = j
}
}
type DefaultConfig struct {
Image string `yaml:"image"`
BeforeScript []string `yaml:"before_script"`
AfterScript []string `yaml:"after_script"`
Image any `yaml:"image"` // string or {name,pull_policy,...} map
BeforeScript any `yaml:"before_script"` // []string or string (block scalar)
AfterScript any `yaml:"after_script"` // []string or string
Cache any `yaml:"cache"`
Artifacts any `yaml:"artifacts"`
Retry any `yaml:"retry"`
@@ -30,6 +42,8 @@ type Workflow struct {
type Job struct {
Name string // set by parser, not from YAML
File string // source file; set by Parse / resolver
Line int // line of the job key in its source file; set by parser
Stage string `yaml:"stage"`
Script any `yaml:"script"` // []string or string (block scalar)
Run any `yaml:"run"` // alternative to script (CI steps)
@@ -37,7 +51,7 @@ type Job struct {
AfterScript any `yaml:"after_script"` // []string or string
Image any `yaml:"image"`
Services []any `yaml:"services"`
Variables map[string]string `yaml:"variables"`
Variables map[string]any `yaml:"variables"` // string or {value,description,options} map
Rules []Rule `yaml:"rules"`
Only any `yaml:"only"`
Except any `yaml:"except"`
@@ -68,8 +82,9 @@ type Job struct {
type Rule struct {
If string `yaml:"if"`
When string `yaml:"when"`
Changes []string `yaml:"changes"`
Exists []string `yaml:"exists"`
Changes any `yaml:"changes"` // []string or {paths,compare_to} map
Exists any `yaml:"exists"` // []string or map form
Variables map[string]any `yaml:"variables"` // set/override variables when rule matches (GitLab CI 15.0+)
}
// ReservedKeys are top-level GitLab CI keys that are NOT job definitions.
+5
View File
@@ -80,6 +80,11 @@ func Resolve(p *model.Pipeline) ([]ExtendWarning, error) {
return extWarnings, fmt.Errorf("job %q: re-decoding merged definition: %w", name, err)
}
j.Name = name
// Preserve source location — File/Line are not part of the YAML map
// and are lost during the encode/decode round-trip.
orig := p.Jobs[name]
j.File = orig.File
j.Line = orig.Line
p.Jobs[name] = j
}
+59 -3
View File
@@ -90,7 +90,14 @@ func resolveIncludes(p *model.Pipeline, includes []any, cfg fetcher.GitLabConfig
continue
}
// remote, template — resolved by GitLab at runtime, skip silently.
if remote, _ := entry["remote"].(string); remote != "" {
w, ew := resolveRemoteInclude(p, remote, cfg, rootDir, visited)
warnings = append(warnings, w...)
extWarnings = append(extWarnings, ew...)
continue
}
// template — resolved by GitLab at runtime, skip silently.
}
return warnings, extWarnings
}
@@ -117,6 +124,7 @@ func resolveLocalInclude(p *model.Pipeline, rawPath string, cfg fetcher.GitLabCo
if err != nil {
return []IncludeWarning{{Label: label, Err: fmt.Errorf("parsing YAML: %w", err)}}, nil
}
included.SetJobOrigin(absPath)
// Recursively resolve the included file's own includes first, merging
// everything into `included` before we merge it into the parent `p`.
@@ -132,6 +140,39 @@ func resolveLocalInclude(p *model.Pipeline, rawPath string, cfg fetcher.GitLabCo
return warnings, extWarnings
}
// resolveRemoteInclude fetches a plain HTTPS URL, parses it as CI YAML, and
// merges it into p. Sub-includes of the fetched file are resolved recursively.
func resolveRemoteInclude(p *model.Pipeline, rawURL string, cfg fetcher.GitLabConfig, rootDir string, visited map[string]bool) ([]IncludeWarning, []ExtendWarning) {
label := "remote " + rawURL
if visited[rawURL] {
return nil, nil
}
visited[rawURL] = true
data, err := fetcher.FetchURL(rawURL)
if err != nil {
return []IncludeWarning{{Label: label, Err: err}}, nil
}
included, err := model.ParseBytes(data)
if err != nil {
return []IncludeWarning{{Label: label, Err: fmt.Errorf("parsing YAML: %w", err)}}, nil
}
included.SetJobOrigin(rawURL)
var warnings []IncludeWarning
var extWarnings []ExtendWarning
if len(included.Include) > 0 {
w, ew := resolveIncludes(included, included.Include, cfg, rootDir, visited)
warnings = append(warnings, w...)
extWarnings = append(extWarnings, ew...)
}
mergeIncluded(p, included)
return warnings, extWarnings
}
// resolveProjectInclude fetches all files listed under a single project: entry
// and merges them into p.
func resolveProjectInclude(p *model.Pipeline, entry map[string]any, project string, cfg fetcher.GitLabConfig, rootDir string, visited map[string]bool) ([]IncludeWarning, []ExtendWarning) {
@@ -161,6 +202,7 @@ func resolveProjectInclude(p *model.Pipeline, entry map[string]any, project stri
warnings = append(warnings, IncludeWarning{Label: label, Err: fmt.Errorf("parsing YAML: %w", err)})
continue
}
included.SetJobOrigin(label)
if len(included.Include) > 0 {
w, ew := resolveIncludes(included, included.Include, cfg, rootDir, visited)
@@ -200,6 +242,7 @@ func resolveComponentInclude(p *model.Pipeline, ref string, cfg fetcher.GitLabCo
if err != nil {
return IncludeWarning{Label: label, Err: fmt.Errorf("parsing component YAML: %w", err)}, nil, true
}
included.SetJobOrigin(label)
var extWarnings []ExtendWarning
if len(included.Include) > 0 {
@@ -283,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 {
@@ -305,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
}
}
}
}
+59
View File
@@ -0,0 +1,59 @@
---
# dead_rules.yml
# Exercises GL033: rules: block where every rule has when: never.
# All flagged jobs produce a WARNING (exit 0). Valid jobs must not be flagged.
stages:
- build
- test
- deploy
# ── Jobs that should trigger GL033 ─────────────────────────────────────────
# Single bare catch-all never — job is always excluded.
disabled-job:
stage: build
script: echo disabled
rules:
- when: never
# Multiple rules, all when: never — no branch can activate this job.
dead-multi:
stage: test
script: echo dead
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
when: never
- if: '$CI_COMMIT_BRANCH == "develop"'
when: never
- when: never
# ── Jobs that must NOT trigger GL033 ───────────────────────────────────────
# First rule can activate.
main-only:
stage: build
script: echo main
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
when: on_success
- when: never
# Rule with no when: — defaults to on_success, so job is reachable.
implicit-on-success:
stage: test
script: echo implicit
rules:
- if: '$CI_COMMIT_BRANCH == "develop"'
# Manual is not never — job is reachable (just gated).
manual-gate:
stage: deploy
script: echo deploy
rules:
- when: manual
# No rules at all — always active.
always-active:
stage: build
script: echo always
+14
View File
@@ -0,0 +1,14 @@
---
# Exercises include: remote: URL fetching and sub-include recursion.
# The remote file is a real public GitLab CI template; it may contain its own
# includes which should also be resolved. Exit code is 0 (warnings allowed).
stages:
- test
include:
- remote: https://gitlab.com/gitlab-org/gitlab/-/raw/master/lib/gitlab/ci/templates/Bash.gitlab-ci.yml
local-job:
stage: test
script:
- echo "local job alongside remote include"
+9
View File
@@ -38,3 +38,12 @@ missing-needs-job:
- echo "bad"
needs:
- nonexistent-job # ERROR: job doesn't exist
# optional: true — missing dep should be WARNING not ERROR
optional-needs-job:
stage: build
script:
- echo "I depend on something that may not exist"
needs:
- job: nonexistent-optional-job
optional: true
+51
View File
@@ -0,0 +1,51 @@
---
# rules_if_expr.yml
# Exercises the rules:if: expression evaluator for:
# - Multi-line block-scalar expressions (|| on next line)
# - ${VAR} curly-brace variable syntax
# - Regex flags (/pattern/i case-insensitive)
# - Parenthesised compound expressions
# Expected: exits 0 (lints clean with no context).
stages:
- build
- deploy
variables:
DEPLOY_ENVIRONMENTS:
value: "staging"
description: "Target deployment environment"
build:
stage: build
script: make build
rules:
# Multi-line expression: || on next line
- if: |
$CI_COMMIT_BRANCH == "main" ||
$CI_COMMIT_BRANCH == "develop"
when: on_success
- when: never
deploy-feature:
stage: deploy
script: make deploy
rules:
# ${VAR} curly-brace syntax
- if: '${CI_COMMIT_BRANCH} != null && ${CI_COMMIT_TAG} == null'
when: manual
- when: never
release:
stage: deploy
script: make release
rules:
# Case-insensitive regex flag
- if: '$CI_COMMIT_BRANCH =~ /^(main|master)$/i'
when: on_success
# Parenthesised compound with multi-line
- if: >-
($CI_COMMIT_TAG != null) &&
($CI_PIPELINE_SOURCE == "push" || $CI_PIPELINE_SOURCE == "web")
when: on_success
- when: never
+87
View File
@@ -0,0 +1,87 @@
---
# Exercises multi-line script patterns and extended variable declarations.
# Ref: https://docs.gitlab.com/ci/yaml/script/#split-long-commands
# Ref: https://docs.gitlab.com/ee/ci/yaml/#variablesdescription
# All patterns here must parse cleanly (exit 0).
stages:
- build
- test
- deploy
# Pipeline-level variables: plain strings and extended {value, description} map form.
variables:
PLAIN_VAR: "hello"
DEPLOY_ENV:
value: "staging"
description: "The deployment target. Set to staging or production."
RETRIES:
value: "3"
description: "Number of retry attempts."
options:
- "1"
- "3"
- "5"
default:
# image in map form (name + pull_policy)
image:
name: alpine:latest
pull_policy: if-not-present
# before_script as a block scalar (not a list)
before_script:
- apk add --no-cache curl git
build-literal-block:
stage: build
# script items using literal block scalar (|)
script:
- |
if [[ "$DEPLOY_ENV" == "production" ]]; then
echo "Production build"
else
echo "Non-production build"
fi
- echo "Build step done"
build-folded-block:
stage: build
# script items using folded block scalar (>)
script:
- >
apt-get update -qq &&
apt-get install -y curl wget
- echo "Packages installed"
before_script:
- |
echo "Job-level before_script"
echo "Using literal block scalar"
test-job:
stage: test
script:
- echo "Running tests"
- |
set -e
go test ./...
echo "Tests passed"
# Job-level variable with extended form
variables:
TEST_FLAG:
value: "true"
description: "Enable verbose test output"
# rules.changes in map form (GitLab 15.3+)
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
changes:
paths:
- "**/*.go"
compare_to: "main"
when: on_success
- when: on_success
deploy-job:
stage: deploy
script:
- echo "Deploying to $DEPLOY_ENV"
when: manual
+51
View File
@@ -0,0 +1,51 @@
---
# variable_refs.yml
# Verifies that GL032 does not fire for variables that are declared or predefined.
# Covers: pipeline variables:, job variables:, workflow:rules:variables:, and CI_* prefixes.
# Expected: exits 0 (no errors; no GL032 warnings).
stages:
- build
- deploy
variables:
DEPLOY_TARGET: "staging"
FEATURE_FLAG: "false"
workflow:
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
variables:
DEPLOY_TARGET: "production"
- when: always
build:
stage: build
script: make build
rules:
# pipeline-level variable — GL032 must not fire
- if: '$DEPLOY_TARGET == "staging"'
when: on_success
# predefined CI variable — GL032 must not fire
- if: '$CI_COMMIT_BRANCH =~ /^feat\/.+/'
when: manual
deploy:
stage: deploy
script: make deploy
variables:
DEPLOY_REGION: "us-east-1"
rules:
# pipeline-level variable — no warning
- if: '$FEATURE_FLAG == "true"'
when: never
# workflow-rule-injected variable — no warning
- if: '$DEPLOY_TARGET == "production"'
when: on_success
# job-level variable — no warning
- if: '$DEPLOY_REGION == "us-east-1"'
when: on_success
# predefined GITLAB_ variable — no warning
- if: '$GITLAB_USER_LOGIN == "bot"'
when: never
- when: manual
+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
+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:
# --branch main → only build active (no workflow rule matches; WORKFLOW var not set)
# --branch develop → deploy-staging active, deploy-prod skipped
# --branch feat/x → only build active (when: always fallback, no deploy vars)
# --var WORKFLOW=gitflow --branch us/feature → deploy-prod + build active
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
DEPLOY: true
BUILD: 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" && $DEPLOY == "true"'
when: on_success
- when: never
deploy-staging:
stage: deploy
script: make deploy ENV=staging
rules:
- if: '$DEPLOY_TARGET == "staging" '
when: on_success
- when: never