9 Commits

Author SHA1 Message Date
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
40 changed files with 1718 additions and 138 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/...
+25 -1
View File
@@ -5,16 +5,40 @@ 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/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
This project uses [Semantic Versioning](https://semver.org). This project uses [Semantic Versioning](https://semver.org).
## [Unreleased] ## [0.2.11] - 2026-06-12
### Added ### 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. - **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. - **`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 ### 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. - **`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). - **`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).
+59 -54
View File
@@ -6,7 +6,7 @@
<p align="center"> <p align="center">
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-blue.svg" alt="License"></a> <a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-blue.svg" alt="License"></a>
<a href="CHANGELOG.md"><img src="https://img.shields.io/badge/release-v0.2.0-blue.svg" alt="Release"></a> <a href="CHANGELOG.md"><img src="https://img.shields.io/badge/release-v0.2.11-blue.svg" alt="Release"></a>
</p> </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. > **Disclaimer:** This tool was built through iterative AI-assisted development with [Claude](https://claude.ai). It is experimental, incomplete, and not intended for production use. Coverage of GitLab CI keywords is best-effort and may lag behind GitLab's evolving spec. Use it at your own discretion — no correctness guarantees are made. Contributions and bug reports are welcome.
@@ -216,8 +216,12 @@ Rules without an `if:` clause always match.
### Example output ### Example output
``` ```
# Clean pipeline, no context # Clean pipeline (implicit default: --branch main --source push)
OK: .gitlab-ci.yml — no issues found (5 jobs, 3 stages) 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 # With --branch develop context
Context: branch=develop, source=push Context: branch=develop, source=push
@@ -225,7 +229,7 @@ Context: branch=develop, source=push
Active (3): build, deploy-staging, test Active (3): build, deploy-staging, test
Skipped (2): deploy-prod, release-notes 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 # With --tag v1.0.0 context
Context: tag=v1.0.0, source=push Context: tag=v1.0.0, source=push
@@ -233,75 +237,76 @@ Context: tag=v1.0.0, source=push
Active (4): build, deploy-prod, release-notes, test Active (4): build, deploy-prod, release-notes, test
Skipped (1): deploy-staging 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 # Pipeline with issues
[ERROR] job "deploy": stage "production" is not defined in 'stages' .gitlab-ci.yml:14: GL004 [error] job "deploy": stage "production" is not defined in 'stages'
[ERROR] job "test": needs unknown job "build-app" .gitlab-ci.yml:22: GL027 [error] job "test": needs unknown job "build-app"
[WARNING] job "old-job": 'only'/'except' are deprecated; prefer 'rules' .gitlab-ci.yml:31: GL007 [warning] job "old-job": 'only'/'except' are deprecated; prefer 'rules'
3 finding(s): 2 error(s) 3 finding(s): 2 error(s)
``` ```
## Lint rules ## 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 ### Pipeline-level
| Severity | Rule | | ID | Severity | Rule |
|----------|------| |----|----------|------|
| ERROR | `workflow.rules[*].when` is not `always` or `never` | | GL002 | ERROR | `workflow.rules[*].when` is not `always` or `never` |
| WARNING | No `stages` defined (GitLab falls back to default stages) | | GL001 | WARNING | No `stages` defined (GitLab falls back to default stages) |
### Job-level — structure ### Job-level — structure
| Severity | Rule | | ID | Severity | Rule |
|----------|------| |----|----------|------|
| ERROR | Job is missing required `script` (or `run`) — non-trigger, non-template jobs | | GL003 | ERROR | Job is missing required `script` (or `run`) — non-trigger, non-template jobs |
| ERROR | Job references a `stage` not declared in `stages` | | GL004 | ERROR | Job references a `stage` not declared in `stages` |
| ERROR | `only` and `rules` used together on the same job | | GL005 | ERROR | `only` and `rules` used together on the same job |
| ERROR | `except` and `rules` used together on the same job | | GL006 | ERROR | `except` and `rules` used together on the same job |
| WARNING | `only`/`except` used (deprecated, prefer `rules`) | | GL007 | WARNING | `only`/`except` used (deprecated, prefer `rules`) |
### Job-level — keyword constraints ### Job-level — keyword constraints
| Severity | Rule | | ID | Severity | Rule |
|----------|------| |----|----------|------|
| ERROR | `when` is not one of `on_success`, `on_failure`, `always`, `manual`, `delayed`, `never` | | GL008 | ERROR | `when` is not one of `on_success`, `on_failure`, `always`, `manual`, `delayed`, `never` |
| ERROR | `when: delayed` without `start_in` | | GL009 | ERROR | `when: delayed` without `start_in` |
| ERROR | `start_in` set but `when` is not `delayed` | | GL010 | ERROR | `start_in` set but `when` is not `delayed` |
| ERROR | `parallel` integer not in range 2200 | | GL011 | ERROR | `parallel` integer not in range 2200, or map form missing `matrix` key |
| ERROR | `parallel` map form missing `matrix` key | | GL012 | ERROR | `retry` integer not in range 02, or `retry.max` out of range |
| ERROR | `retry` integer not in range 02 | | GL013 | ERROR | `retry.when` contains an unrecognised failure type |
| ERROR | `retry.max` not in range 02 | | GL014 | ERROR | `allow_failure` is not a boolean or a map with `exit_codes` |
| ERROR | `retry.when` contains an invalid failure type | | GL015 | ERROR | `interruptible` is not a boolean |
| ERROR | `allow_failure` is not a boolean or a map with `exit_codes` | | GL016 | ERROR | `trigger` job also has `script` |
| ERROR | `interruptible` is not a boolean | | GL017 | ERROR | `trigger` map missing `project` or `include` |
| ERROR | `trigger` job also has `script` | | GL018 | ERROR | `coverage` is not a regex pattern wrapped in `/` |
| ERROR | `trigger` map missing `project` or `include` | | GL019 | ERROR | `release` missing required `tag_name`, or is not a map |
| ERROR | `coverage` is not a regex pattern wrapped in `/` | | GL020 | ERROR | `environment.url` set without `environment.name`, or invalid `environment.action` |
| ERROR | `release` missing required `tag_name` | | GL021 | ERROR | `artifacts.when` invalid, or `artifacts.expose_as` set without `artifacts.paths` |
| ERROR | `environment.url` set without `environment.name` | | GL022 | WARNING | `pages` job `artifacts.paths` does not include `public` |
| ERROR | `environment.action` is not one of `start`, `stop`, `prepare`, `verify`, `access` | | GL023 | ERROR | `cache.when` or `cache.policy` has an invalid value |
| ERROR | `artifacts.when` is not `on_success`, `on_failure`, or `always` | | GL024 | ERROR | `rules[*].when` is not one of the valid `when` values |
| ERROR | `artifacts.expose_as` set without `artifacts.paths` | | GL025 | ERROR | `image` map form missing `name` key |
| ERROR | `cache.when` is not `on_success`, `on_failure`, or `always` | | GL026 | ERROR | `inherit.default` / `inherit.variables` is not a boolean or list |
| 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` |
### Cross-job graph ### Cross-job graph
| Severity | Rule | | ID | Severity | Rule |
|----------|------| |----|----------|------|
| ERROR | `needs:` references a job that does not exist | | GL027 | ERROR/WARNING | `needs:` references a job that does not exist (WARNING when `optional: true`) |
| ERROR | `needs:` references a job in a later stage | | GL028 | ERROR | `needs:` references a job in a later stage |
| ERROR | Circular dependency detected in `needs:` graph | | GL029 | ERROR | Circular dependency detected in `needs:` graph |
| ERROR | `dependencies:` references a job that does not exist | | GL030 | ERROR | `dependencies:` references a job that does not exist |
| ERROR | `dependencies:` references a job in the same or a later stage | | GL031 | 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 | ### 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 |
### Hidden jobs (templates) ### Hidden jobs (templates)
+34 -5
View File
@@ -4,7 +4,7 @@ This document tracks planned improvements to `glint`. Items are grouped by theme
--- ---
## Context-aware validation — ✓ single-context shipped in v0.2.0 ## Context-aware validation — ✓ single-context shipped in v0.2.0; expression evaluator hardened post-v0.2.0; implicit defaults and --list-vars shipped v0.2.11
Single-context simulation is fully implemented. Pass `--branch`, `--tag`, `--source`, or `--var` to either `glint check` or `glint graph`; jobs are evaluated and shown as active / manual / skipped. 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,20 @@ 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] 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.
**Remaining work** **Remaining work**
- **Multi-context simulation** — run multiple contexts in one invocation and print a comparison table: - **Multi-context simulation** — run multiple contexts in one invocation and print a comparison table:
@@ -23,7 +37,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 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 - **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,7 +45,7 @@ glint graph tree --branch main .gitlab-ci.yml # tree annotated with [skipped]
The current rule set covers the most common sources of broken pipelines. These are the gaps most likely to matter in practice. The current rule set covers the most common sources of broken pipelines. These are the gaps most likely to matter in practice.
- **Variable reference validation** — warn when a job references `$VAR` (or `${VAR}`) that is not declared anywhere in `variables:`, `default.variables`, or the job itself - ~~**Variable reference validation (GL032)**~~ — ✓ shipped v0.2.11; warns when a `rules:if:` expression references `$VAR` / `${VAR}` not declared anywhere in pipeline YAML; predefined GitLab namespaces (`CI_*`, `GITLAB_*`, …) exempt; variables from included files are also considered
- **`services:` validation** — map form requires `name`; `alias` must be a valid DNS label - **`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) - **`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.) - **`timeout` format** — must be a duration string GitLab understands (`1h 30m`, `90 minutes`, etc.)
@@ -47,7 +61,7 @@ The current rule set covers the most common sources of broken pipelines. These a
## Include resolution ## Include resolution
- ~~**`include: local:`** full resolution~~ — ✓ shipped in v0.2.0; local files are read from disk, recursively resolved, and merged before linting - ~~**`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 - **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 - **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 - **`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 +84,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. 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 - ~~**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 - **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 - **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 - **`when: on_failure` visual distinction** — dashed border or distinct icon for failure-path jobs
@@ -79,6 +94,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 ## 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 - **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 +131,7 @@ The SVG renderer and terminal tree cover the basic layout. These would bring it
## Reliability and developer experience ## 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 - **`--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) - ~~**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` - ~~**Subcommand CLI**~~ — shipped as `v0.2.0` (2026-06-11); `glint check` / `glint graph [mode]` with ruff-style `--help`
+29 -4
View File
@@ -41,6 +41,8 @@ tasks:
ignore_error: true ignore_error: true
- cmd: ./{{.BINARY}} check testdata/keywords_invalid.yml - cmd: ./{{.BINARY}} check testdata/keywords_invalid.yml
ignore_error: true ignore_error: true
- cmd: ./{{.BINARY}} check testdata/includes_remote.yml
ignore_error: false
- cmd: ./{{.BINARY}} check testdata/includes_project.yml - cmd: ./{{.BINARY}} check testdata/includes_project.yml
ignore_error: false ignore_error: false
- cmd: ./{{.BINARY}} check testdata/includes_component.yml - cmd: ./{{.BINARY}} check testdata/includes_component.yml
@@ -57,21 +59,44 @@ tasks:
ignore_error: false ignore_error: false
- cmd: ./{{.BINARY}} check --tag v1.0.0 testdata/context_rules.yml - cmd: ./{{.BINARY}} check --tag v1.0.0 testdata/context_rules.yml
ignore_error: false ignore_error: false
- cmd: ./{{.BINARY}} check samba-testdata/.gitlab-ci.yml - cmd: ./{{.BINARY}} check testdata/rules_if_expr.yml
ignore_error: false 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 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/variable_refs.yml
ignore_error: false
- cmd: ./{{.BINARY}} check testdata/variable_refs_included.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 ignore_error: false
lint-go: lint-go:
desc: Run go vet on all packages desc: Run go vet on all packages
cmd: "{{.GO}} vet ./..." cmd: "{{.GO}} vet ./..."
lint-static:
desc: Run staticcheck on all packages
cmd: "{{.GO}} tool staticcheck ./..."
ci: ci:
desc: Full CI check — vet, test, build, validate desc: Full CI check — vet, staticcheck, test, build, validate
cmds: cmds:
- task: lint-go - task: lint-go
- task: lint-static
- task: test - task: test
- task: build - task: build
- task: validate - task: validate
+138 -4
View File
@@ -64,6 +64,7 @@ func cmdCheck(args []string) {
branch := fs.String("branch", "", "simulate a branch push (sets CI_COMMIT_BRANCH, …)") branch := fs.String("branch", "", "simulate a branch push (sets CI_COMMIT_BRANCH, …)")
tag := fs.String("tag", "", "simulate a tag push (sets CI_COMMIT_TAG, …)") tag := fs.String("tag", "", "simulate a tag push (sets CI_COMMIT_TAG, …)")
source := fs.String("source", "", "set CI_PIPELINE_SOURCE") 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 var vars multiFlag
fs.Var(&vars, "var", "set a CI variable as KEY=VALUE; repeatable") fs.Var(&vars, "var", "set a CI variable as KEY=VALUE; repeatable")
fs.Usage = func() { fs.Usage = func() {
@@ -90,6 +91,7 @@ Options:
--branch <NAME> --branch <NAME>
Simulate a branch push. Populates: CI_COMMIT_BRANCH, Simulate a branch push. Populates: CI_COMMIT_BRANCH,
CI_COMMIT_REF_NAME, CI_COMMIT_REF_SLUG, CI_PIPELINE_SOURCE=push. CI_COMMIT_REF_NAME, CI_COMMIT_REF_SLUG, CI_PIPELINE_SOURCE=push.
[default: main]
--tag <NAME> --tag <NAME>
Simulate a tag push. Populates: CI_COMMIT_TAG, CI_COMMIT_REF_NAME, Simulate a tag push. Populates: CI_COMMIT_TAG, CI_COMMIT_REF_NAME,
@@ -97,18 +99,30 @@ Options:
--source <EVENT> --source <EVENT>
Override CI_PIPELINE_SOURCE. 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> --var <KEY=VALUE>
Set or override a CI variable. Takes precedence over --branch, --tag, Set or override a CI variable. Takes precedence over --branch, --tag,
and --source. Repeatable. 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 -h, --help
Print 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: Examples:
glint check .gitlab-ci.yml 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 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 --token glpat-xxxx --gitlab-url https://gitlab.example.com .gitlab-ci.yml
glint check --branch main --var DEPLOY_ENV=production .gitlab-ci.yml glint check --branch main --var DEPLOY_ENV=production .gitlab-ci.yml
@@ -116,6 +130,12 @@ Examples:
} }
_ = fs.Parse(args) _ = 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 { if fs.NArg() != 1 {
fs.Usage() fs.Usage()
os.Exit(2) os.Exit(2)
@@ -146,6 +166,12 @@ Examples:
} }
ctx := cicontext.New(*branch, *tag, *source, vars) ctx := cicontext.New(*branch, *tag, *source, vars)
if !ctx.IsEmpty() {
enrichContext(ctx, p)
}
if *listVars {
printVars(p, ctx)
}
if !ctx.IsEmpty() { if !ctx.IsEmpty() {
printContext(p, ctx) printContext(p, ctx)
} }
@@ -221,6 +247,7 @@ Options:
evaluated state ([skipped] or [manual]; no tag means active). evaluated state ([skipped] or [manual]; no tag means active).
Populates: CI_COMMIT_BRANCH, CI_COMMIT_REF_NAME, CI_COMMIT_REF_SLUG, Populates: CI_COMMIT_BRANCH, CI_COMMIT_REF_NAME, CI_COMMIT_REF_SLUG,
CI_PIPELINE_SOURCE=push. CI_PIPELINE_SOURCE=push.
[default: main]
--tag <NAME> --tag <NAME>
Simulate a tag push. Populates: CI_COMMIT_TAG, CI_COMMIT_REF_NAME, Simulate a tag push. Populates: CI_COMMIT_TAG, CI_COMMIT_REF_NAME,
@@ -228,18 +255,29 @@ Options:
--source <EVENT> --source <EVENT>
Override CI_PIPELINE_SOURCE. 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> --var <KEY=VALUE>
Set or override a CI variable. Repeatable. 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 -h, --help
Print 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: Examples:
glint graph .gitlab-ci.yml glint graph .gitlab-ci.yml
glint graph tree .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 includes .gitlab-ci.yml > includes.mmd
glint graph pipeline .gitlab-ci.yml glint graph pipeline .gitlab-ci.yml
glint graph pipeline --out /tmp/graphs .gitlab-ci.yml glint graph pipeline --out /tmp/graphs .gitlab-ci.yml
@@ -249,10 +287,17 @@ Examples:
branch := fs.String("branch", "", "simulate a branch push (sets CI_COMMIT_BRANCH, …)") branch := fs.String("branch", "", "simulate a branch push (sets CI_COMMIT_BRANCH, …)")
tag := fs.String("tag", "", "simulate a tag push (sets CI_COMMIT_TAG, …)") tag := fs.String("tag", "", "simulate a tag push (sets CI_COMMIT_TAG, …)")
source := fs.String("source", "", "set CI_PIPELINE_SOURCE") 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 var vars multiFlag
fs.Var(&vars, "var", "set a CI variable as KEY=VALUE; repeatable") fs.Var(&vars, "var", "set a CI variable as KEY=VALUE; repeatable")
_ = fs.Parse(args) _ = 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 { if fs.NArg() != 1 {
fs.Usage() fs.Usage()
os.Exit(2) os.Exit(2)
@@ -272,6 +317,12 @@ Examples:
resolver.Resolve(p) //nolint:errcheck resolver.Resolve(p) //nolint:errcheck
ctx := cicontext.New(*branch, *tag, *source, vars) ctx := cicontext.New(*branch, *tag, *source, vars)
if !ctx.IsEmpty() {
enrichContext(ctx, p)
}
if *listVars {
printVars(p, ctx)
}
switch mode { switch mode {
case "default": case "default":
@@ -300,6 +351,89 @@ Examples:
} }
} }
// printVars prints the collected variable namespaces to stderr:
// 1. Pipeline variables — declared in variables: blocks across the root file
// and all included files (merged by ResolveIncludes).
// 2. Workflow-rule variables — union of variables: from every workflow:rules
// entry; any one of them may be injected at runtime.
// 3. Effective context variables — only when ctx is non-empty; shows the
// fully merged set visible to job rules:if: after enrichContext.
func printVars(p *model.Pipeline, ctx *cicontext.Context) {
fmt.Fprintln(os.Stderr, "Pipeline variables (YAML, root + includes):")
printVarMap(p.Variables)
if p.Workflow != nil {
union := map[string]any{}
for _, rule := range p.Workflow.Rules {
for k, v := range rule.Variables {
union[k] = v
}
}
if len(union) > 0 {
fmt.Fprintln(os.Stderr, "Workflow-rule variables (union across all rules):")
printVarMap(union)
}
}
if !ctx.IsEmpty() {
fmt.Fprintln(os.Stderr, "Effective context variables (after workflow + CLI flags):")
keys := make([]string, 0, len(ctx.Vars))
for k := range ctx.Vars {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
fmt.Fprintf(os.Stderr, " %s=%s\n", k, ctx.Vars[k])
}
}
}
func printVarMap(m map[string]any) {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
if len(keys) == 0 {
fmt.Fprintln(os.Stderr, " (none)")
return
}
for _, k := range keys {
fmt.Fprintf(os.Stderr, " %s=%s\n", k, varValueString(m[k]))
}
}
func varValueString(v any) string {
switch val := v.(type) {
case string:
return val
case map[string]any:
if s, ok := val["value"].(string); ok {
return s
}
return "(complex)"
}
return "(complex)"
}
// enrichContext injects pipeline-level variable defaults and then
// workflow-rule-generated variables into ctx before job evaluation.
// Injection respects pinned variables (--branch/--tag/--source/--var always win).
func enrichContext(ctx *cicontext.Context, p *model.Pipeline) {
// 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)
if !runs {
fmt.Fprintln(os.Stderr, "[WARNING] workflow:rules: pipeline would not start for this context")
}
for k, v := range ruleVars {
ctx.Inject(k, v)
}
}
func printContext(p *model.Pipeline, ctx *cicontext.Context) { func printContext(p *model.Pipeline, ctx *cicontext.Context) {
fmt.Printf("Context: %s\n\n", ctx.Summary()) 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 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/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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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=
+58 -11
View File
@@ -7,6 +7,7 @@ import "strings"
// Variables are keyed by their name without the leading $. // Variables are keyed by their name without the leading $.
type Context struct { type Context struct {
Vars map[string]string 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 // New builds a Context from high-level shortcut values and optional KEY=VALUE
@@ -17,46 +18,55 @@ type Context struct {
// preserving the existing linting behaviour when no context flags are given. // preserving the existing linting behaviour when no context flags are given.
// //
// Override priority (highest wins): extraVars > branch/tag/source shortcuts. // 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 { func New(branch, tag, source string, extraVars []string) *Context {
if branch == "" && tag == "" && source == "" && len(extraVars) == 0 { if branch == "" && tag == "" && source == "" && len(extraVars) == 0 {
return &Context{} return &Context{}
} }
vars := make(map[string]string) vars := make(map[string]string)
pinned := make(map[string]bool)
pin := func(k, v string) {
vars[k] = v
pinned[k] = true
}
if branch != "" { if branch != "" {
vars["CI_COMMIT_BRANCH"] = branch pin("CI_COMMIT_BRANCH", branch)
vars["CI_COMMIT_REF_NAME"] = branch pin("CI_COMMIT_REF_NAME", branch)
vars["CI_COMMIT_REF_SLUG"] = slugify(branch) pin("CI_COMMIT_REF_SLUG", slugify(branch))
if source == "" { if source == "" {
source = "push" source = "push"
} }
} }
if tag != "" { if tag != "" {
vars["CI_COMMIT_TAG"] = tag pin("CI_COMMIT_TAG", tag)
vars["CI_COMMIT_REF_NAME"] = tag pin("CI_COMMIT_REF_NAME", tag)
vars["CI_COMMIT_REF_SLUG"] = slugify(tag) pin("CI_COMMIT_REF_SLUG", slugify(tag))
delete(vars, "CI_COMMIT_BRANCH") // tag pushes have no branch variable delete(vars, "CI_COMMIT_BRANCH")
delete(pinned, "CI_COMMIT_BRANCH")
if source == "" { if source == "" {
source = "push" source = "push"
} }
} }
if source != "" { if source != "" {
vars["CI_PIPELINE_SOURCE"] = source pin("CI_PIPELINE_SOURCE", source)
} }
if _, ok := vars["CI_DEFAULT_BRANCH"]; !ok { if _, ok := vars["CI_DEFAULT_BRANCH"]; !ok {
vars["CI_DEFAULT_BRANCH"] = "main" vars["CI_DEFAULT_BRANCH"] = "main"
} }
// KEY=VALUE overrides win over shortcuts. // KEY=VALUE overrides win over shortcuts and everything else.
for _, kv := range extraVars { for _, kv := range extraVars {
k, v, ok := strings.Cut(kv, "=") k, v, ok := strings.Cut(kv, "=")
if ok { 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). // IsEmpty reports whether no variables have been set (no context flags given).
@@ -73,6 +83,21 @@ func (c *Context) Get(key string) string {
return c.Vars[key] 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. // Summary returns a short human-readable description of the context for CLI output.
func (c *Context) Summary() string { func (c *Context) Summary() string {
if c.IsEmpty() { if c.IsEmpty() {
@@ -90,6 +115,28 @@ func (c *Context) Summary() string {
return strings.Join(parts, ", ") 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. Other forms are skipped.
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 {
switch val := v.(type) {
case string:
out[k] = val
case map[string]any:
if s, ok := val["value"].(string); ok {
out[k] = s
}
}
}
return out
}
// slugify converts a ref name to its GitLab slug form: // slugify converts a ref name to its GitLab slug form:
// lowercased, non-alphanumeric characters replaced with '-', leading/trailing '-' removed. // lowercased, non-alphanumeric characters replaced with '-', leading/trailing '-' removed.
func slugify(s string) string { func slugify(s string) string {
+145 -17
View File
@@ -9,12 +9,15 @@ import (
// variable resolver. // variable resolver.
// //
// Supported: // Supported:
// - Variable references: $VAR_NAME // - Variable references: $VAR_NAME or ${VAR_NAME}
// - String literals: "value" or 'value' // - String literals: "value" or 'value'
// - Null keyword: null // - Null keyword: null
// - Comparison: == != =~ !~ // - Comparison: == != =~ !~
// - Boolean: && || ! // - Boolean: && || !
// - Grouping: ( ) // - 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 // 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 // used by GitLab CI. Unsupported or unparseable expressions fall back to true
@@ -56,8 +59,13 @@ func (p *exprParser) consume(tok string) bool {
} }
func (p *exprParser) skipWS() { 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++ p.pos++
continue
}
break
} }
} }
@@ -67,11 +75,11 @@ func (p *exprParser) skipWS() {
// and_expr → not_expr ( '&&' not_expr )* // and_expr → not_expr ( '&&' not_expr )*
// not_expr → '!' not_expr | primary // not_expr → '!' not_expr | primary
// primary → '(' or_expr ')' | comparison // primary → '(' or_expr ')' | comparison
// comparison → value ( op value | regex_op regex )? | value // comparison → value ( op value | regex_op regex_rhs )?
// value → '$' ident | '"' … '"' | "'" … "'" | 'null' // value → '$' '{' ident '}' | '$' ident | '"' … '"' | "'" … "'" | 'null'
// op → '==' | '!=' // op → '==' | '!='
// regex_op → '=~' | '!~' // regex_op → '=~' | '!~'
// regex '/' … '/' // regex_rhs → '/' … '/' flags? | '$' ident (where ident value is '/…/flags')
func (p *exprParser) parseOr() (bool, bool) { func (p *exprParser) parseOr() (bool, bool) {
left, ok := p.parseAnd() left, ok := p.parseAnd()
@@ -165,8 +173,11 @@ func (p *exprParser) parseComparison() (bool, bool) {
case p.consume("=~"): case p.consume("=~"):
p.skipWS() p.skipWS()
pat, ok := p.parseRegexLiteral() pat, patOk, permissive := p.parseRegexRHS()
if !ok { if permissive {
return true, true
}
if !patOk {
return false, false return false, false
} }
re, err := regexp.Compile(pat) re, err := regexp.Compile(pat)
@@ -177,8 +188,11 @@ func (p *exprParser) parseComparison() (bool, bool) {
case p.consume("!~"): case p.consume("!~"):
p.skipWS() p.skipWS()
pat, ok := p.parseRegexLiteral() pat, patOk, permissive := p.parseRegexRHS()
if !ok { if permissive {
return true, true
}
if !patOk {
return false, false return false, false
} }
re, err := regexp.Compile(pat) re, err := regexp.Compile(pat)
@@ -192,13 +206,49 @@ func (p *exprParser) parseComparison() (bool, bool) {
return leftStr != "", true return leftStr != "", true
} }
// parseValue reads $VAR, "string", 'string', or null. // parseRegexRHS parses the right-hand side of =~ / !~ operators.
// null and undefined variables both produce an empty string. // 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) { func (p *exprParser) parseValue() (string, bool) {
p.skipWS() p.skipWS()
if p.peek() == '$' { if p.peek() == '$' {
p.pos++ // consume '$' 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() name := p.parseIdent()
if name == "" { if name == "" {
return "", false return "", false
@@ -206,12 +256,18 @@ func (p *exprParser) parseValue() (string, bool) {
return p.vars(name), true return p.vars(name), true
} }
// null keyword — must not be a prefix of a longer identifier. // Keywords and string literals must not be prefixes of longer identifiers.
if p.startsWith("null") { for _, kw := range []struct{ tok, val string }{
end := p.pos + 4 {"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]) { if end >= len(p.s) || !isIdentByte(p.s[end]) {
p.pos += 4 p.pos += len(kw.tok)
return "", true // null → empty string return kw.val, true
}
} }
} }
@@ -219,6 +275,15 @@ func (p *exprParser) parseValue() (string, bool) {
return p.parseStringLiteral() 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 return "", false
} }
@@ -261,7 +326,8 @@ func (p *exprParser) parseRegexLiteral() (string, bool) {
b := p.s[p.pos] b := p.s[p.pos]
if b == '/' { if b == '/' {
p.pos++ // consume closing '/' 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) { if b == '\\' && p.pos+1 < len(p.s) {
p.pos++ p.pos++
@@ -275,6 +341,68 @@ func (p *exprParser) parseRegexLiteral() (string, bool) {
return "", false // unterminated regex 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 { func isIdentByte(b byte) bool {
return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') || b == '_' return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') || b == '_'
} }
+48
View File
@@ -9,6 +9,10 @@ func TestEvalIf(t *testing.T) {
"CI_COMMIT_TAG": "", "CI_COMMIT_TAG": "",
"CI_PIPELINE_SOURCE": "push", "CI_PIPELINE_SOURCE": "push",
"DEPLOY_ENV": "staging", "DEPLOY_ENV": "staging",
"BRANCH_PATTERN": "/^dev/",
"BRANCH_PATTERN_CI": "/^DEV/i",
"EMPTY_PATTERN": "",
"PLAIN_PATTERN": "develop",
} }
return m[key] return m[key]
} }
@@ -69,6 +73,50 @@ func TestEvalIf(t *testing.T) {
{"extra spaces", ` $CI_COMMIT_BRANCH == "develop" `, true}, {"extra spaces", ` $CI_COMMIT_BRANCH == "develop" `, true},
{"tabs", "$CI_COMMIT_BRANCH\t==\t\"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 ─────────────────────────────────────────────── // ── Permissive fallback ───────────────────────────────────────────────
{"unparseable returns true", `this is not valid syntax %%%`, true}, {"unparseable returns true", `this is not valid syntax %%%`, true},
{"empty expr returns true", ``, true}, {"empty expr returns true", ``, true},
+12 -8
View File
@@ -28,13 +28,17 @@ func (s JobState) String() string {
} }
} }
// EvalWorkflow returns false when the pipeline's workflow:rules block would // EvalWorkflow evaluates the pipeline's workflow:rules block against ctx.
// prevent any pipeline from starting in the given context. // Returns (runs, ruleVars):
// Returns true when ctx is empty, when there is no workflow block, or when no // - runs=false means the pipeline would not start for this context.
// rule is configured. // - ruleVars holds any variables: defined on the matching rule; inject these
func EvalWorkflow(p *model.Pipeline, ctx *Context) bool { // 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 { if ctx.IsEmpty() || p.Workflow == nil || len(p.Workflow.Rules) == 0 {
return true return true, nil
} }
vars := ctx.Get vars := ctx.Get
for _, rule := range p.Workflow.Rules { for _, rule := range p.Workflow.Rules {
@@ -45,9 +49,9 @@ func EvalWorkflow(p *model.Pipeline, ctx *Context) bool {
if when == "" { if when == "" {
when = "always" 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. // EvalJob returns the effective JobState for job in the given context.
+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 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 { func firstNonEmpty(values ...string) string {
for _, v := range values { for _, v := range values {
if v != "" { if v != "" {
+26 -4
View File
@@ -55,9 +55,7 @@ func (b *treeBuilder) nextID() string {
func (b *treeBuilder) buildChildren(parent *treeNode, rawIncludes []any) { func (b *treeBuilder) buildChildren(parent *treeNode, rawIncludes []any) {
for _, entry := range rawIncludes { for _, entry := range rawIncludes {
for _, child := range b.parseEntry(entry) { parent.children = append(parent.children, b.parseEntry(entry)...)
parent.children = append(parent.children, child)
}
} }
} }
@@ -114,7 +112,9 @@ func (b *treeBuilder) parseMap(m map[string]any) []*treeNode {
return []*treeNode{node} return []*treeNode{node}
} }
if remote, ok := m["remote"].(string); ok { 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 { if tmpl, ok := m["template"].(string); ok {
return []*treeNode{{id: b.nextID(), label: "template:<br>" + mermaidLabel(tmpl), class: "template"}} return []*treeNode{{id: b.nextID(), label: "template:<br>" + mermaidLabel(tmpl), class: "template"}}
@@ -170,6 +170,28 @@ func (b *treeBuilder) recurseProject(node *treeNode, project, filePath, ref stri
b.buildChildren(node, p.Include) 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)
}
func (b *treeBuilder) recurseComponent(node *treeNode, ref string) { func (b *treeBuilder) recurseComponent(node *treeNode, ref string) {
if strings.ContainsRune(ref, '$') { if strings.ContainsRune(ref, '$') {
return return
+2
View File
@@ -23,6 +23,7 @@ func checkDependencies(p *model.Pipeline) []Finding {
if !exists { if !exists {
findings = append(findings, Finding{ findings = append(findings, Finding{
Severity: Error, Severity: Error,
Rule: RuleUnknownDependency,
Job: name, Job: name,
File: job.File, File: job.File,
Line: job.Line, Line: job.Line,
@@ -35,6 +36,7 @@ func checkDependencies(p *model.Pipeline) []Finding {
if depHasStage && depIdx >= jobStageIdx { if depHasStage && depIdx >= jobStageIdx {
findings = append(findings, Finding{ findings = append(findings, Finding{
Severity: Error, Severity: Error,
Rule: RuleDependencyStage,
Job: name, Job: name,
File: job.File, File: job.File,
Line: job.Line, Line: job.Line,
+31 -3
View File
@@ -105,6 +105,7 @@ func checkWhen(name string, job model.Job) []Finding {
if job.When != "" && !validJobWhen[job.When] { if job.When != "" && !validJobWhen[job.When] {
findings = append(findings, Finding{ findings = append(findings, Finding{
Severity: Error, Severity: Error,
Rule: RuleInvalidWhen,
Job: name, Job: name,
Message: fmt.Sprintf("'when' has invalid value %q; valid: on_success, on_failure, always, manual, delayed, never", job.When), Message: fmt.Sprintf("'when' has invalid value %q; valid: on_success, on_failure, always, manual, delayed, never", job.When),
}) })
@@ -112,6 +113,7 @@ func checkWhen(name string, job model.Job) []Finding {
if job.When == "delayed" && job.StartIn == "" { if job.When == "delayed" && job.StartIn == "" {
findings = append(findings, Finding{ findings = append(findings, Finding{
Severity: Error, Severity: Error,
Rule: RuleDelayedNoStartIn,
Job: name, Job: name,
Message: "'when: delayed' requires 'start_in' (e.g. 'start_in: 30 minutes')", Message: "'when: delayed' requires 'start_in' (e.g. 'start_in: 30 minutes')",
}) })
@@ -119,6 +121,7 @@ func checkWhen(name string, job model.Job) []Finding {
if job.When != "delayed" && job.StartIn != "" { if job.When != "delayed" && job.StartIn != "" {
findings = append(findings, Finding{ findings = append(findings, Finding{
Severity: Error, Severity: Error,
Rule: RuleStartInNoDelayed,
Job: name, Job: name,
Message: "'start_in' is only valid when 'when: delayed'", Message: "'start_in' is only valid when 'when: delayed'",
}) })
@@ -135,6 +138,7 @@ func checkParallel(name string, job model.Job) []Finding {
if v < 2 || v > 200 { if v < 2 || v > 200 {
return []Finding{{ return []Finding{{
Severity: Error, Severity: Error,
Rule: RuleInvalidParallel,
Job: name, Job: name,
Message: fmt.Sprintf("'parallel' must be between 2 and 200, got %d", v), Message: fmt.Sprintf("'parallel' must be between 2 and 200, got %d", v),
}} }}
@@ -143,6 +147,7 @@ func checkParallel(name string, job model.Job) []Finding {
if _, ok := v["matrix"]; !ok { if _, ok := v["matrix"]; !ok {
return []Finding{{ return []Finding{{
Severity: Error, Severity: Error,
Rule: RuleInvalidParallel,
Job: name, Job: name,
Message: "'parallel' map form must have a 'matrix' key", Message: "'parallel' map form must have a 'matrix' key",
}} }}
@@ -150,6 +155,7 @@ func checkParallel(name string, job model.Job) []Finding {
default: default:
return []Finding{{ return []Finding{{
Severity: Error, Severity: Error,
Rule: RuleInvalidParallel,
Job: name, Job: name,
Message: "'parallel' must be an integer (2200) or a map with 'matrix'", Message: "'parallel' must be an integer (2200) or a map with 'matrix'",
}} }}
@@ -166,6 +172,7 @@ func checkRetry(name string, job model.Job) []Finding {
if v < 0 || v > 2 { if v < 0 || v > 2 {
return []Finding{{ return []Finding{{
Severity: Error, Severity: Error,
Rule: RuleInvalidRetry,
Job: name, Job: name,
Message: fmt.Sprintf("'retry' must be 0, 1, or 2; got %d", v), Message: fmt.Sprintf("'retry' must be 0, 1, or 2; got %d", v),
}} }}
@@ -176,6 +183,7 @@ func checkRetry(name string, job model.Job) []Finding {
if n, ok := maxVal.(int); ok && (n < 0 || n > 2) { if n, ok := maxVal.(int); ok && (n < 0 || n > 2) {
findings = append(findings, Finding{ findings = append(findings, Finding{
Severity: Error, Severity: Error,
Rule: RuleInvalidRetry,
Job: name, Job: name,
Message: fmt.Sprintf("'retry.max' must be 0, 1, or 2; got %d", n), Message: fmt.Sprintf("'retry.max' must be 0, 1, or 2; got %d", n),
}) })
@@ -188,6 +196,7 @@ func checkRetry(name string, job model.Job) []Finding {
default: default:
return []Finding{{ return []Finding{{
Severity: Error, Severity: Error,
Rule: RuleInvalidRetry,
Job: name, Job: name,
Message: "'retry' must be an integer (02) or a map with 'max'/'when'", Message: "'retry' must be an integer (02) or a map with 'max'/'when'",
}} }}
@@ -201,6 +210,7 @@ func validateRetryWhen(name string, val any) []Finding {
if !validRetryWhen[s] { if !validRetryWhen[s] {
findings = append(findings, Finding{ findings = append(findings, Finding{
Severity: Error, Severity: Error,
Rule: RuleInvalidRetryWhen,
Job: name, Job: name,
Message: fmt.Sprintf("'retry.when' has invalid value %q", s), Message: fmt.Sprintf("'retry.when' has invalid value %q", s),
}) })
@@ -230,6 +240,7 @@ func checkAllowFailure(name string, job model.Job) []Finding {
if _, ok := v["exit_codes"]; !ok { if _, ok := v["exit_codes"]; !ok {
return []Finding{{ return []Finding{{
Severity: Error, Severity: Error,
Rule: RuleInvalidAllowFailure,
Job: name, Job: name,
Message: "'allow_failure' map form must contain 'exit_codes'", Message: "'allow_failure' map form must contain 'exit_codes'",
}} }}
@@ -238,6 +249,7 @@ func checkAllowFailure(name string, job model.Job) []Finding {
_ = v _ = v
return []Finding{{ return []Finding{{
Severity: Error, Severity: Error,
Rule: RuleInvalidAllowFailure,
Job: name, Job: name,
Message: "'allow_failure' must be a boolean or a map with 'exit_codes'", Message: "'allow_failure' must be a boolean or a map with 'exit_codes'",
}} }}
@@ -252,6 +264,7 @@ func checkInterruptible(name string, job model.Job) []Finding {
if _, ok := job.Interruptible.(bool); !ok { if _, ok := job.Interruptible.(bool); !ok {
return []Finding{{ return []Finding{{
Severity: Error, Severity: Error,
Rule: RuleInvalidInterruptible,
Job: name, Job: name,
Message: "'interruptible' must be a boolean", Message: "'interruptible' must be a boolean",
}} }}
@@ -267,6 +280,7 @@ func checkTrigger(name string, job model.Job) []Finding {
if scriptNonEmpty(job.Script) { if scriptNonEmpty(job.Script) {
findings = append(findings, Finding{ findings = append(findings, Finding{
Severity: Error, Severity: Error,
Rule: RuleTriggerWithScript,
Job: name, Job: name,
Message: "jobs with 'trigger' cannot use 'script'", Message: "jobs with 'trigger' cannot use 'script'",
}) })
@@ -277,6 +291,7 @@ func checkTrigger(name string, job model.Job) []Finding {
if !hasProject && !hasInclude { if !hasProject && !hasInclude {
findings = append(findings, Finding{ findings = append(findings, Finding{
Severity: Error, Severity: Error,
Rule: RuleInvalidTrigger,
Job: name, Job: name,
Message: "'trigger' map must specify 'project' or 'include'", Message: "'trigger' map must specify 'project' or 'include'",
}) })
@@ -294,6 +309,7 @@ func checkCoverage(name string, job model.Job) []Finding {
if !coveragePattern.MatchString(job.Coverage) { if !coveragePattern.MatchString(job.Coverage) {
return []Finding{{ return []Finding{{
Severity: Error, Severity: Error,
Rule: RuleInvalidCoverage,
Job: name, Job: name,
Message: fmt.Sprintf("'coverage' must be a regex pattern wrapped in '/' (e.g. '/\\d+\\.?\\d*%%/'), got %q", job.Coverage), Message: fmt.Sprintf("'coverage' must be a regex pattern wrapped in '/' (e.g. '/\\d+\\.?\\d*%%/'), got %q", job.Coverage),
}} }}
@@ -309,6 +325,7 @@ func checkRelease(name string, job model.Job) []Finding {
if !ok { if !ok {
return []Finding{{ return []Finding{{
Severity: Error, Severity: Error,
Rule: RuleInvalidRelease,
Job: name, Job: name,
Message: "'release' must be a map", Message: "'release' must be a map",
}} }}
@@ -317,6 +334,7 @@ func checkRelease(name string, job model.Job) []Finding {
if !exists || tagName == "" || tagName == nil { if !exists || tagName == "" || tagName == nil {
return []Finding{{ return []Finding{{
Severity: Error, Severity: Error,
Rule: RuleInvalidRelease,
Job: name, Job: name,
Message: "'release' requires 'tag_name'", Message: "'release' requires 'tag_name'",
}} }}
@@ -334,11 +352,12 @@ func checkEnvironment(name string, job model.Job) []Finding {
return nil return nil
} }
var findings []Finding var findings []Finding
envName, _ := m["name"] envName := m["name"]
_, hasURL := m["url"] _, hasURL := m["url"]
if (envName == nil || envName == "") && hasURL { if (envName == nil || envName == "") && hasURL {
findings = append(findings, Finding{ findings = append(findings, Finding{
Severity: Error, Severity: Error,
Rule: RuleInvalidEnvironment,
Job: name, Job: name,
Message: "'environment.url' requires 'environment.name' to be set", Message: "'environment.url' requires 'environment.name' to be set",
}) })
@@ -346,6 +365,7 @@ func checkEnvironment(name string, job model.Job) []Finding {
if action, ok := m["action"].(string); ok && !validEnvironmentAction[action] { if action, ok := m["action"].(string); ok && !validEnvironmentAction[action] {
findings = append(findings, Finding{ findings = append(findings, Finding{
Severity: Error, Severity: Error,
Rule: RuleInvalidEnvironment,
Job: name, Job: name,
Message: fmt.Sprintf("'environment.action' has invalid value %q; valid: start, stop, prepare, verify, access", action), Message: fmt.Sprintf("'environment.action' has invalid value %q; valid: start, stop, prepare, verify, access", action),
}) })
@@ -365,15 +385,17 @@ func checkArtifacts(name string, job model.Job) []Finding {
if w, ok := m["when"].(string); ok && !validArtifactsWhen[w] { if w, ok := m["when"].(string); ok && !validArtifactsWhen[w] {
findings = append(findings, Finding{ findings = append(findings, Finding{
Severity: Error, Severity: Error,
Rule: RuleInvalidArtifacts,
Job: name, Job: name,
Message: fmt.Sprintf("'artifacts.when' has invalid value %q; valid: on_success, on_failure, always", w), Message: fmt.Sprintf("'artifacts.when' has invalid value %q; valid: on_success, on_failure, always", w),
}) })
} }
if _, hasExposeAs := m["expose_as"]; hasExposeAs { if _, hasExposeAs := m["expose_as"]; hasExposeAs {
paths, _ := m["paths"] paths := m["paths"]
if paths == nil { if paths == nil {
findings = append(findings, Finding{ findings = append(findings, Finding{
Severity: Error, Severity: Error,
Rule: RuleInvalidArtifacts,
Job: name, Job: name,
Message: "'artifacts.expose_as' requires 'artifacts.paths'", Message: "'artifacts.expose_as' requires 'artifacts.paths'",
}) })
@@ -392,6 +414,7 @@ func checkArtifacts(name string, job model.Job) []Finding {
if !found { if !found {
findings = append(findings, Finding{ findings = append(findings, Finding{
Severity: Warning, Severity: Warning,
Rule: RulePagesPublic,
Job: name, Job: name,
Message: "the 'pages' job should include 'public' in 'artifacts.paths' for GitLab Pages to deploy", Message: "the 'pages' job should include 'public' in 'artifacts.paths' for GitLab Pages to deploy",
}) })
@@ -421,6 +444,7 @@ func checkCache(name string, job model.Job) []Finding {
if w, ok := m["when"].(string); ok && !validCacheWhen[w] { if w, ok := m["when"].(string); ok && !validCacheWhen[w] {
findings = append(findings, Finding{ findings = append(findings, Finding{
Severity: Error, Severity: Error,
Rule: RuleInvalidCache,
Job: name, Job: name,
Message: fmt.Sprintf("'cache.when' has invalid value %q; valid: on_success, on_failure, always", w), Message: fmt.Sprintf("'cache.when' has invalid value %q; valid: on_success, on_failure, always", w),
}) })
@@ -428,6 +452,7 @@ func checkCache(name string, job model.Job) []Finding {
if p, ok := m["policy"].(string); ok && !validCachePolicy[p] { if p, ok := m["policy"].(string); ok && !validCachePolicy[p] {
findings = append(findings, Finding{ findings = append(findings, Finding{
Severity: Error, Severity: Error,
Rule: RuleInvalidCache,
Job: name, Job: name,
Message: fmt.Sprintf("'cache.policy' has invalid value %q; valid: pull, push, pull-push", p), Message: fmt.Sprintf("'cache.policy' has invalid value %q; valid: pull, push, pull-push", p),
}) })
@@ -442,6 +467,7 @@ func checkRules(name string, job model.Job) []Finding {
if rule.When != "" && !validRuleWhen[rule.When] { if rule.When != "" && !validRuleWhen[rule.When] {
findings = append(findings, Finding{ findings = append(findings, Finding{
Severity: Error, Severity: Error,
Rule: RuleInvalidRulesWhen,
Job: name, Job: name,
Message: fmt.Sprintf("rules[%d].when has invalid value %q; valid: on_success, on_failure, always, manual, delayed, never", i, rule.When), Message: fmt.Sprintf("rules[%d].when has invalid value %q; valid: on_success, on_failure, always, manual, delayed, never", i, rule.When),
}) })
@@ -458,10 +484,11 @@ func checkImage(name string, job model.Job) []Finding {
if !ok { if !ok {
return nil // String form is valid. return nil // String form is valid.
} }
imgName, _ := m["name"] imgName := m["name"]
if imgName == nil || imgName == "" { if imgName == nil || imgName == "" {
return []Finding{{ return []Finding{{
Severity: Error, Severity: Error,
Rule: RuleInvalidImage,
Job: name, Job: name,
Message: "'image' map form requires a 'name' key", Message: "'image' map form requires a 'name' key",
}} }}
@@ -489,6 +516,7 @@ func checkInherit(name string, job model.Job) []Finding {
default: default:
findings = append(findings, Finding{ findings = append(findings, Finding{
Severity: Error, Severity: Error,
Rule: RuleInvalidInherit,
Job: name, Job: name,
Message: fmt.Sprintf("'inherit.%s' must be a boolean or a list of names", key), Message: fmt.Sprintf("'inherit.%s' must be a boolean or a list of names", key),
}) })
+24 -8
View File
@@ -16,6 +16,7 @@ const (
type Finding struct { type Finding struct {
Severity Severity Severity Severity
Rule string // stable rule ID, e.g. "GL003" — see rules.go
Job string // empty for pipeline-level findings Job string // empty for pipeline-level findings
File string // source file where the finding originates File string // source file where the finding originates
Line int // line number in File (0 = unknown) Line int // line number in File (0 = unknown)
@@ -23,21 +24,28 @@ type Finding struct {
} }
func (f Finding) String() string { func (f Finding) String() string {
loc := "" var loc string
if f.File != "" { if f.File != "" {
if f.Line > 0 { if f.Line > 0 {
loc = fmt.Sprintf(" (%s:%d)", f.File, f.Line) loc = fmt.Sprintf("%s:%d: ", f.File, f.Line)
} else { } else {
loc = fmt.Sprintf(" (%s)", f.File) loc = fmt.Sprintf("%s: ", f.File)
} }
} }
rule := ""
if f.Rule != "" {
rule = f.Rule + " "
}
sev := "[" + strings.ToLower(string(f.Severity)) + "]"
msg := f.Message
if f.Job != "" { if f.Job != "" {
return fmt.Sprintf("[%s] job %q%s: %s", f.Severity, f.Job, loc, f.Message) msg = fmt.Sprintf("job %q: %s", f.Job, f.Message)
} }
if loc != "" {
return fmt.Sprintf("[%s]%s: %s", f.Severity, loc, f.Message) return fmt.Sprintf("%s%s%s %s", loc, rule, sev, msg)
}
return fmt.Sprintf("[%s] %s", f.Severity, f.Message)
} }
// Lint runs all rules against p and returns findings sorted by job name. // Lint runs all rules against p and returns findings sorted by job name.
@@ -48,6 +56,7 @@ func Lint(p *model.Pipeline) []Finding {
findings = append(findings, checkJobs(p)...) findings = append(findings, checkJobs(p)...)
findings = append(findings, checkNeeds(p)...) findings = append(findings, checkNeeds(p)...)
findings = append(findings, checkDependencies(p)...) findings = append(findings, checkDependencies(p)...)
findings = append(findings, checkVariableRefs(p)...)
return findings return findings
} }
@@ -56,6 +65,7 @@ func checkStages(p *model.Pipeline) []Finding {
if len(p.Stages) == 0 { if len(p.Stages) == 0 {
findings = append(findings, Finding{ findings = append(findings, Finding{
Severity: Warning, Severity: Warning,
Rule: RuleNoStages,
File: p.SourceFile, File: p.SourceFile,
Message: "no stages defined; GitLab will use default stages (build, test, deploy)", Message: "no stages defined; GitLab will use default stages (build, test, deploy)",
}) })
@@ -72,6 +82,7 @@ func checkWorkflow(p *model.Pipeline) []Finding {
if rule.When != "" && !validWorkflowRuleWhen[rule.When] { if rule.When != "" && !validWorkflowRuleWhen[rule.When] {
findings = append(findings, Finding{ findings = append(findings, Finding{
Severity: Error, Severity: Error,
Rule: RuleWorkflowWhen,
File: p.SourceFile, File: p.SourceFile,
Message: fmt.Sprintf("workflow.rules[%d].when has invalid value %q; valid: always, never", i, rule.When), Message: fmt.Sprintf("workflow.rules[%d].when has invalid value %q; valid: always, never", i, rule.When),
}) })
@@ -113,6 +124,7 @@ func checkJob(name string, job model.Job, stageSet map[string]bool) []Finding {
} }
findings = append(findings, Finding{ findings = append(findings, Finding{
Severity: sev, Severity: sev,
Rule: RuleMissingScript,
Job: name, Job: name,
Message: "missing required field 'script' (or 'run')", Message: "missing required field 'script' (or 'run')",
}) })
@@ -124,6 +136,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] { if job.Stage != "" && !strings.Contains(job.Stage, "$[[") && len(stageSet) > 0 && !stageSet[job.Stage] {
findings = append(findings, Finding{ findings = append(findings, Finding{
Severity: Error, Severity: Error,
Rule: RuleUnknownStage,
Job: name, Job: name,
Message: fmt.Sprintf("stage %q is not defined in 'stages'", job.Stage), Message: fmt.Sprintf("stage %q is not defined in 'stages'", job.Stage),
}) })
@@ -133,6 +146,7 @@ func checkJob(name string, job model.Job, stageSet map[string]bool) []Finding {
if job.Only != nil && len(job.Rules) > 0 { if job.Only != nil && len(job.Rules) > 0 {
findings = append(findings, Finding{ findings = append(findings, Finding{
Severity: Error, Severity: Error,
Rule: RuleOnlyRulesConflict,
Job: name, Job: name,
Message: "'only' and 'rules' cannot be used together", Message: "'only' and 'rules' cannot be used together",
}) })
@@ -142,6 +156,7 @@ func checkJob(name string, job model.Job, stageSet map[string]bool) []Finding {
if job.Except != nil && len(job.Rules) > 0 { if job.Except != nil && len(job.Rules) > 0 {
findings = append(findings, Finding{ findings = append(findings, Finding{
Severity: Error, Severity: Error,
Rule: RuleExceptRulesConflict,
Job: name, Job: name,
Message: "'except' and 'rules' cannot be used together", Message: "'except' and 'rules' cannot be used together",
}) })
@@ -151,6 +166,7 @@ func checkJob(name string, job model.Job, stageSet map[string]bool) []Finding {
if job.Only != nil || job.Except != nil { if job.Only != nil || job.Except != nil {
findings = append(findings, Finding{ findings = append(findings, Finding{
Severity: Warning, Severity: Warning,
Rule: RuleDeprecatedOnly,
Job: name, Job: name,
Message: "'only'/'except' are deprecated; prefer 'rules'", Message: "'only'/'except' are deprecated; prefer 'rules'",
}) })
+3
View File
@@ -46,6 +46,7 @@ func checkNeeds(p *model.Pipeline) []Finding {
} }
findings = append(findings, Finding{ findings = append(findings, Finding{
Severity: sev, Severity: sev,
Rule: RuleNeedsUnknown,
Job: name, Job: name,
File: job.File, File: job.File,
Line: job.Line, Line: job.Line,
@@ -63,6 +64,7 @@ func checkNeeds(p *model.Pipeline) []Finding {
if neededHasStage && neededStageIdx > jobStageIdx { if neededHasStage && neededStageIdx > jobStageIdx {
findings = append(findings, Finding{ findings = append(findings, Finding{
Severity: Error, Severity: Error,
Rule: RuleNeedsStageOrder,
Job: name, Job: name,
File: job.File, File: job.File,
Line: job.Line, Line: job.Line,
@@ -124,6 +126,7 @@ func detectNeedsCycles(graph map[string][]string, jobs map[string]model.Job) []F
j := jobs[name] j := jobs[name]
findings = append(findings, Finding{ findings = append(findings, Finding{
Severity: Error, Severity: Error,
Rule: RuleNeedsCycle,
Job: name, Job: name,
File: j.File, File: j.File,
Line: j.Line, Line: j.Line,
+117
View File
@@ -0,0 +1,117 @@
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"
)
+4 -4
View File
@@ -14,7 +14,7 @@ import (
// pipeline that is valid on GitLab) produces no Error findings. // pipeline that is valid on GitLab) produces no Error findings.
// These files exercise local include resolution and multi-level extends chains. // These files exercise local include resolution and multi-level extends chains.
func TestSambaCI(t *testing.T) { func TestSambaCI(t *testing.T) {
entryPoint := "../../samba-testdata/.gitlab-ci.yml" entryPoint := "../../testdata/samba/.gitlab-ci.yml"
p, err := model.Parse(entryPoint) p, err := model.Parse(entryPoint)
if err != nil { if err != nil {
@@ -51,9 +51,9 @@ func TestSambaCIEntryFiles(t *testing.T) {
name string name string
path string path string
}{ }{
{"default", "../../samba-testdata/.gitlab-ci.yml"}, {"default", "../../testdata/samba/.gitlab-ci.yml"},
{"coverage", "../../samba-testdata/.gitlab-ci-coverage.yml"}, {"coverage", "../../testdata/samba/.gitlab-ci-coverage.yml"},
{"private", "../../samba-testdata/.gitlab-ci-private.yml"}, {"private", "../../testdata/samba/.gitlab-ci-private.yml"},
} }
for _, tc := range entryPoints { 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)
}
})
}
}
+1
View File
@@ -84,6 +84,7 @@ type Rule struct {
When string `yaml:"when"` When string `yaml:"when"`
Changes any `yaml:"changes"` // []string or {paths,compare_to} map Changes any `yaml:"changes"` // []string or {paths,compare_to} map
Exists any `yaml:"exists"` // []string or map form 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. // ReservedKeys are top-level GitLab CI keys that are NOT job definitions.
+56 -3
View File
@@ -90,7 +90,14 @@ func resolveIncludes(p *model.Pipeline, includes []any, cfg fetcher.GitLabConfig
continue 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 return warnings, extWarnings
} }
@@ -133,6 +140,39 @@ func resolveLocalInclude(p *model.Pipeline, rawPath string, cfg fetcher.GitLabCo
return warnings, extWarnings 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 // resolveProjectInclude fetches all files listed under a single project: entry
// and merges them into p. // 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) { func resolveProjectInclude(p *model.Pipeline, entry map[string]any, project string, cfg fetcher.GitLabConfig, rootDir string, visited map[string]bool) ([]IncludeWarning, []ExtendWarning) {
@@ -286,8 +326,9 @@ func includeFiles(entry map[string]any) []string {
return nil return nil
} }
// mergeIncluded copies jobs and stages from src into dst. // mergeIncluded copies jobs, stages, and variables from src into dst.
// dst (the main pipeline) always wins when a key already exists. // 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) { func mergeIncluded(dst, src *model.Pipeline) {
stageSet := make(map[string]bool, len(dst.Stages)) stageSet := make(map[string]bool, len(dst.Stages))
for _, s := range dst.Stages { for _, s := range dst.Stages {
@@ -308,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
}
}
}
} }
+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"
+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
+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"
+51
View File
@@ -0,0 +1,51 @@
---
# 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: '$CI_COMMIT_BRANCH == "main"'
variables:
DEPLOY_TARGET: production
- 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