8 Commits

Author SHA1 Message Date
k3nny 7f7e2bf77b docs(docs): release v0.2.20
ci / vet, staticcheck, test, build (push) Successful in 1m52s
release / Build and publish release (push) Successful in 1m17s
Add FEATURES.md and USAGE.md entries to the [0.2.20] changelog section.
No code changes — documentation reorganisation only.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 11:17:27 +02:00
k3nny e13455a629 docs(docs): extract usage examples to USAGE.md, trim README
Move all command-by-command reference (output formats, context
simulation, remote includes, cache/offline, component format, graph
modes, explain, project config, inline suppression, example output)
from README.md into a new USAGE.md. Replace the 326-line Usage section
in README with the commands block and a single link to USAGE.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 11:13:02 +02:00
k3nny b8e640de03 docs(docs): extract features to FEATURES.md, trim README
The README features block grew to 38 bullets plus a full lint-rules
table, making the document hard to scan. This commit:

- Creates FEATURES.md with a structured reference covering lint rules
  (GL001–GL043 tables), include resolution, context simulation, output
  formats, configuration, graph visualization, and developer tools.
- Replaces the flat bullet list in README with a 6-line "What it does"
  category summary that links to FEATURES.md and ROADMAP.md.
- Removes the redundant ## Lint rules section from README (now in
  FEATURES.md).
- Adds 'explain' to the commands block in the README Usage section.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 11:07:51 +02:00
k3nny 4ce7f86d4d feat(linter): glint explain, GL042 rules:if: reachability, GL043 inherit completeness
- `glint explain <RULE>`: new subcommand printing rule description,
  rationale, bad-YAML example and fix for every GL001–GL043 rule.
  `glint explain` (no arg) lists all rules with ID, severity, title.
  Rule IDs are case-insensitive.

- GL042 (rules:if: evaluated reachability): warns when every rules:if:
  condition evaluates to false given the values of variables declared in
  the pipeline YAML, making the job statically unreachable. Conservative:
  only fires when all referenced variables are declared in YAML; predefined
  CI_* / GITLAB_* variables are skipped to avoid false positives.

- GL043 (inherit: completeness): warns when inherit: default: is declared
  but there is no default: block in the pipeline (dead declaration), or
  when the list form names fields not set in the default: block.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 11:02:23 +02:00
k3nny 02d8e63a98 feat(cli): .glint.yml config and inline suppression comments
ci / vet, staticcheck, test, build (push) Successful in 2m25s
release / Build and publish release (push) Successful in 1m24s
Adds project-level configuration and per-job suppression directives:

.glint.yml (searched from pipeline dir up to the git root):
- ignore: [GL007, GL032] — suppress rules globally for the project
- severity: {GL004: warning} — override rule severity (error/warning/ignore)
- stages: [quality] — extra stages beyond the pipeline's stages: block
- token: / url: / cache_dir: — defaults for flags; lower priority than
  CLI flags and environment variables

Inline suppression (# glint: ignore):
- Place "# glint: ignore GL007" immediately before a job definition to
  suppress that rule for the specific job only
- Multiple rules: "# glint: ignore GL007, GL032" (comma or space separated)
- Wildcard: "# glint: ignore all" suppresses every finding for the job
- Suppressions are scoped to the annotated job; pipeline-level findings
  are unaffected
- Parsed from yaml.Node head/line comments in the first parse pass;
  stored in Pipeline.Suppressions (root file only, not includes)

New packages: internal/config (Load, walk-up search, .git boundary stop)
New files: cmd/glint/filter.go (applyConfig, isSuppressed helpers)
Tests: config_test.go, parser_suppress_test.go, filter_test.go
Validate fixtures: testdata/config_ignored/, config_severity/, config_suppress/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 10:23:33 +02:00
k3nny f5f8546bcf feat(cli): output formats, GL034-GL041 lint rules, include inputs and cache
ci / vet, staticcheck, test, build (push) Successful in 2m16s
release / Build and publish release (push) Successful in 1m9s
Bundles three patch releases (v0.2.16–v0.2.18):

v0.2.18 — output formats (--format flag on glint check):
- json: stable JSON report (schema_version: 1, findings array, summary)
- sarif: SARIF 2.1.0 for GitHub Code Scanning / GitLab SAST
- junit: JUnit XML for CI test-report artifacts (artifacts:reports:junit)
- github: GitHub Actions ::error:: / ::warning:: annotation lines
- Unknown --format value exits 2 with a helpful error message
- Summary line routed to stderr in structured formats; context suppressed

v0.2.17 — include resolution improvements:
- Recursive include depth capped at 100 (matches GitLab's own limit)
- project: and component: includes tracked in visited set (cycle detection)
- $[[ inputs.KEY ]] / $[[ inputs.KEY | default(…) ]] substituted from with:
- --cache-dir: persist fetched remote templates to disk (SHA-256 keyed)
- --offline: serve from cache only; defaults to ~/.cache/glint

v0.2.16 — new lint rules (GL034–GL041):
- GL034: services map form requires name; alias must be valid DNS label
- GL035: rules:changes / rules:exists absolute path detection
- GL036: timeout format validation (job-level + default.timeout)
- GL037: id_tokens entries must have an aud key
- GL038: secrets entries must declare a provider (vault / gcp / azure)
- GL039: pages: keyword + artifacts.paths consistency
- GL040: duplicate stage names in stages: list
- GL041: cache.key.files must be exact paths, not globs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 10:09:16 +02:00
k3nny 54b5850835 feat(linter): GL033 static dead-rules detection
ci / vet, staticcheck, test, build (push) Successful in 2m12s
release / Build and publish release (push) Successful in 1m7s
Add rule GL033 that warns when every rule in a job's rules: block has
an explicit when: never, making the job permanently excluded from any
pipeline run. This is a pure static check — no if: evaluation or context
required. Only rules with literal when: never trigger it; rules with no
when: (defaults to on_success), when: manual, when: always, or
when: on_failure are treated as reachable.

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 00:13:51 +02:00
48 changed files with 4635 additions and 404 deletions
+100
View File
@@ -5,6 +5,106 @@ 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).
## [0.2.20] - 2026-06-14
### Added
- **`glint explain <RULE>`** — new subcommand that prints the description, rationale, a bad-YAML example, and the corrected fix for any rule ID (GL001GL043). `glint explain` with no argument lists all rules with ID, severity, and title. Rule IDs are case-insensitive (`gl007` and `GL007` are equivalent).
- **`rules:if:` evaluated reachability (GL042)** — warns when every `rules:if:` condition in a job evaluates to false using the values of variables declared in the pipeline YAML, meaning the job can never be included in a pipeline run. The check is conservative: it only fires when all variables referenced in the `if:` expressions are explicitly declared in the YAML; predefined `CI_*` / `GITLAB_*` variables and undeclared variables are treated as unknown (may have any runtime value) to avoid false positives.
- **`inherit:` completeness (GL043)** — warns when a job declares `inherit: default:` (boolean or list form) but the pipeline has no `default:` block, making the declaration a no-op. Also warns when the list form names fields (e.g. `[image, before_script]`) that are not actually set in the `default:` block — those list entries have no effect.
- **`FEATURES.md`** — full feature reference extracted from the README: all 43 lint rules in categorised tables, include resolution capabilities, context simulation details, output format schemas, configuration reference, graph visualisation modes, and developer tools. The README `## Features` section replaced with a compact 6-line `## What it does` summary with a link to `FEATURES.md`.
- **`USAGE.md`** — complete command-by-command usage reference extracted from the README: `glint check` with all output formats and examples, context simulation flags and predefined variable table, remote includes and token resolution, cache and offline mode, component reference format, `glint graph` modes, `glint explain` usage, project config (`.glint.yml`), and inline suppression. The README `## Usage` section replaced with the commands block and a single link to `USAGE.md`.
## [0.2.19] - 2026-06-14
### Added
- **`.glint.yml` project config file** — glint now searches for a `.glint.yml` file starting from the pipeline file's directory and walking up to the first `.git` boundary. Supported keys:
- `ignore: [GL007, GL032]` — suppress rules globally for the project.
- `severity: {GL004: warning}` — override rule severity (`error`, `warning`, or `ignore`). `ignore` is equivalent to listing the rule in `ignore:`.
- `stages: [quality]` — declare extra stage names that are valid beyond those in the pipeline's own `stages:` block; jobs in these stages are not flagged by GL004.
- `token: glpat-xxx` — default GitLab personal access token (lower priority than the `--token` flag and `GITLAB_TOKEN` env var).
- `url: https://gitlab.example.com` — default GitLab instance URL.
- `cache_dir: ~/.cache/glint` — default cache directory for fetched remote includes.
- **Inline suppression comments** — a `# glint: ignore RULE` comment placed immediately before a job definition suppresses that rule for the specific job. Multiple rules can be comma- or space-separated (`# glint: ignore GL007, GL032`). Use `# glint: ignore all` to suppress every finding for the job. Suppressions are scoped to the annotated job; pipeline-level findings are unaffected.
## [0.2.18] - 2026-06-14
### Added
- **`--format json`** — `glint check` can now emit a structured JSON report instead of plain text. The schema (version `1`) includes `glint_version`, `pipeline`, a `findings` array (each finding has `rule`, `severity`, `file`, `line`, `job`, `message`), and a `summary` block (`total`, `errors`, `warnings`). An empty findings array is `[]`, not `null`. In this mode the human-readable summary line is written to stderr so stdout contains only the JSON payload.
- **`--format sarif`** — emits a SARIF 2.1.0 JSON document (schema `https://json.schemastore.org/sarif-2.1.0.json`). The `runs[0].tool.driver` lists every unique rule ID found in findings; each `result` carries `ruleId`, `level` (`error`/`warning`), `message.text` (including the job name prefix), and `locations[0].physicalLocation` with `artifactLocation.uri` and `region.startLine` (when available). Pipeline-level findings without a file have no `locations` entry. This format is consumed natively by GitHub Code Scanning and GitLab SAST.
- **`--format junit`** — emits a JUnit XML document compatible with CI test-report artifact parsers (GitLab: `artifacts:reports:junit`; GitHub: upload-artifact + test-reporter). Each finding becomes a `<testcase>` with a `<failure>` child whose `message` and `type` attributes carry the finding details. A clean pipeline produces a single passing `<testcase>` with no `<failure>` element.
- **`--format github`** — emits GitHub Actions workflow-command annotation lines (`::error file=…,line=…,title=RULE::message` / `::warning …`). GitHub CI renders these as inline comments on the relevant file in pull requests. Pipeline-level findings without a file omit the `file=` parameter.
- **Format validation** — an unknown `--format` value exits with code 2 and a helpful error message listing the valid formats.
## [0.2.17] - 2026-06-14
### Added
- **Recursive include depth limit** — `resolveIncludes` now accepts a `depth` counter and returns a warning when the nesting depth exceeds 100 (matching GitLab's documented limit). This guards against pathologically deep include chains.
- **Cycle detection for project and component includes** — `project:` and `component:` includes were not previously tracked in the `visited` map (only `local:` and `remote:` were). They are now registered before recursing into their sub-includes, preventing cross-file include cycles from causing infinite loops.
- **`include: inputs:` substitution** — when a `component:` include entry has a `with:` block, all `$[[ inputs.KEY ]]` and `$[[ inputs.KEY | default('…') ]]` placeholders in the fetched template YAML are substituted with the corresponding values before parsing. Supported default value forms: `'single quoted'`, `"double quoted"`, bare booleans (`true`/`false`), and bare integers. Missing keys without a default become empty strings. This replaces the previous behaviour of leaving `$[[…]]` tokens in the YAML, which caused false-positive lint findings.
- **Include cache (`--cache-dir`)** — pass `--cache-dir DIR` to `glint check` or `glint graph` to persist fetched remote templates (both `project:` and `component:` includes and `remote:` URLs) to a local directory. Cache entries are keyed by the SHA-256 of the full request coordinates (base URL + project + path + ref). The directory is created automatically on first use.
- **Offline mode (`--offline`)** — pass `--offline` to skip all network calls. Remote includes not present in the cache are surfaced as warnings (same UX as "no token"). When `--offline` is set without an explicit `--cache-dir`, the default platform cache directory (`$XDG_CACHE_HOME/glint` or `~/.cache/glint`) is used automatically.
## [0.2.16] - 2026-06-14
### Added
- **`services:` validation (GL034)** — the map form of a service entry requires a `name` key; emits an `ERROR` when absent. The optional `alias` field must be a valid DNS label (letters, digits, hyphens, and dots; must start and end with an alphanumeric character); invalid aliases emit an `ERROR`.
- **`rules:changes` / `rules:exists` absolute path detection (GL035)** — emits a `WARNING` when a path in `rules:changes:` or `rules:exists:` starts with `/`. GitLab CI evaluates these paths relative to the repository root, so absolute paths can never match. Applies to both the list form and the `{paths: …}` map form.
- **`timeout:` format validation (GL036)** — emits an `ERROR` when a job's `timeout:` (or the pipeline-level `default.timeout:`) is not a valid GitLab CI duration string. Valid formats: `30m`, `1h 30m`, `90 minutes`, `2 hours 30 minutes`, `1 day`, etc.
- **`id_tokens:` `aud` validation (GL037)** — emits an `ERROR` for each OIDC token entry in `id_tokens:` that is missing the required `aud` key. GitLab returns an API error at pipeline start when `aud` is absent.
- **`secrets:` provider validation (GL038)** — emits an `ERROR` for each secret entry in `secrets:` that does not declare a provider key (`vault`, `gcp_secret_manager`, or `azure_key_vault`).
- **`pages:` keyword + `artifacts.paths` consistency (GL039)** — emits a `WARNING` when a job uses the `pages:` keyword but `artifacts.paths` does not include the publish directory (default `public`, or the value of `pages.publish`). GitLab Pages will not deploy unless the publish directory is listed as an artifact.
- **Duplicate stage names (GL040)** — emits a `WARNING` when a stage name appears more than once in the top-level `stages:` list. GitLab silently merges duplicate stage entries, which can produce confusing pipeline ordering.
- **`cache.key.files` glob detection (GL041)** — emits a `WARNING` when an entry in `cache.key.files` contains glob metacharacters (`*`, `?`, `[`). The `files` field requires exact file paths; GitLab does not expand globs there.
## [0.2.15] - 2026-06-13
### Added
- **Static reachability check (GL033)** — warns when every rule in a job's `rules:` block has an explicit `when: never`, making the job permanently excluded from any pipeline run. This is a purely static claim: no matter which `if:` condition evaluates to true, the outcome is always "skip"; and if no rule matches, the implicit fallback is also skip. No expression evaluation or context is required. The finding is a `WARNING` (may be intentional as a "disabled job" pattern). Only jobs where every rule has the literal `when: never` value are flagged; rules with no `when:` (default `on_success`), `when: manual`, `when: always`, or `when: on_failure` are not.
## [0.2.14] - 2026-06-13
### Added
- **`--version` / `-v` flag** — `glint --version`, `glint -v`, and `glint version` all print the compiled version string (e.g. `glint v0.2.14`). The version is also shown at the top of every `--help` output (global, `check --help`, `graph --help`). The version is injected at build time via `-ldflags "-X main.version=..."` using `git describe --tags --always --dirty`.
- **Sorted findings output** — `Lint` now returns findings sorted by `(File, Line, Rule)`. All issues from the same source file appear together in ascending line order; pipeline-level findings with no file location sort first. Previously findings were emitted in map-iteration order (non-deterministic).
### Fixed
- **Warning format consistency** — include-resolution warnings, extends-chain warnings, and the workflow non-start warning now use the same ruff-style `path: [warning] message` format as lint findings instead of the old `[WARNING] …` prefix with no file context.
- **Workflow rule permissive evaluation** — workflow `rules:if:` expressions are now evaluated in strict mode: an expression that cannot be fully parsed returns `false` (skip this rule, try the next) instead of `true` (match everything). Previously, a complex or partially-unsupported condition on the first workflow rule would match every context, blocking all subsequent rules and injecting the wrong variables. Job rules retain permissive evaluation (`true` on parse failure) to avoid silently dropping jobs.
- **Single `=` operator in `rules:if:`** — a bare `=` not followed by `=` or `~` is now accepted as an alias for `==`. This is a common mistake in GitLab CI YAML; previously it caused a parse failure and triggered the permissive fallback.
- **Source location lost through `extends:` resolution** — when a job was resolved via `extends:`, the merged definition was re-encoded and re-decoded as a fresh `model.Job` struct, which does not carry `File` or `Line` (they are not YAML keys). Those fields are now explicitly copied back from the original job before replacing it in `p.Jobs`, so extended jobs report the correct source file and line number in findings.
## [0.2.13] - 2026-06-12 ## [0.2.13] - 2026-06-12
### Added ### Added
+259
View File
@@ -0,0 +1,259 @@
# glint — feature reference
This document describes every capability in the current release.
For planned work see [ROADMAP.md](ROADMAP.md).
---
## Lint rules
Every finding carries a stable rule ID (`GL001` `GL043`) that can be used to
suppress, filter, or look up the check. Run `glint explain <ID>` for a
description, bad-YAML example, and fix.
### Pipeline-level
| ID | Sev | Rule |
|----|-----|------|
| GL001 | WARN | No `stages:` block — GitLab falls back to its built-in default stages |
| GL002 | ERR | `workflow.rules[*].when` must be `always` or `never` |
| GL036 | ERR | `default.timeout` is not a valid GitLab CI duration string |
| GL040 | WARN | A stage name appears more than once in `stages:` |
### Job structure
| ID | Sev | Rule |
|----|-----|------|
| GL003 | ERR | Job missing required `script:` (or `run:`) |
| GL004 | ERR | Job `stage:` references a stage not declared in `stages:` |
| GL005 | ERR | `only:` and `rules:` used together |
| GL006 | ERR | `except:` and `rules:` used together |
| GL007 | WARN | `only:` / `except:` used (deprecated; prefer `rules:`) |
### Keyword constraints
| ID | Sev | Rule |
|----|-----|------|
| GL008 | ERR | `when:` has an invalid value |
| GL009 | ERR | `when: delayed` without `start_in:` |
| GL010 | ERR | `start_in:` set but `when:` is not `delayed` |
| GL011 | ERR | `parallel:` integer not in range 2200, or map form missing `matrix:` |
| GL012 | ERR | `retry:` integer not in range 02, or `retry.max` out of range |
| GL013 | ERR | `retry.when:` contains an unrecognised failure type |
| GL014 | ERR | `allow_failure:` is not a boolean or a map with `exit_codes:` |
| GL015 | ERR | `interruptible:` is not a boolean |
| GL016 | ERR | Trigger job also defines `script:` |
| GL017 | ERR | `trigger:` map missing `project:` or `include:` |
| GL018 | ERR | `coverage:` is not a regex wrapped in `/…/` |
| GL019 | ERR | `release:` missing required `tag_name:`, or is not a map |
| GL020 | ERR | `environment.url` set without `environment.name`, or invalid `action:` |
| GL021 | ERR | `artifacts.when` invalid, or `expose_as` set without `paths` |
| GL022 | WARN | `pages` job `artifacts.paths` does not include `public/` |
| GL023 | ERR | `cache.when` or `cache.policy` has an invalid value |
| GL024 | ERR | `rules[*].when` has an invalid value |
| GL025 | ERR | `image:` map form missing `name:` |
| GL026 | ERR | `inherit.default` / `inherit.variables` is not a boolean or list |
| GL034 | ERR | `services:` map form missing `name:`, or `alias:` is not a valid DNS label |
| GL036 | ERR | `timeout:` is not a valid GitLab CI duration string |
| GL037 | ERR | `id_tokens:` entry missing required `aud:` |
| GL038 | ERR | `secrets:` entry missing a provider key (`vault`, `gcp_secret_manager`, `azure_key_vault`) |
| GL039 | WARN | Job uses `pages:` keyword but `artifacts.paths` doesn't include the publish directory |
| GL041 | WARN | `cache.key.files` entry looks like a glob; must be an exact file path |
### Cross-job graph
| ID | Sev | Rule |
|----|-----|------|
| GL027 | ERR/WARN | `needs:` references a job that doesn't exist (WARN when `optional: true`) |
| GL028 | ERR | `needs:` references a job in a later stage |
| GL029 | ERR | Circular dependency in `needs:` graph |
| GL030 | ERR | `dependencies:` references a job that doesn't exist |
| GL031 | ERR | `dependencies:` references a job in the same or a later stage |
### Expression & reachability
| ID | Sev | Rule |
|----|-----|------|
| GL032 | WARN | `rules:if:` references `$VAR` not declared in any `variables:` block (may be false-positive for project-setting variables) |
| GL033 | WARN | Every rule in `rules:` has `when: never` — job permanently excluded (provable without evaluating `if:` expressions) |
| GL035 | WARN | `rules:changes` / `rules:exists` path is absolute — absolute paths never match in GitLab CI |
| GL042 | WARN | Every `rules:if:` condition evaluates to false given the declared variable values — job statically unreachable (only fires when all referenced variables are declared in YAML) |
### Inheritance
| ID | Sev | Rule |
|----|-----|------|
| GL043 | WARN | `inherit: default:` declared but the pipeline has no `default:` block (dead declaration), or the list form names fields not set in `default:` |
### Hidden jobs
Jobs whose name starts with `.` are reusable templates; most rules are skipped for them. This matches GitLab CI's own behaviour.
---
## Include resolution
| Capability | Notes |
|------------|-------|
| `include: local:` | Read from disk, recursively merged before linting |
| `include: remote:` | HTTPS URL fetched unauthenticated; unreachable URLs produce a warning, linting continues |
| `include: project:` | Fetched from the GitLab REST API using the configured token; skipped with a warning when no token is available |
| `include: component:` | Fetched from the GitLab CI/CD Catalog; public components work without a token |
| `include: inputs:` | `$[[ inputs.KEY ]]` and `$[[ inputs.KEY \| default(…) ]]` placeholders substituted from the `with:` block before parsing |
| Depth limit | Include chains capped at 100 levels (matches GitLab); circular cross-file references detected via visited-set tracking |
| Offline mode | `--offline` serves all remote includes from the cache; missing entries produce a warning |
| Include cache | `--cache-dir DIR` (or `~/.cache/glint` by default with `--offline`) persists fetched templates; keyed by SHA-256 of the request coordinates |
**Token resolution order** (first non-empty wins):
| Source | Header |
|--------|--------|
| `--token` flag / `GITLAB_TOKEN` env | `PRIVATE-TOKEN` |
| `CI_JOB_TOKEN` env | `JOB-TOKEN` |
| `GITLAB_PRIVATE_TOKEN` env | `PRIVATE-TOKEN` |
**URL resolution order:** `--gitlab-url` flag → `CI_SERVER_URL` env → `GITLAB_URL` env → `https://gitlab.com`
---
## Context simulation
Pass `--branch`, `--tag`, `--source`, or `--var` to evaluate `rules:if:` and
`only`/`except` filters against a specific pipeline event. When no context flag
is given glint defaults to `--branch main --source push` so that `rules:if:`
expressions are always evaluated.
**Evaluated at lint time:**
- `rules:if:` — full expression language: `==`, `!=`, `=~`, `!~`, `&&`, `||`, `!`, `(…)`, `$VAR`/`${VAR}`, string literals, `null`, regex flags (`/pat/i`)
- `only:` / `except:` — ref keywords, branch-name globs, and `/regex/` patterns
- `workflow:rules:` — evaluated to determine whether the pipeline would run; matching rule's `variables:` are injected before job evaluation
- Variable expansion — `$VAR` / `${VAR}` references in variable values expanded after all sources merge; transitive chains resolved (up to 10 passes)
**Not evaluated** (no git tree at lint time): `rules:changes:`, `rules:exists:`.
**Predefined variables** set by shortcut flags:
| Flag | Variables populated |
|------|---------------------|
| `--branch NAME` | `CI_COMMIT_BRANCH`, `CI_COMMIT_REF_NAME`, `CI_COMMIT_REF_SLUG`, `CI_PIPELINE_SOURCE=push` |
| `--tag NAME` | `CI_COMMIT_TAG`, `CI_COMMIT_REF_NAME`, `CI_COMMIT_REF_SLUG`, `CI_PIPELINE_SOURCE=push`; clears `CI_COMMIT_BRANCH` |
| `--source EVENT` | `CI_PIPELINE_SOURCE` |
| `--var KEY=VALUE` | any variable; overrides shortcuts; repeatable |
Use `--list-vars` to print the resolved variable table to stderr.
---
## Output formats
Pass `--format` to `glint check`. In structured formats the summary line is
written to stderr so stdout contains only the machine-readable payload.
| Format | Flag | Description |
|--------|------|-------------|
| Text (default) | `--format text` | Ruff-style `file:line: RULE [sev] message` |
| JSON | `--format json` | Stable schema (version 1); `findings` array + `summary` block |
| SARIF 2.1.0 | `--format sarif` | Consumed by GitHub Code Scanning and GitLab SAST |
| JUnit XML | `--format junit` | CI test-report artifact (`artifacts:reports:junit`) |
| GitHub annotations | `--format github` | `::error file=…,line=…,title=RULE::message` inline PR comments |
**JSON schema (`schema_version: 1`):**
```json
{
"schema_version": 1,
"glint_version": "v0.2.20",
"pipeline": ".gitlab-ci.yml",
"findings": [
{"rule":"GL004","severity":"error","file":".gitlab-ci.yml","line":14,
"job":"deploy","message":"stage \"production\" is not defined in 'stages'"}
],
"summary": {"total": 1, "errors": 1, "warnings": 0}
}
```
---
## Configuration
### `.glint.yml` project config file
Searched from the pipeline file's directory upward to the first `.git` boundary.
```yaml
# Suppress rules globally for this project.
ignore:
- GL007 # migrating from only:/except:
- GL032 # dynamic variables injected by CI
# Override rule severity (error | warning | ignore).
# 'ignore' is equivalent to listing the rule in ignore:.
severity:
GL004: warning # demote during a stage migration
GL035: error # promote to hard error for this project
# Extra stage names valid beyond those in the pipeline's own stages: block.
stages:
- quality
- security
# Default GitLab token (lower priority than --token and GITLAB_TOKEN).
token: glpat-xxxx
# Default GitLab instance URL.
url: https://gitlab.example.com
# Default cache directory.
cache_dir: ~/.cache/glint
```
**Priority chain:** `--token`/`--gitlab-url` flags > `.glint.yml` > environment variables.
### Inline suppression (`# glint: ignore`)
Suppress a finding for a specific job by placing a comment immediately before it:
```yaml
# glint: ignore GL007
legacy-job:
only: [main]
script: echo ok
# Multiple rules (comma- or space-separated):
# glint: ignore GL007, GL032
other-job:
script: echo ok
# Suppress every rule for this job:
# glint: ignore all
noisy-job:
script: echo ok
```
Suppressions are scoped to the single job they precede and do not affect other
jobs or pipeline-level findings.
---
## Graph visualization (`glint graph`)
| Mode | Output |
|------|--------|
| `tree` (default) | Terminal job tree: stages as branches, jobs as leaves; annotated with `[manual]`, `[delayed]`, `[trigger]` where applicable |
| `includes` | Mermaid flowchart to stdout; colour-coded nodes by include type (local, remote, project, component, template) |
| `pipeline` | GitLab CI-style SVG/PNG written to `--out` directory (default: `glint-out/`); converted to PNG when `rsvg-convert`, `inkscape`, or `magick` is available |
| `all` | `includes` to stdout + `pipeline` file path to stderr |
In DAG pipelines (any job has `needs:`) the pipeline graph uses job-to-job
Bézier connectors instead of stage-to-stage lines.
---
## Developer tools
| Tool | Description |
|------|-------------|
| `glint explain <RULE>` | Print description, rationale, bad-YAML example, and fix for a rule. Case-insensitive (`gl007` = `GL007`). |
| `glint explain` | List all rules with ID, severity, and title. |
| `--list-vars` | Print all resolved pipeline variables (pipeline + workflow rules + context) to stderr before linting. |
| `--version` / `-v` | Print the compiled version string. |
+13 -270
View File
@@ -6,32 +6,23 @@
<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.13-blue.svg" alt="Release"></a> <a href="CHANGELOG.md"><img src="https://img.shields.io/badge/release-v0.2.20-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.
A local tool to validate and lint `.gitlab-ci.yml` pipelines without needing a GitLab server. A local tool to validate and lint `.gitlab-ci.yml` pipelines without needing a GitLab server.
## Features ## What it does
- **YAML validation** — detects malformed pipeline files early - **Lints** — 43 rules covering pipeline structure, keyword constraints, `needs:`/`dependencies:` graphs, expression reachability, and deprecations (GL001GL043); run `glint explain <ID>` for any rule
- **Stage validation** — every job's `stage` must be declared in `stages` - **Resolves includes** — local files, HTTPS URLs, GitLab project templates, and CI/CD Catalog components, with offline cache support
- **`extends:` resolution** — resolves single and multi-level template inheritance before linting, so derived jobs are evaluated against their fully merged definition - **Simulates context** — `--branch`, `--tag`, `--source` flags evaluate `rules:if:` and `only`/`except` to show which jobs would be active, manual, or skipped
- **`needs:` DAG validation** — checks that `needs:` references exist, respect stage ordering, and contain no circular dependencies - **Multiple output formats** — `--format text` (default, ruff-style), `json`, `sarif` (GitHub Code Scanning / GitLab SAST), `junit`, `github` (PR annotations)
- **`dependencies:` validation** — checks that artifact dependency references exist and are in earlier stages - **Project config** — `.glint.yml` for rule suppression, severity overrides, token/URL defaults; `# glint: ignore RULE` for per-job inline suppression
- **Keyword validation** — validates constraints on `when`, `parallel`, `retry`, `allow_failure`, `trigger`, `artifacts`, `cache`, `release`, `environment`, `coverage`, `rules`, and more - **Graph visualization** — `glint graph` prints a terminal job tree; `glint graph pipeline` renders a GitLab CI-style SVG/PNG
- **Remote project includes** — fetches `include: project:` templates from the GitLab API so extends/needs can be validated against the full merged pipeline
- **CI/CD catalog components** — resolves `include: component:` references from the GitLab CI/CD Catalog; public components work without a token
- **Deprecation warnings** — flags `only`/`except` usage in favour of `rules`
- **Local include resolution** — `include: local:` entries are read from disk and recursively merged before linting, so multi-file pipelines are fully validated
- **Extended variable declarations** — `variables:` entries may use the `{value, description, options}` map form (GitLab CI 13.7+); `default.image` accepts both string and map form; `rules.changes`/`rules.exists` accept both list and `{paths, compare_to}` map form
- **Graph output** — `glint graph` prints a job tree (stages → jobs) to the terminal; `glint graph includes` emits a Mermaid include dependency diagram; `glint graph pipeline` renders a GitLab CI-style PNG/SVG
- **Context simulation** — pass `--branch`, `--tag`, or `--source` to `glint check` or `glint graph` to see which jobs would be active, manual, or skipped for a specific pipeline event; evaluates `rules:if:` expressions and `only`/`except` filters
- **Variable expansion** — `$VAR` and `${VAR}` references inside variable values are expanded after all sources are merged (pipeline defaults → workflow-rule overrides → CLI flags); transitive chains resolve automatically; visible via `--list-vars`
- **Non-string variable scalars** — `BUILD: true`, `RETRIES: 3` and other bare boolean/integer variable values are handled correctly throughout: they render in `--list-vars` output and are injected into the evaluation context as their string equivalents, matching GitLab CI's behaviour
See [ROADMAP.md](ROADMAP.md) for planned improvements. See [FEATURES.md](FEATURES.md) for the complete feature reference and lint rules table, and [ROADMAP.md](ROADMAP.md) for planned improvements.
## Requirements ## Requirements
@@ -60,260 +51,12 @@ glint [OPTIONS] <COMMAND>
Commands: Commands:
check Lint a pipeline file — exits 0 (clean) or 1 (errors found) check Lint a pipeline file — exits 0 (clean) or 1 (errors found)
graph Visualise the pipeline as a job tree or Mermaid graph graph Visualise the pipeline as a job tree or Mermaid graph
explain Print description and fix for a lint rule
``` ```
Run `glint <command> --help` for command-specific options and examples. Run `glint <command> --help` for all flags. See [USAGE.md](USAGE.md) for full
examples covering output formats, context simulation, remote includes, cache,
### `glint check` graph modes, and project configuration.
```bash
glint check .gitlab-ci.yml
```
Exits `0` when no errors are found, `1` when at least one error is reported.
### Remote project includes
Pipelines that include templates from other GitLab projects are supported.
Provide a token so `glint` can fetch them:
```bash
# personal access token (read_api scope)
GITLAB_TOKEN=glpat-xxxx glint check .gitlab-ci.yml
# CI/CD job token (when running inside a pipeline)
CI_JOB_TOKEN=$CI_JOB_TOKEN glint check .gitlab-ci.yml
# self-hosted GitLab
GITLAB_TOKEN=glpat-xxxx GITLAB_URL=https://gitlab.example.com glint check .gitlab-ci.yml
# or via flags
glint check --token glpat-xxxx --gitlab-url https://gitlab.example.com .gitlab-ci.yml
```
**Project includes** require a token; without one they are skipped with a
warning and the rest of the pipeline is linted as-is.
**Component includes** (`include: component: ...`) attempt the fetch
unauthenticated, so public [CI/CD Catalog](https://gitlab.com/explore/catalog)
components work without a token. A warning is emitted if the fetch fails.
Token resolution order (first non-empty wins):
| Source | Header used |
|--------|-------------|
| `--token` flag / `GITLAB_TOKEN` | `PRIVATE-TOKEN` |
| `CI_JOB_TOKEN` | `JOB-TOKEN` |
| `GITLAB_PRIVATE_TOKEN` | `PRIVATE-TOKEN` |
Instance URL resolution order: `--gitlab-url` flag → `CI_SERVER_URL`
`GITLAB_URL``https://gitlab.com`
### Component reference format
```
<host>/<project-path>/<component-name>@<version>
gitlab.com/components/secret-detection/secret-detection@v0.1.0
gitlab.com/my-org/ci-catalog/lint@main
gitlab.example.com/platform/components/build@~latest
```
The component file is looked up in order:
1. `templates/<component-name>.yml` (single-file layout)
2. `templates/<component-name>/template.yml` (directory layout)
Component input parameters (`with:`) are not validated — they are resolved by
GitLab at runtime. Jobs in fetched components may use `$[[ inputs.xxx ]]`
placeholders in fields like `stage`; `glint` skips those fields rather
than producing false positive errors.
### `glint graph`
Visualise the pipeline. Without a mode word, prints a job tree and the include
dependency graph separated by `---`.
```bash
# Default: job tree + include dependency graph
glint graph .gitlab-ci.yml
# Job tree only (stages → jobs, like the tree command)
glint graph tree .gitlab-ci.yml
# Include dependency graph → Mermaid flowchart to stdout
glint graph includes .gitlab-ci.yml > includes.mmd
# GitLab-like pipeline layout → PNG (or SVG fallback) written to --out dir
glint graph pipeline .gitlab-ci.yml
# prints the output file path, e.g.: glint-out/pipeline-20260607-143022.png
# Mermaid to stdout + pipeline file path to stderr
glint graph all .gitlab-ci.yml > includes.mmd
# Custom output directory (pipeline mode)
glint graph pipeline --out /tmp/graphs .gitlab-ci.yml
```
**Job tree** (`graph tree`) — stages as branches, jobs as leaves. Jobs with
`when: manual`, `when: delayed`, or `trigger:` are annotated in brackets.
**Include graph** (`graph includes`) — [Mermaid](https://mermaid.js.org) flowchart written to stdout.
Pipe to a `.mmd` file or paste into [mermaid.live](https://mermaid.live).
One node per include entry, colour-coded by type:
- Orange (bold): the main pipeline file
- Purple: `project:` includes
- Green: `component:` includes
- Blue: `local:` includes
- Grey: `remote:` URL includes
- Light orange: GitLab-provided `template:` includes
**Pipeline graph** (`graph pipeline`) — GitLab CI-style SVG rendered to a timestamped file
in the `--out` directory (default: `glint-out/`). Converted to PNG automatically
when `rsvg-convert`, `inkscape`, or `magick` is available; falls back to SVG otherwise.
Jobs are colour-coded by type:
- Blue (`#1f75cb`): regular jobs
- Orange (`#fc6d26`): `when: manual` jobs
- Purple (`#6b4fbb`): `trigger:` jobs
- Amber (`#fca326`): `when: delayed` jobs
DAG mode (job-to-job Bézier arrows) activates automatically when any job has a `needs:` list.
Classic mode draws L-shaped or straight connectors between stage columns otherwise.
### Context simulation
Pass `--branch`, `--tag`, or `--source` to `glint check` to see which jobs
would run for a given pipeline event. The pipeline is still fully linted;
context output is printed first.
```bash
# What runs on a push to develop?
glint check --branch develop .gitlab-ci.yml
# What runs when a v1.2.0 tag is pushed?
glint check --tag v1.2.0 .gitlab-ci.yml
# Merge request pipeline
glint check --source merge_request_event .gitlab-ci.yml
# Arbitrary variable overrides (repeatable)
glint check --branch main --var DEPLOY_ENV=production .gitlab-ci.yml
```
**Evaluated:**
- `rules:if:` — full expression language: `==`, `!=`, `=~`, `!~`, `&&`, `||`, `!`, `()`, `$VAR`, string literals, `null`
- `only:` / `except:` — ref keywords (`branches`, `tags`, `merge_requests`, `schedules`, …), branch name globs (`feat/*`), and `/regex/` patterns
- Variable expansion — `$VAR` / `${VAR}` references within variable values are expanded after all sources are merged; use `--list-vars` to inspect the resolved values
**Not evaluated** (no git tree at lint time): `rules:changes:`, `rules:exists:`.
Rules without an `if:` clause always match.
**Predefined variables** set automatically by the shortcut flags:
| Flag | Variables populated |
|------|---------------------|
| `--branch <name>` | `CI_COMMIT_BRANCH`, `CI_COMMIT_REF_NAME`, `CI_COMMIT_REF_SLUG`, `CI_PIPELINE_SOURCE=push` |
| `--tag <name>` | `CI_COMMIT_TAG`, `CI_COMMIT_REF_NAME`, `CI_COMMIT_REF_SLUG`, `CI_PIPELINE_SOURCE=push` (clears `CI_COMMIT_BRANCH`) |
| `--source <event>` | `CI_PIPELINE_SOURCE` |
| `--var KEY=VALUE` | any variable; overrides shortcuts |
### Example output
```
# Clean pipeline (implicit default: --branch main --source push)
Context: branch=main, source=push
Active (5): build, deploy-staging, test, ...
OK: .gitlab-ci.yml — no issues found (5 job(s), 3 stage(s))
# With --branch develop context
Context: branch=develop, source=push
Active (3): build, deploy-staging, test
Skipped (2): deploy-prod, release-notes
OK: .gitlab-ci.yml — no issues found (5 job(s), 3 stage(s))
# With --tag v1.0.0 context
Context: tag=v1.0.0, source=push
Active (4): build, deploy-prod, release-notes, test
Skipped (1): deploy-staging
OK: .gitlab-ci.yml — no issues found (5 job(s), 3 stage(s))
# Pipeline with issues
.gitlab-ci.yml:14: GL004 [error] job "deploy": stage "production" is not defined in 'stages'
.gitlab-ci.yml:22: GL027 [error] job "test": needs unknown job "build-app"
.gitlab-ci.yml:31: GL007 [warning] job "old-job": 'only'/'except' are deprecated; prefer 'rules'
3 finding(s): 2 error(s)
```
## Lint rules
Every finding includes a stable rule ID (e.g. `GL003`) that can be used to filter output or reference a specific check in documentation.
### Pipeline-level
| ID | Severity | Rule |
|----|----------|------|
| GL002 | ERROR | `workflow.rules[*].when` is not `always` or `never` |
| GL001 | WARNING | No `stages` defined (GitLab falls back to default stages) |
### Job-level — structure
| ID | Severity | Rule |
|----|----------|------|
| GL003 | ERROR | Job is missing required `script` (or `run`) — non-trigger, non-template jobs |
| GL004 | ERROR | Job references a `stage` not declared in `stages` |
| GL005 | ERROR | `only` and `rules` used together on the same job |
| GL006 | ERROR | `except` and `rules` used together on the same job |
| GL007 | WARNING | `only`/`except` used (deprecated, prefer `rules`) |
### Job-level — keyword constraints
| ID | Severity | Rule |
|----|----------|------|
| GL008 | ERROR | `when` is not one of `on_success`, `on_failure`, `always`, `manual`, `delayed`, `never` |
| GL009 | ERROR | `when: delayed` without `start_in` |
| GL010 | ERROR | `start_in` set but `when` is not `delayed` |
| GL011 | ERROR | `parallel` integer not in range 2200, or map form missing `matrix` key |
| GL012 | ERROR | `retry` integer not in range 02, or `retry.max` out of range |
| GL013 | ERROR | `retry.when` contains an unrecognised failure type |
| GL014 | ERROR | `allow_failure` is not a boolean or a map with `exit_codes` |
| GL015 | ERROR | `interruptible` is not a boolean |
| GL016 | ERROR | `trigger` job also has `script` |
| GL017 | ERROR | `trigger` map missing `project` or `include` |
| GL018 | ERROR | `coverage` is not a regex pattern wrapped in `/` |
| GL019 | ERROR | `release` missing required `tag_name`, or is not a map |
| GL020 | ERROR | `environment.url` set without `environment.name`, or invalid `environment.action` |
| GL021 | ERROR | `artifacts.when` invalid, or `artifacts.expose_as` set without `artifacts.paths` |
| GL022 | WARNING | `pages` job `artifacts.paths` does not include `public` |
| GL023 | ERROR | `cache.when` or `cache.policy` has an invalid value |
| GL024 | ERROR | `rules[*].when` is not one of the valid `when` values |
| GL025 | ERROR | `image` map form missing `name` key |
| GL026 | ERROR | `inherit.default` / `inherit.variables` is not a boolean or list |
### Cross-job graph
| ID | Severity | Rule |
|----|----------|------|
| GL027 | ERROR/WARNING | `needs:` references a job that does not exist (WARNING when `optional: true`) |
| GL028 | ERROR | `needs:` references a job in a later stage |
| GL029 | ERROR | Circular dependency detected in `needs:` graph |
| GL030 | ERROR | `dependencies:` references a job that does not exist |
| GL031 | ERROR | `dependencies:` references a job in the same or a later stage |
### Expression validation
| ID | Severity | Rule |
|----|----------|------|
| GL032 | WARNING | `rules:if:` references `$VAR` not declared in `variables:` (pipeline, job, or `workflow:rules:variables:`) — may be a false positive for variables set in GitLab CI/CD project settings |
### Hidden jobs (templates)
Jobs whose name starts with `.` are treated as reusable templates and skipped for most rules. This matches GitLab's own behaviour.
## Development ## Development
+41 -65
View File
@@ -4,44 +4,25 @@ This document tracks planned improvements to `glint`. Items are grouped by theme
--- ---
## Context-aware validation — ✓ single-context shipped in v0.2.0; expression evaluator hardened post-v0.2.0; implicit defaults and --list-vars shipped v0.2.11; variable expansion and scalar handling shipped v0.2.13 ## Context simulation
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. Pass `--branch`, `--tag`, `--source`, or `--var` to `glint check` or `glint graph` to evaluate `rules:if:` expressions and `only`/`except` filters against a specific pipeline event.
```bash - ~~**Single-context simulation**~~ — ✓ shipped v0.2.0; `--branch`, `--tag`, `--source`, `--var` flags on both subcommands; jobs classified as active / manual / skipped
# shipped: single-context simulation - ~~**`workflow:rules:variables:` propagation**~~ — ✓ shipped post-v0.2.0; variables from the matching workflow rule entry injected into the evaluation context before job `rules:if:` expressions are evaluated
glint check --branch develop .gitlab-ci.yml - ~~**Expression evaluator: multi-line `if:` values**~~ — ✓ shipped post-v0.2.0; newlines in block-scalar and folded YAML `if:` values treated as whitespace
glint check --tag v1.2.0 .gitlab-ci.yml - ~~**Expression evaluator: `${VAR}` syntax**~~ — ✓ shipped post-v0.2.0; `${CI_COMMIT_BRANCH}` equivalent to `$CI_COMMIT_BRANCH` everywhere
glint check --source merge_request_event --var CI_MERGE_REQUEST_TARGET_BRANCH_NAME=main .gitlab-ci.yml - ~~**Expression evaluator: regex flags**~~ — ✓ shipped post-v0.2.0; `/pattern/i`, `/pattern/m`, `/pattern/s` supported
glint graph tree --branch main .gitlab-ci.yml # tree annotated with [skipped] / [manual] - ~~**Expression evaluator: variable as regex RHS**~~ — ✓ shipped post-v0.2.0; `$BRANCH =~ $PATTERN` where `$PATTERN` holds a `/regex/` string evaluates correctly
``` - ~~**Expression evaluator: bare `true`/`false` and integer literals**~~ — ✓ shipped post-v0.2.0; `$FLAG == true`, `$COUNT == 4` compare as decimal strings matching GitLab CI behaviour
- ~~**Implicit default context**~~ — ✓ shipped v0.2.11; defaults to `--branch main --source push` when no context flag is given, so `rules:if:` is always evaluated
**Shipped post-v0.2.0 (unreleased)** - ~~**`--list-vars` debug flag**~~ — ✓ shipped v0.2.11; prints sorted `KEY=VALUE` of all collected pipeline variables (root file + includes + workflow-rule union + effective context) to stderr
- ~~**Variable expansion**~~ — ✓ shipped v0.2.13; `$VAR`/`${VAR}` references within variable values expanded after all sources are merged; transitive chains resolved; visible in `--list-vars`
- **`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. - ~~**Non-string scalar variables**~~ — ✓ shipped v0.2.13; `BUILD: true`, `RETRIES: 3` rendered and injected correctly instead of being silently dropped
- **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. - ~~**YAML `\/` escape in double-quoted strings**~~ — ✓ shipped v0.2.13; regex patterns like `/^us\//` in double-quoted `if:` blocks no longer cause a parse error
- **Expression evaluator: `${VAR}` curly-brace syntax**`${CI_COMMIT_BRANCH}` is equivalent to `$CI_COMMIT_BRANCH` everywhere. - ~~**Workflow rule strict evaluation**~~ — ✓ shipped v0.2.14; unparseable `if:` skips the rule instead of matching everything; prevents wrong variables being injected
- **Expression evaluator: regex flags**`/pattern/i`, `/pattern/m`, `/pattern/s` are now supported; `i` maps to `(?i)` in Go's regexp. - ~~**Single `=` operator**~~ — ✓ shipped v0.2.14; bare `=` accepted as alias for `==` in `rules:if:` expressions
- **Expression evaluator: variable as regex RHS**`$BRANCH =~ $PATTERN` where `$PATTERN` holds a `/regex/` string is evaluated correctly. - **Multi-context simulation** — run multiple contexts in one invocation and print a comparison table (`--context branch=main --context branch=develop --context tag=v1.0.0`)
-**Expression evaluator: bare `true` / `false` keywords** — treated as the strings `"true"` / `"false"` matching GitLab CI's own behaviour; `$GATEWAY_ENABLED == true` now evaluates correctly.
-**Expression evaluator: integer literals**`$COUNT == 4`, `$ENABLED == 1`, `$DISABLED == 0` compare as decimal strings.
~~**Implicit default context**~~ — ✓ shipped v0.2.11; `glint check` and `glint graph` default to `--branch main --source push` when no context flag is given, so `rules:if:` expressions are always evaluated out of the box.
~~**`--list-vars` debug flag**~~ — ✓ shipped v0.2.11; prints sorted `KEY=VALUE` of all collected variables (pipeline YAML + included files + workflow-rule union + effective context) to stderr.
**Shipped in v0.2.13**
-**Variable expansion**`$VAR` / `${VAR}` references within variable values are expanded after all sources are merged; transitive chains resolve over multiple passes; visible in `--list-vars` effective-context output.
-**Non-string scalar variables**`BUILD: true`, `RETRIES: 3` and similar bare boolean/integer values now render correctly in `--list-vars` and are injected into the evaluation context as string equivalents; previously shown as `(complex)` and silently dropped.
-**YAML `\/` escape in double-quoted strings** — regex patterns like `/^us\//` in double-quoted `if:` blocks no longer cause a parse error; the raw bytes are preprocessed before YAML unmarshalling.
**Remaining work**
- **Multi-context simulation** — run multiple contexts in one invocation and print a comparison table:
```bash
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 - **`rules:changes:` evaluation** — path glob evaluation against the local git tree
@@ -52,15 +33,16 @@ 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 (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 - ~~**Variable reference validation (GL032)**~~ — ✓ shipped v0.2.11; warns when a `rules:if:` expression references `$VAR` / `${VAR}` not declared anywhere in pipeline YAML; predefined GitLab namespaces (`CI_*`, `GITLAB_*`, …) exempt; variables from included files are also considered
- **`services:` validation** — map form requires `name`; `alias` must be a valid DNS label - ~~**`rules:if:` static reachability (GL033)**~~ — ✓ shipped v0.2.15; warns when every rule in a job's `rules:` block has `when: never`, making the job permanently excluded from any pipeline run; no `if:` evaluation required
- **`rules:changes` / `rules:exists`** — warn on glob patterns that can never match (e.g. absolute paths, double `**` on unsupported versions) - ~~**`services:` validation (GL034)**~~ — ✓ shipped v0.2.16; map form requires `name`; `alias` must be a valid DNS label
- **`timeout` format** — must be a duration string GitLab understands (`1h 30m`, `90 minutes`, etc.) - ~~**`rules:changes` / `rules:exists` absolute path detection (GL035)**~~ — ✓ shipped v0.2.16; warns when a path starts with `/`; GitLab CI paths are always relative to the repo root
- **`id_tokens:` / `secrets:`** — presence and required-key checks - ~~**`timeout` format validation (GL036)**~~ — ✓ shipped v0.2.16; validates job-level and `default.timeout` against recognised GitLab CI duration strings
- **`pages:publish`** — validate that the path is consistent with `artifacts.paths` - ~~**`id_tokens:` / `secrets:` required-key checks (GL037, GL038)**~~ — ✓ shipped v0.2.16; `id_tokens` entries must have `aud`; `secrets` entries must declare a provider
- **`inherit:` completeness** — flag when a job overrides a default field that would require `inherit: default: false` to suppress - ~~**`pages:publish` + `artifacts.paths` consistency (GL039)**~~ — ✓ shipped v0.2.16; warns when the publish directory is missing from `artifacts.paths`
- **Unreachable jobs** — detect jobs that can never run because every `rules:` branch evaluates to `never` (static analysis only) - ~~**Duplicate stage names (GL040)**~~ — ✓ shipped v0.2.16; warns when a stage appears more than once in `stages:`
- **Duplicate stage names** — GitLab silently merges them; warn to avoid confusion - ~~**`cache:key:files` must be exact paths (GL041)**~~ — ✓ shipped v0.2.16; warns when entries look like glob patterns
- **`cache:key:files`** — must be a list of paths, not a glob - ~~**Unreachable jobs**~~ — covered by GL033 (shipped v0.2.15); every-`when:never` rules block is statically dead
- ~~**`inherit:` completeness (GL043)**~~ — ✓ shipped v0.2.20; warns when `inherit: default:` is declared but there's no `default:` block, or list form names fields not set in `default:`
--- ---
@@ -68,20 +50,18 @@ The current rule set covers the most common sources of broken pipelines. These a
- ~~**`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)~~ — ✓ 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 - ~~**`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**~~✓ shipped v0.2.17; depth capped at 100 (matching GitLab); project/component includes now tracked in visited set to prevent cross-file cycles
- **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**~~ — ✓ shipped v0.2.17; `--cache-dir DIR` persists fetched templates; `--offline` serves from cache only; default cache dir (`~/.cache/glint`) used automatically with `--offline`
- **`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:`**~~ — ✓ shipped v0.2.17; `$[[ inputs.KEY ]]` and `$[[ inputs.KEY | default(…) ]]` placeholders in fetched component YAML are substituted from the include's `with:` block before parsing
--- ---
## Output formats ## Output formats — ✓ shipped v0.2.18
Right now the only output is plain-text findings. Structured output enables integration with other tools. - ~~**JSON** (`--format json`)~~ — ✓ shipped v0.2.18; machine-readable findings with stable schema (version 1)
- ~~**SARIF** (`--format sarif`)~~ — ✓ shipped v0.2.18; SARIF 2.1.0; consumed natively by GitHub Code Scanning and GitLab SAST
- **JSON** (`--format json`) — machine-readable findings with file, job, severity, rule ID, and message; stable schema - ~~**JUnit XML** (`--format junit`)~~ — ✓ shipped v0.2.18; lets CI pipelines publish lint results as a test report artifact
- **SARIF** (`--format sarif`) — [Static Analysis Results Interchange Format](https://sarifweb.azurewebsites.net); consumed natively by GitHub Code Scanning and GitLab SAST - ~~**GitHub annotation format** (`--format github`)~~ — ✓ shipped v0.2.18; emits `::error file=…,line=…,title=RULE::message` lines so findings appear as inline comments in PR diffs
- **JUnit XML** (`--format junit`) — lets CI pipelines publish lint results as a test report artifact
- **GitHub / GitLab annotation format** — emit `::error file=…,line=…::message` lines so findings appear as inline comments in PR diffs
--- ---
@@ -110,7 +90,7 @@ The SVG renderer and terminal tree cover the basic layout. These would bring it
- ~~**`needs: optional: true` false-positive errors**~~ — ✓ shipped post-v0.2.0; optional missing needs are downgraded to `[WARNING]` - ~~**`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) - ~~**`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) - ~~**`rules:if:` static reachability (GL042)**~~ — ✓ shipped v0.2.20; warns when all `rules:if:` conditions evaluate to false given declared variable values (only fires when all referenced vars are declared in YAML)
--- ---
@@ -124,21 +104,17 @@ The SVG renderer and terminal tree cover the basic layout. These would bring it
--- ---
## Configuration ## Configuration — ✓ shipped v0.2.19
- **`.glint.yml` config file** — project-level configuration for: - ~~**`.glint.yml` config file**~~✓ shipped v0.2.19; `ignore:`, `severity:`, `stages:`, `token:`, `url:`, `cache_dir:`; searched from the pipeline directory up to the git root
- Rule suppression by rule ID (e.g. `ignore: [no-only, missing-stages]`) - ~~**Inline suppression comments**~~ — ✓ shipped v0.2.19; `# glint: ignore GL007` before a job definition; comma/space-separated rules; `# glint: ignore all` wildcard
- Severity overrides (demote specific errors to warnings)
- Custom `stages` allowlist for projects that use a non-standard default set
- Token and URL defaults so flags are not needed in every invocation
- **Inline suppression comments** — `# glint: ignore next-line <rule-id>` in the pipeline YAML
--- ---
## Reliability and developer experience ## Reliability and developer experience
- ~~**Structured rule IDs**~~ — ✓ shipped post-v0.2.0; GL001GL031 assigned; GL032 added v0.2.11 - ~~**Structured rule IDs**~~ — ✓ shipped post-v0.2.0; GL001GL031 assigned; GL032 added v0.2.11; GL033 added v0.2.15; GL034GL041 added v0.2.16; output formats (--format json/sarif/junit/github) added v0.2.18; GL042GL043 added v0.2.20
- **`--explain <rule-id>`** — print the rule description, rationale, and an example fix - ~~**`glint explain <rule-id>`**~~ — ✓ shipped v0.2.20; prints rule description, rationale, bad-YAML example, and fix; `glint explain` (no arg) lists all rules
- ~~**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`
- **Changelog automation** — generate release notes from Conventional Commits via `git-cliff` or similar - **Changelog automation** — generate release notes from Conventional Commits via `git-cliff` or similar
+37 -3
View File
@@ -3,6 +3,8 @@ version: "3"
vars: vars:
BINARY: glint BINARY: glint
GO: /usr/local/go/bin/go GO: /usr/local/go/bin/go
VERSION:
sh: git describe --tags --always --dirty 2>/dev/null || echo "dev"
tasks: tasks:
default: default:
@@ -12,7 +14,7 @@ tasks:
build: build:
desc: Build the glint binary desc: Build the glint binary
cmds: cmds:
- "{{.GO}} build -o {{.BINARY}} ./cmd/glint/..." - "{{.GO}} build -ldflags \"-X main.version={{.VERSION}}\" -o {{.BINARY}} ./cmd/glint/..."
sources: sources:
- "**/*.go" - "**/*.go"
- go.mod - go.mod
@@ -79,12 +81,44 @@ tasks:
ignore_error: false ignore_error: false
- cmd: ./{{.BINARY}} check testdata/variable_refs_included.yml - cmd: ./{{.BINARY}} check testdata/variable_refs_included.yml
ignore_error: false ignore_error: false
- cmd: ./{{.BINARY}} check testdata/dead_rules.yml
ignore_error: false
- cmd: ./{{.BINARY}} check testdata/new_rules_valid.yml
ignore_error: false
- cmd: ./{{.BINARY}} check testdata/new_rules_invalid.yml
ignore_error: true
- cmd: ./{{.BINARY}} check testdata/samba/.gitlab-ci.yml - cmd: ./{{.BINARY}} check testdata/samba/.gitlab-ci.yml
ignore_error: false ignore_error: false
- cmd: ./{{.BINARY}} check testdata/samba/.gitlab-ci-coverage.yml - cmd: ./{{.BINARY}} check testdata/samba/.gitlab-ci-coverage.yml
ignore_error: false ignore_error: false
- cmd: ./{{.BINARY}} check testdata/samba/.gitlab-ci-private.yml - cmd: ./{{.BINARY}} check testdata/samba/.gitlab-ci-private.yml
ignore_error: false ignore_error: false
- cmd: ./{{.BINARY}} check --format json testdata/valid.yml
ignore_error: false
- cmd: ./{{.BINARY}} check --format sarif testdata/valid.yml
ignore_error: false
- cmd: ./{{.BINARY}} check --format junit testdata/valid.yml
ignore_error: false
- cmd: ./{{.BINARY}} check --format github testdata/invalid.yml
ignore_error: true
- cmd: ./{{.BINARY}} check testdata/config_ignored/.gitlab-ci.yml
ignore_error: false
- cmd: ./{{.BINARY}} check testdata/config_severity/.gitlab-ci.yml
ignore_error: false
- cmd: ./{{.BINARY}} check testdata/config_suppress/.gitlab-ci.yml
ignore_error: false
- cmd: ./{{.BINARY}} check testdata/static_dead_rules.yml
ignore_error: false
- cmd: ./{{.BINARY}} check testdata/inherit_dead.yml
ignore_error: false
- cmd: ./{{.BINARY}} check testdata/inherit_dead_fields.yml
ignore_error: false
- cmd: ./{{.BINARY}} explain GL007
ignore_error: false
- cmd: ./{{.BINARY}} explain gl042
ignore_error: false
- cmd: ./{{.BINARY}} explain
ignore_error: false
lint-go: lint-go:
desc: Run go vet on all packages desc: Run go vet on all packages
@@ -112,7 +146,7 @@ tasks:
- sh: git describe --tags --exact-match - sh: git describe --tags --exact-match
msg: "Current commit is not tagged — Windows build requires a git tag" msg: "Current commit is not tagged — Windows build requires a git tag"
cmds: cmds:
- "GOOS=windows GOARCH=amd64 {{.GO}} build -o {{.BINARY}}-{{.TAG}}.exe ./cmd/glint/..." - "GOOS=windows GOARCH=amd64 {{.GO}} build -ldflags \"-X main.version={{.TAG}}\" -o {{.BINARY}}-{{.TAG}}.exe ./cmd/glint/..."
sources: sources:
- "**/*.go" - "**/*.go"
- go.mod - go.mod
@@ -128,7 +162,7 @@ tasks:
- sh: git describe --tags --exact-match - sh: git describe --tags --exact-match
msg: "Current commit is not tagged — Linux build requires a git tag" msg: "Current commit is not tagged — Linux build requires a git tag"
cmds: cmds:
- "GOOS=linux GOARCH=amd64 {{.GO}} build -o {{.BINARY}}-{{.TAG}}-linux-amd64 ./cmd/glint/..." - "GOOS=linux GOARCH=amd64 {{.GO}} build -ldflags \"-X main.version={{.TAG}}\" -o {{.BINARY}}-{{.TAG}}-linux-amd64 ./cmd/glint/..."
sources: sources:
- "**/*.go" - "**/*.go"
- go.mod - go.mod
+314
View File
@@ -0,0 +1,314 @@
# glint — usage reference
Full examples and option descriptions for every command.
For the lint rules reference see [FEATURES.md](FEATURES.md).
---
## `glint check`
Lint a pipeline file. Exits `0` when no errors are found, `1` when at least
one error is reported (warnings alone do not fail).
```bash
glint check .gitlab-ci.yml
```
### Output formats
Pass `--format` to control the output. Plain text is the default.
```bash
# Default: ruff-style text (human-readable)
glint check .gitlab-ci.yml
# JSON — stable schema, machine-readable
glint check --format json .gitlab-ci.yml
# SARIF 2.1.0 — GitHub Code Scanning / GitLab SAST
glint check --format sarif .gitlab-ci.yml > glint.sarif
# JUnit XML — CI test-report artifact (GitLab: artifacts:reports:junit)
glint check --format junit .gitlab-ci.yml > glint-junit.xml
# GitHub Actions annotations — inline PR diff comments
glint check --format github .gitlab-ci.yml
```
In structured formats (`json`, `sarif`, `junit`, `github`) the summary line is
written to stderr so stdout contains only the machine-readable payload.
**JSON schema (`schema_version: 1`):**
```json
{
"schema_version": 1,
"glint_version": "v0.2.20",
"pipeline": ".gitlab-ci.yml",
"findings": [
{"rule":"GL004","severity":"error","file":".gitlab-ci.yml","line":14,
"job":"deploy","message":"stage \"production\" is not defined in 'stages'"}
],
"summary": {"total": 1, "errors": 1, "warnings": 0}
}
```
**GitHub annotation lines:**
```
::error file=.gitlab-ci.yml,line=14,title=GL004::job "deploy": stage "production" is not defined in 'stages'
```
### Context simulation
Pass `--branch`, `--tag`, or `--source` to evaluate `rules:if:` and
`only`/`except` against a specific pipeline event. The pipeline is still fully
linted; the context summary is printed first. When no context flag is given,
glint defaults to `--branch main --source push`.
```bash
# What runs on a push to develop?
glint check --branch develop .gitlab-ci.yml
# What runs when a v1.2.0 tag is pushed?
glint check --tag v1.2.0 .gitlab-ci.yml
# Merge request pipeline
glint check --source merge_request_event .gitlab-ci.yml
# Arbitrary variable overrides (repeatable)
glint check --branch main --var DEPLOY_ENV=production .gitlab-ci.yml
# Debug variable resolution
glint check --list-vars .gitlab-ci.yml
```
**Predefined variables** set by the shortcut flags:
| Flag | Variables populated |
|------|---------------------|
| `--branch NAME` | `CI_COMMIT_BRANCH`, `CI_COMMIT_REF_NAME`, `CI_COMMIT_REF_SLUG`, `CI_PIPELINE_SOURCE=push` |
| `--tag NAME` | `CI_COMMIT_TAG`, `CI_COMMIT_REF_NAME`, `CI_COMMIT_REF_SLUG`, `CI_PIPELINE_SOURCE=push`; clears `CI_COMMIT_BRANCH` |
| `--source EVENT` | `CI_PIPELINE_SOURCE` |
| `--var KEY=VALUE` | any variable; overrides shortcuts; repeatable |
### Remote project includes
Provide a token so glint can fetch `include: project:` templates:
```bash
# Personal access token (read_api scope)
GITLAB_TOKEN=glpat-xxxx glint check .gitlab-ci.yml
# CI/CD job token (when running inside a pipeline)
CI_JOB_TOKEN=$CI_JOB_TOKEN glint check .gitlab-ci.yml
# Self-hosted GitLab
GITLAB_TOKEN=glpat-xxxx GITLAB_URL=https://gitlab.example.com glint check .gitlab-ci.yml
# Via flags (override env vars)
glint check --token glpat-xxxx --gitlab-url https://gitlab.example.com .gitlab-ci.yml
```
Project includes are skipped with a warning when no token is available; linting
continues with whatever is resolved.
**Token resolution order** (first non-empty wins):
| Source | Header |
|--------|--------|
| `--token` flag / `GITLAB_TOKEN` env | `PRIVATE-TOKEN` |
| `CI_JOB_TOKEN` env | `JOB-TOKEN` |
| `GITLAB_PRIVATE_TOKEN` env | `PRIVATE-TOKEN` |
**URL resolution order:** `--gitlab-url` flag → `CI_SERVER_URL` env → `GITLAB_URL` env → `https://gitlab.com`
### Include cache and offline mode
```bash
# Cache fetched templates to disk (keyed by SHA-256 of the request coordinates)
glint check --cache-dir ~/.cache/glint .gitlab-ci.yml
# Fully offline — serve from cache, warn on misses, make no network calls
glint check --offline .gitlab-ci.yml
glint check --offline --cache-dir /path/to/cache .gitlab-ci.yml
```
`--offline` without `--cache-dir` uses `~/.cache/glint` automatically.
There is no automatic cache expiry; delete the directory or individual entries
to force a re-fetch.
### Component reference format
`include: component:` references follow the format:
```
<host>/<project-path>/<component-name>@<version>
gitlab.com/components/secret-detection/secret-detection@v0.1.0
gitlab.com/my-org/ci-catalog/lint@main
gitlab.example.com/platform/components/build@~latest
```
The component file is looked up in order:
1. `templates/<component-name>.yml` (single-file layout)
2. `templates/<component-name>/template.yml` (directory layout)
Public Catalog components work without a token. `with:` input parameters are
substituted locally before parsing; they are not validated against the
component spec.
### Example output
```
# Clean pipeline
Context: branch=main, source=push
Active (5): build, deploy-staging, test, lint, security-scan
OK: .gitlab-ci.yml — no issues found (5 job(s), 3 stage(s))
# With --branch develop
Context: branch=develop, source=push
Active (3): build, deploy-staging, test
Skipped (2): deploy-prod, release-notes
OK: .gitlab-ci.yml — no issues found (5 job(s), 3 stage(s))
# Pipeline with issues
.gitlab-ci.yml:14: GL004 [error] job "deploy": stage "production" is not defined in 'stages'
.gitlab-ci.yml:22: GL027 [error] job "test": needs unknown job "build-app"
.gitlab-ci.yml:31: GL007 [warning] job "old-job": 'only'/'except' are deprecated; prefer 'rules'
3 finding(s): 2 error(s)
```
---
## `glint graph`
Visualise the pipeline. Without a mode word, prints a job tree and the include
dependency graph separated by `---`.
```bash
# Default: job tree + include dependency graph
glint graph .gitlab-ci.yml
# Job tree only
glint graph tree .gitlab-ci.yml
# Job tree with context (shows active/manual/skipped annotations)
glint graph tree --branch develop .gitlab-ci.yml
# Include dependency graph → Mermaid flowchart to stdout
glint graph includes .gitlab-ci.yml > includes.mmd
# GitLab-like pipeline layout → PNG/SVG written to --out dir
glint graph pipeline .gitlab-ci.yml
# prints the output path, e.g.: glint-out/pipeline-20260614-143022.png
# Mermaid to stdout + pipeline file path to stderr
glint graph all .gitlab-ci.yml > includes.mmd
# Custom output directory
glint graph pipeline --out /tmp/graphs .gitlab-ci.yml
```
**Job tree** — stages as branches, jobs as leaves; annotated with `[manual]`,
`[delayed]`, `[trigger]` where applicable. Context flags apply the same
evaluation as `glint check`.
**Include graph** — [Mermaid](https://mermaid.js.org) flowchart; pipe to `.mmd`
or paste into [mermaid.live](https://mermaid.live). Nodes are colour-coded by
include type: orange (main file), purple (project), green (component), blue
(local), grey (remote URL), light orange (GitLab template).
**Pipeline graph** — GitLab CI-style SVG rendered to a timestamped file.
Converted to PNG when `rsvg-convert`, `inkscape`, or `magick` is available.
DAG mode (Bézier arrows between jobs) activates automatically when any job has
a `needs:` list; classic mode uses stage-column connectors otherwise.
---
## `glint explain`
Print the documentation for a specific lint rule.
```bash
# Description, example, and fix for GL007
glint explain GL007
# Case-insensitive
glint explain gl007
# List all rules with ID, severity, and title
glint explain
```
---
## Project configuration (`.glint.yml`)
Place a `.glint.yml` anywhere in the directory tree from the pipeline file up
to the first `.git` boundary. glint searches upward and uses the first file it
finds.
```yaml
# Suppress rules globally for this project.
ignore:
- GL007 # migrating from only:/except:
- GL032 # dynamic variables injected by CI
# Override rule severity: error | warning | ignore
# 'ignore' is equivalent to listing the rule under ignore:.
severity:
GL004: warning # demote during a stage migration
GL035: error # promote to hard error for this project
# Extra stage names valid beyond those declared in the pipeline YAML
# (e.g. injected by an include template you don't own).
stages:
- quality
- security
# Default token — lower priority than --token and GITLAB_TOKEN.
token: glpat-xxxx
# Default GitLab instance URL.
url: https://gitlab.example.com
# Default cache directory for fetched remote includes.
cache_dir: ~/.cache/glint
```
**Priority chain:** `--token`/`--gitlab-url` flags > `.glint.yml` > environment variables.
---
## Inline suppression (`# glint: ignore`)
Suppress a finding for a specific job by placing a comment immediately before
the job definition in the pipeline YAML:
```yaml
# glint: ignore GL007
legacy-job:
stage: build
only: [main]
script: echo ok
# Multiple rules — comma- or space-separated:
# glint: ignore GL007, GL032
other-job:
stage: build
script: echo ok
# Suppress every rule for this job:
# glint: ignore all
noisy-job:
stage: build
script: echo ok
```
Suppressions are scoped to the single job they precede and do not affect other
jobs or pipeline-level findings. For project-wide suppression, use `.glint.yml`
`ignore:` instead.
+69
View File
@@ -0,0 +1,69 @@
package main
import (
"fmt"
"os"
"sort"
"strings"
"git.k3nny.fr/glint/internal/linter"
)
func cmdExplain(args []string) {
if len(args) == 0 {
printRuleList()
return
}
ruleID := strings.ToUpper(args[0])
entry, ok := linter.RuleCatalog[ruleID]
if !ok {
fmt.Fprintf(os.Stderr, "glint explain: unknown rule %q\n\nRun 'glint explain' to list all rules.\n", ruleID)
os.Exit(2)
}
printRuleEntry(ruleID, entry)
}
func printRuleEntry(id string, e linter.RuleEntry) {
sev := strings.ToLower(string(e.Severity))
header := fmt.Sprintf("%s [%s] %s", id, sev, e.Title)
rule := fmt.Sprintf("\n%s\n%s\n\n%s\n",
header,
strings.Repeat("─", len(header)),
e.Description,
)
fmt.Print(rule)
if e.Example != "" {
fmt.Println("Example:")
fmt.Println()
for _, line := range strings.Split(e.Example, "\n") {
fmt.Printf(" %s\n", line)
}
fmt.Println()
}
if e.Fix != "" {
fmt.Println("Fix:")
fmt.Println()
for _, line := range strings.Split(e.Fix, "\n") {
fmt.Printf(" %s\n", line)
}
fmt.Println()
}
}
func printRuleList() {
ids := make([]string, 0, len(linter.RuleCatalog))
for id := range linter.RuleCatalog {
ids = append(ids, id)
}
sort.Strings(ids)
fmt.Printf("glint %s — lint rules\n\n", version)
for _, id := range ids {
e := linter.RuleCatalog[id]
sev := strings.ToLower(string(e.Severity))
fmt.Printf(" %-6s [%-7s] %s\n", id, sev, e.Title)
}
fmt.Println("\nUse 'glint explain <RULE>' for details on a specific rule.")
}
+87
View File
@@ -0,0 +1,87 @@
package main
import (
"strings"
"git.k3nny.fr/glint/internal/config"
"git.k3nny.fr/glint/internal/linter"
)
// applyConfig filters and adjusts findings according to the project config
// and inline suppression comments parsed from the pipeline YAML.
//
// Processing order:
// 1. Build a combined ignore set from config.Ignore and any severity entry
// whose value is "ignore".
// 2. Drop findings whose rule is in the ignore set.
// 3. Drop findings suppressed by an inline "# glint: ignore" comment on the
// job definition (from p.Suppressions).
// 4. Apply severity overrides ("error" / "warning") from config.Severity.
func applyConfig(findings []linter.Finding, cfg config.Config, suppressions map[string][]string) []linter.Finding {
// Build the global ignore set (uppercased rule IDs).
ignoreSet := make(map[string]bool, len(cfg.Ignore))
for _, r := range cfg.Ignore {
ignoreSet[strings.ToUpper(r)] = true
}
// Severity entries with value "ignore" are equivalent to Ignore entries.
sevMap := make(map[string]string, len(cfg.Severity)) // upperRule → lowerLevel
for rule, sev := range cfg.Severity {
upper := strings.ToUpper(rule)
lower := strings.ToLower(sev)
sevMap[upper] = lower
if lower == "ignore" {
ignoreSet[upper] = true
}
}
if len(ignoreSet) == 0 && len(sevMap) == 0 && len(suppressions) == 0 {
return findings
}
kept := findings[:0:0] // reuse underlying array but return fresh slice
for _, f := range findings {
ruleUpper := strings.ToUpper(f.Rule)
// Global ignore.
if ignoreSet[ruleUpper] {
continue
}
// Inline suppression.
if isSuppressed(f.Job, ruleUpper, suppressions) {
continue
}
// Severity override.
if level, ok := sevMap[ruleUpper]; ok {
switch level {
case "error":
f.Severity = linter.Error
case "warning":
f.Severity = linter.Warning
}
}
kept = append(kept, f)
}
return kept
}
// isSuppressed reports whether jobName has a "# glint: ignore" directive that
// covers ruleUpper. The wildcard entry "*" (from "# glint: ignore all")
// suppresses every rule.
func isSuppressed(jobName, ruleUpper string, suppressions map[string][]string) bool {
if jobName == "" || len(suppressions) == 0 {
return false
}
rules, ok := suppressions[jobName]
if !ok {
return false
}
for _, r := range rules {
if r == "*" || r == ruleUpper {
return true
}
}
return false
}
+116
View File
@@ -0,0 +1,116 @@
package main
import (
"testing"
"git.k3nny.fr/glint/internal/config"
"git.k3nny.fr/glint/internal/linter"
)
func TestApplyConfig(t *testing.T) {
findings := []linter.Finding{
{Severity: linter.Error, Rule: "GL004", Job: "deploy", File: "ci.yml", Line: 10, Message: "bad stage"},
{Severity: linter.Warning, Rule: "GL007", Job: "old-job", File: "ci.yml", Line: 20, Message: "deprecated"},
{Severity: linter.Warning, Rule: "GL032", Job: "check", File: "ci.yml", Line: 30, Message: "var ref"},
}
t.Run("no config — pass through", func(t *testing.T) {
got := applyConfig(findings, config.Config{}, nil)
if len(got) != 3 {
t.Errorf("got %d findings, want 3", len(got))
}
})
t.Run("ignore GL007", func(t *testing.T) {
cfg := config.Config{Ignore: []string{"GL007"}}
got := applyConfig(findings, cfg, nil)
if len(got) != 2 {
t.Fatalf("got %d findings, want 2", len(got))
}
for _, f := range got {
if f.Rule == "GL007" {
t.Error("GL007 should be suppressed")
}
}
})
t.Run("ignore case-insensitive", func(t *testing.T) {
cfg := config.Config{Ignore: []string{"gl007"}}
got := applyConfig(findings, cfg, nil)
if len(got) != 2 {
t.Fatalf("got %d, want 2", len(got))
}
})
t.Run("severity demote error to warning", func(t *testing.T) {
cfg := config.Config{Severity: map[string]string{"GL004": "warning"}}
got := applyConfig(findings, cfg, nil)
if len(got) != 3 {
t.Fatalf("got %d findings, want 3", len(got))
}
if got[0].Severity != linter.Warning {
t.Errorf("GL004 severity = %s, want WARNING", got[0].Severity)
}
})
t.Run("severity promote warning to error", func(t *testing.T) {
cfg := config.Config{Severity: map[string]string{"GL007": "error"}}
got := applyConfig(findings, cfg, nil)
if got[1].Severity != linter.Error {
t.Errorf("GL007 severity = %s, want ERROR", got[1].Severity)
}
})
t.Run("severity ignore is equivalent to ignore list", func(t *testing.T) {
cfg := config.Config{Severity: map[string]string{"GL032": "ignore"}}
got := applyConfig(findings, cfg, nil)
if len(got) != 2 {
t.Fatalf("got %d, want 2", len(got))
}
for _, f := range got {
if f.Rule == "GL032" {
t.Error("GL032 should be suppressed via severity=ignore")
}
}
})
t.Run("inline suppression by job", func(t *testing.T) {
suppressions := map[string][]string{
"old-job": {"GL007"},
}
got := applyConfig(findings, config.Config{}, suppressions)
if len(got) != 2 {
t.Fatalf("got %d, want 2", len(got))
}
for _, f := range got {
if f.Job == "old-job" && f.Rule == "GL007" {
t.Error("old-job GL007 should be suppressed")
}
}
})
t.Run("inline suppression wildcard", func(t *testing.T) {
suppressions := map[string][]string{
"old-job": {"*"},
}
got := applyConfig(findings, config.Config{}, suppressions)
// old-job had GL007; should be gone
if len(got) != 2 {
t.Fatalf("got %d, want 2", len(got))
}
})
t.Run("pipeline-level findings not suppressed by job comment", func(t *testing.T) {
pipelineFindings := []linter.Finding{
{Severity: linter.Error, Rule: "GL001", Job: "", File: "ci.yml", Line: 0, Message: "no stages"},
}
suppressions := map[string][]string{
"": {"GL001"}, // empty job key should not match pipeline-level
}
got := applyConfig(pipelineFindings, config.Config{}, suppressions)
// Pipeline-level findings (f.Job == "") are never suppressed by job comments.
if len(got) != 1 {
t.Errorf("got %d, want 1 (pipeline-level finding must not be suppressed)", len(got))
}
})
}
+332
View File
@@ -0,0 +1,332 @@
package main
import (
"encoding/json"
"encoding/xml"
"fmt"
"io"
"strings"
"git.k3nny.fr/glint/internal/linter"
)
// --- JSON ---
type jsonReport struct {
SchemaVersion int `json:"schema_version"`
GlintVersion string `json:"glint_version"`
Pipeline string `json:"pipeline"`
Findings []jsonFinding `json:"findings"`
Summary jsonSummary `json:"summary"`
}
type jsonFinding struct {
Rule string `json:"rule,omitempty"`
Severity string `json:"severity"`
File string `json:"file,omitempty"`
Line int `json:"line,omitempty"`
Job string `json:"job,omitempty"`
Message string `json:"message"`
}
type jsonSummary struct {
Total int `json:"total"`
Errors int `json:"errors"`
Warnings int `json:"warnings"`
}
func writeJSON(w io.Writer, findings []linter.Finding, pipeline string) {
errs, warns := countSeverities(findings)
jf := make([]jsonFinding, 0, len(findings))
for _, f := range findings {
jf = append(jf, jsonFinding{
Rule: f.Rule,
Severity: strings.ToLower(string(f.Severity)),
File: f.File,
Line: f.Line,
Job: f.Job,
Message: f.Message,
})
}
report := jsonReport{
SchemaVersion: 1,
GlintVersion: version,
Pipeline: pipeline,
Findings: jf,
Summary: jsonSummary{Total: len(findings), Errors: errs, Warnings: warns},
}
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
_ = enc.Encode(report)
}
// --- SARIF 2.1.0 ---
type sarifLog struct {
Schema string `json:"$schema"`
Version string `json:"version"`
Runs []sarifRun `json:"runs"`
}
type sarifRun struct {
Tool sarifTool `json:"tool"`
Results []sarifResult `json:"results"`
}
type sarifTool struct {
Driver sarifDriver `json:"driver"`
}
type sarifDriver struct {
Name string `json:"name"`
Version string `json:"version"`
InformationURI string `json:"informationUri"`
Rules []sarifRule `json:"rules"`
}
type sarifRule struct {
ID string `json:"id"`
ShortDescription sarifMessage `json:"shortDescription"`
HelpURI string `json:"helpUri,omitempty"`
}
type sarifMessage struct {
Text string `json:"text"`
}
type sarifResult struct {
RuleID string `json:"ruleId,omitempty"`
Level string `json:"level"`
Message sarifMessage `json:"message"`
Locations []sarifLocation `json:"locations,omitempty"`
}
type sarifLocation struct {
PhysicalLocation sarifPhysLoc `json:"physicalLocation"`
}
type sarifPhysLoc struct {
ArtifactLocation sarifArtifact `json:"artifactLocation"`
Region *sarifRegion `json:"region,omitempty"`
}
type sarifArtifact struct {
URI string `json:"uri"`
URIBaseID string `json:"uriBaseId,omitempty"`
}
type sarifRegion struct {
StartLine int `json:"startLine"`
}
func writeSARIF(w io.Writer, findings []linter.Finding, _ string) {
// Collect unique rule IDs (preserving first-seen order).
seenRules := map[string]bool{}
var rules []sarifRule
for _, f := range findings {
if f.Rule != "" && !seenRules[f.Rule] {
seenRules[f.Rule] = true
rules = append(rules, sarifRule{
ID: f.Rule,
ShortDescription: sarifMessage{Text: "glint rule " + f.Rule},
HelpURI: "https://git.k3nny.fr/glint",
})
}
}
if rules == nil {
rules = []sarifRule{}
}
var results []sarifResult
for _, f := range findings {
level := "warning"
if f.Severity == linter.Error {
level = "error"
}
msg := f.Message
if f.Job != "" {
msg = fmt.Sprintf("job %q: %s", f.Job, msg)
}
result := sarifResult{
RuleID: f.Rule,
Level: level,
Message: sarifMessage{Text: msg},
}
if f.File != "" {
loc := sarifLocation{
PhysicalLocation: sarifPhysLoc{
ArtifactLocation: sarifArtifact{URI: f.File, URIBaseID: "%SRCROOT%"},
},
}
if f.Line > 0 {
loc.PhysicalLocation.Region = &sarifRegion{StartLine: f.Line}
}
result.Locations = []sarifLocation{loc}
}
results = append(results, result)
}
if results == nil {
results = []sarifResult{}
}
log := sarifLog{
Schema: "https://json.schemastore.org/sarif-2.1.0.json",
Version: "2.1.0",
Runs: []sarifRun{{
Tool: sarifTool{Driver: sarifDriver{
Name: "glint",
Version: strings.TrimPrefix(version, "v"),
InformationURI: "https://git.k3nny.fr/glint",
Rules: rules,
}},
Results: results,
}},
}
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
_ = enc.Encode(log)
}
// --- JUnit XML ---
type junitTestsuites struct {
XMLName xml.Name `xml:"testsuites"`
Name string `xml:"name,attr"`
Tests int `xml:"tests,attr"`
Failures int `xml:"failures,attr"`
Time string `xml:"time,attr"`
Suites []junitTestsuite `xml:"testsuite"`
}
type junitTestsuite struct {
Name string `xml:"name,attr"`
Tests int `xml:"tests,attr"`
Failures int `xml:"failures,attr"`
Time string `xml:"time,attr"`
Cases []junitTestcase `xml:"testcase"`
}
type junitTestcase struct {
Name string `xml:"name,attr"`
Classname string `xml:"classname,attr"`
Failure *junitFailure `xml:"failure,omitempty"`
}
type junitFailure struct {
Message string `xml:"message,attr"`
Type string `xml:"type,attr"`
Body string `xml:",chardata"`
}
func writeJUnit(w io.Writer, findings []linter.Finding, pipeline string) {
_, fails := countSeverities(findings)
_ = fails // re-derive below to count both errors and warnings as failures
var cases []junitTestcase
if len(findings) == 0 {
cases = []junitTestcase{{Name: "no issues found", Classname: pipeline}}
} else {
for _, f := range findings {
name := f.Rule
if name == "" {
name = "lint"
}
classname := f.File
if classname == "" {
classname = pipeline
}
if f.Job != "" {
classname += "#" + f.Job
}
msg := f.Message
if f.Job != "" {
msg = fmt.Sprintf("job %q: %s", f.Job, msg)
}
cases = append(cases, junitTestcase{
Name: name,
Classname: classname,
Failure: &junitFailure{
Message: msg,
Type: strings.ToLower(string(f.Severity)),
Body: f.String(),
},
})
}
}
suite := junitTestsuite{
Name: pipeline,
Tests: len(cases),
Failures: len(findings), // all findings are failures
Time: "0",
Cases: cases,
}
suites := junitTestsuites{
Name: "glint",
Tests: len(cases),
Failures: len(findings),
Time: "0",
Suites: []junitTestsuite{suite},
}
fmt.Fprint(w, `<?xml version="1.0" encoding="UTF-8"?>`)
fmt.Fprintln(w)
enc := xml.NewEncoder(w)
enc.Indent("", " ")
_ = enc.Encode(suites)
_ = enc.Flush()
fmt.Fprintln(w)
}
// --- GitHub Actions annotations ---
// writeGitHub emits GitHub Actions workflow command annotation lines.
// Each finding becomes an ::error:: or ::warning:: line that GitHub CI
// renders as an inline comment on the relevant file in pull requests.
func writeGitHub(w io.Writer, findings []linter.Finding) {
for _, f := range findings {
level := "warning"
if f.Severity == linter.Error {
level = "error"
}
msg := f.Message
if f.Job != "" {
msg = fmt.Sprintf("job %q: %s", f.Job, msg)
}
// GitHub annotation messages must not contain raw newlines, percent signs,
// carriage returns, or colons in the parameter block.
msg = strings.ReplaceAll(msg, "%", "%25")
msg = strings.ReplaceAll(msg, "\r", "%0D")
msg = strings.ReplaceAll(msg, "\n", "%0A")
var params []string
if f.File != "" {
params = append(params, "file="+f.File)
if f.Line > 0 {
params = append(params, fmt.Sprintf("line=%d", f.Line))
}
}
if f.Rule != "" {
params = append(params, "title="+f.Rule)
}
paramStr := ""
if len(params) > 0 {
paramStr = " " + strings.Join(params, ",")
}
fmt.Fprintf(w, "::%s%s::%s\n", level, paramStr, msg)
}
}
// --- shared helpers ---
func countSeverities(findings []linter.Finding) (errors, warnings int) {
for _, f := range findings {
if f.Severity == linter.Error {
errors++
} else {
warnings++
}
}
return
}
+228
View File
@@ -0,0 +1,228 @@
package main
import (
"bytes"
"encoding/json"
"encoding/xml"
"strings"
"testing"
"git.k3nny.fr/glint/internal/linter"
)
var testFindings = []linter.Finding{
{
Severity: linter.Error,
Rule: "GL004",
Job: "deploy",
File: ".gitlab-ci.yml",
Line: 14,
Message: `stage "production" is not defined in 'stages'`,
},
{
Severity: linter.Warning,
Rule: "GL007",
Job: "old-job",
File: ".gitlab-ci.yml",
Line: 31,
Message: "'only'/'except' are deprecated; prefer 'rules'",
},
}
func TestWriteJSON(t *testing.T) {
t.Run("findings", func(t *testing.T) {
var buf bytes.Buffer
writeJSON(&buf, testFindings, ".gitlab-ci.yml")
var got jsonReport
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if got.SchemaVersion != 1 {
t.Errorf("schema_version = %d, want 1", got.SchemaVersion)
}
if len(got.Findings) != 2 {
t.Fatalf("got %d findings, want 2", len(got.Findings))
}
if got.Summary.Errors != 1 || got.Summary.Warnings != 1 {
t.Errorf("summary = %+v, want {2,1,1}", got.Summary)
}
if got.Findings[0].Rule != "GL004" || got.Findings[0].Severity != "error" {
t.Errorf("finding[0] = %+v", got.Findings[0])
}
if got.Findings[0].Job != "deploy" || got.Findings[0].Line != 14 {
t.Errorf("finding[0] job/line = %s/%d", got.Findings[0].Job, got.Findings[0].Line)
}
})
t.Run("empty", func(t *testing.T) {
var buf bytes.Buffer
writeJSON(&buf, nil, "ci.yml")
var got jsonReport
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if got.Findings == nil {
t.Error("findings must be [] not null")
}
if got.Summary.Total != 0 {
t.Errorf("total = %d, want 0", got.Summary.Total)
}
})
}
func TestWriteSARIF(t *testing.T) {
t.Run("findings", func(t *testing.T) {
var buf bytes.Buffer
writeSARIF(&buf, testFindings, ".gitlab-ci.yml")
var got sarifLog
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if got.Version != "2.1.0" {
t.Errorf("version = %q, want 2.1.0", got.Version)
}
if len(got.Runs) != 1 {
t.Fatalf("runs count = %d, want 1", len(got.Runs))
}
run := got.Runs[0]
if run.Tool.Driver.Name != "glint" {
t.Errorf("driver.name = %q", run.Tool.Driver.Name)
}
if len(run.Results) != 2 {
t.Fatalf("results count = %d, want 2", len(run.Results))
}
r0 := run.Results[0]
if r0.RuleID != "GL004" || r0.Level != "error" {
t.Errorf("result[0] ruleId/level = %s/%s", r0.RuleID, r0.Level)
}
if len(r0.Locations) != 1 {
t.Fatalf("result[0] locations count = %d, want 1", len(r0.Locations))
}
loc := r0.Locations[0].PhysicalLocation
if loc.ArtifactLocation.URI != ".gitlab-ci.yml" {
t.Errorf("artifactLocation.uri = %q", loc.ArtifactLocation.URI)
}
if loc.Region == nil || loc.Region.StartLine != 14 {
t.Errorf("region = %v", loc.Region)
}
// Message should include job name prefix.
if !strings.Contains(r0.Message.Text, `job "deploy"`) {
t.Errorf("message missing job prefix: %q", r0.Message.Text)
}
})
t.Run("empty", func(t *testing.T) {
var buf bytes.Buffer
writeSARIF(&buf, nil, "ci.yml")
var got sarifLog
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
run := got.Runs[0]
if run.Results == nil {
t.Error("results must be [] not null")
}
if run.Tool.Driver.Rules == nil {
t.Error("rules must be [] not null")
}
})
}
func TestWriteJUnit(t *testing.T) {
t.Run("findings", func(t *testing.T) {
var buf bytes.Buffer
writeJUnit(&buf, testFindings, ".gitlab-ci.yml")
if !strings.HasPrefix(buf.String(), "<?xml") {
t.Error("output does not start with XML declaration")
}
var got junitTestsuites
if err := xml.Unmarshal(buf.Bytes(), &got); err != nil {
t.Fatalf("invalid XML: %v\noutput:\n%s", err, buf.String())
}
if got.Failures != 2 {
t.Errorf("failures = %d, want 2", got.Failures)
}
if len(got.Suites) != 1 {
t.Fatalf("suites count = %d, want 1", len(got.Suites))
}
suite := got.Suites[0]
if len(suite.Cases) != 2 {
t.Fatalf("test cases count = %d, want 2", len(suite.Cases))
}
if suite.Cases[0].Name != "GL004" {
t.Errorf("case[0].name = %q", suite.Cases[0].Name)
}
if suite.Cases[0].Failure == nil {
t.Error("case[0] has no failure element")
}
})
t.Run("empty_is_passing", func(t *testing.T) {
var buf bytes.Buffer
writeJUnit(&buf, nil, "ci.yml")
var got junitTestsuites
if err := xml.Unmarshal(buf.Bytes(), &got); err != nil {
t.Fatalf("invalid XML: %v", err)
}
if got.Failures != 0 {
t.Errorf("failures = %d, want 0", got.Failures)
}
suite := got.Suites[0]
if len(suite.Cases) != 1 || suite.Cases[0].Failure != nil {
t.Error("expected exactly one passing testcase when no findings")
}
})
}
func TestWriteGitHub(t *testing.T) {
cases := []struct {
name string
findings []linter.Finding
wantLine string
}{
{
name: "error with file and line",
findings: []linter.Finding{testFindings[0]},
wantLine: "::error file=.gitlab-ci.yml,line=14,title=GL004::job \"deploy\": stage \"production\" is not defined in 'stages'",
},
{
name: "warning",
findings: []linter.Finding{testFindings[1]},
wantLine: "::warning file=.gitlab-ci.yml,line=31,title=GL007::job \"old-job\": 'only'/'except' are deprecated; prefer 'rules'",
},
{
name: "no file or line",
findings: []linter.Finding{{
Severity: linter.Error,
Rule: "GL001",
Message: "no stages defined",
}},
wantLine: "::error title=GL001::no stages defined",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var buf bytes.Buffer
writeGitHub(&buf, tc.findings)
got := strings.TrimRight(buf.String(), "\n")
if got != tc.wantLine {
t.Errorf("\ngot: %s\nwant: %s", got, tc.wantLine)
}
})
}
}
func TestCountSeverities(t *testing.T) {
errs, warns := countSeverities(testFindings)
if errs != 1 || warns != 1 {
t.Errorf("countSeverities = (%d, %d), want (1, 1)", errs, warns)
}
}
+138 -23
View File
@@ -9,6 +9,7 @@ import (
"strings" "strings"
"git.k3nny.fr/glint/internal/cicontext" "git.k3nny.fr/glint/internal/cicontext"
"git.k3nny.fr/glint/internal/config"
"git.k3nny.fr/glint/internal/fetcher" "git.k3nny.fr/glint/internal/fetcher"
"git.k3nny.fr/glint/internal/graph" "git.k3nny.fr/glint/internal/graph"
"git.k3nny.fr/glint/internal/linter" "git.k3nny.fr/glint/internal/linter"
@@ -16,6 +17,21 @@ import (
"git.k3nny.fr/glint/internal/resolver" "git.k3nny.fr/glint/internal/resolver"
) )
// version is set at build time via -ldflags "-X main.version=vX.Y.Z".
var version = "dev"
// defaultCacheDir returns the platform-default glint cache directory:
// $XDG_CACHE_HOME/glint or ~/.cache/glint.
func defaultCacheDir() string {
if xdg := os.Getenv("XDG_CACHE_HOME"); xdg != "" {
return filepath.Join(xdg, "glint")
}
if home, err := os.UserHomeDir(); err == nil {
return filepath.Join(home, ".cache", "glint")
}
return ""
}
const globalUsage = `glint: Lint and visualise GitLab CI pipelines locally. const globalUsage = `glint: Lint and visualise GitLab CI pipelines locally.
Usage: glint [OPTIONS] <COMMAND> Usage: glint [OPTIONS] <COMMAND>
@@ -23,9 +39,11 @@ Usage: glint [OPTIONS] <COMMAND>
Commands: Commands:
check Lint a pipeline file — exits 0 (clean) or 1 (errors found) check Lint a pipeline file — exits 0 (clean) or 1 (errors found)
graph Visualise the pipeline as a job tree or Mermaid graph graph Visualise the pipeline as a job tree or Mermaid graph
explain Show description and fix for a lint rule (e.g. glint explain GL007)
Options: Options:
-h, --help Print help -h, --help Print help
-v, --version Print version
For help with a specific command, see: ` + "`glint <command> --help`" + `. For help with a specific command, see: ` + "`glint <command> --help`" + `.
` `
@@ -40,8 +58,13 @@ func main() {
cmdCheck(os.Args[2:]) cmdCheck(os.Args[2:])
case "graph": case "graph":
cmdGraph(os.Args[2:]) cmdGraph(os.Args[2:])
case "explain":
cmdExplain(os.Args[2:])
case "-h", "--help", "help": case "-h", "--help", "help":
fmt.Fprintf(os.Stderr, "glint %s\n\n", version)
fmt.Fprint(os.Stderr, globalUsage) fmt.Fprint(os.Stderr, globalUsage)
case "-v", "--version", "version":
fmt.Printf("glint %s\n", version)
default: default:
fmt.Fprintf(os.Stderr, "glint: unknown command %q\n\n%s", os.Args[1], globalUsage) fmt.Fprintf(os.Stderr, "glint: unknown command %q\n\n%s", os.Args[1], globalUsage)
os.Exit(2) os.Exit(2)
@@ -61,6 +84,9 @@ func cmdCheck(args []string) {
fs := flag.NewFlagSet("glint check", flag.ExitOnError) fs := flag.NewFlagSet("glint check", flag.ExitOnError)
token := fs.String("token", "", "GitLab personal access token (overrides GITLAB_TOKEN)") token := fs.String("token", "", "GitLab personal access token (overrides GITLAB_TOKEN)")
gitlabURL := fs.String("gitlab-url", "", "GitLab instance URL (overrides CI_SERVER_URL / GITLAB_URL)") gitlabURL := fs.String("gitlab-url", "", "GitLab instance URL (overrides CI_SERVER_URL / GITLAB_URL)")
cacheDir := fs.String("cache-dir", "", "directory to cache fetched remote includes (created if needed)")
offline := fs.Bool("offline", false, "skip all network calls; serve only from --cache-dir")
format := fs.String("format", "text", "output format: text, json, sarif, junit, github")
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")
@@ -68,6 +94,7 @@ func cmdCheck(args []string) {
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() {
fmt.Fprintf(os.Stderr, "glint %s\n\n", version)
fmt.Fprint(os.Stderr, `Lint a GitLab CI pipeline file. fmt.Fprint(os.Stderr, `Lint a GitLab CI pipeline file.
Resolves local includes and extends chains, then runs all lint rules. Resolves local includes and extends chains, then runs all lint rules.
@@ -79,6 +106,10 @@ Arguments:
<PIPELINE> Path to the .gitlab-ci.yml file to lint <PIPELINE> Path to the .gitlab-ci.yml file to lint
Options: Options:
--format <FORMAT>
Output format for findings.
[default: text] [possible values: text, json, sarif, junit, github]
--token <TOKEN> --token <TOKEN>
GitLab personal access token. Required to fetch project: includes; GitLab personal access token. Required to fetch project: includes;
component: includes are attempted unauthenticated. component: includes are attempted unauthenticated.
@@ -88,6 +119,17 @@ Options:
GitLab instance URL. GitLab instance URL.
[env: CI_SERVER_URL | GITLAB_URL] [default: https://gitlab.com] [env: CI_SERVER_URL | GITLAB_URL] [default: https://gitlab.com]
--cache-dir <DIR>
Cache fetched remote templates (project: and component: includes) in
DIR. The directory is created on first use. Subsequent runs read from
cache first, avoiding repeated network calls.
--offline
Do not make any network calls. All remote includes must already be
present in --cache-dir; missing entries emit a warning (same as
having no token). Implies the default cache dir (~/.cache/glint) when
--cache-dir is not set.
--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.
@@ -119,6 +161,10 @@ always evaluated.
Examples: Examples:
glint check .gitlab-ci.yml glint check .gitlab-ci.yml
glint check --format json .gitlab-ci.yml
glint check --format sarif .gitlab-ci.yml | upload-to-github-code-scanning
glint check --format junit .gitlab-ci.yml > junit.xml
glint check --format github .gitlab-ci.yml
glint check --branch develop .gitlab-ci.yml glint check --branch develop .gitlab-ci.yml
glint check --tag v1.0.0 .gitlab-ci.yml glint check --tag v1.0.0 .gitlab-ci.yml
glint check --source merge_request_event .gitlab-ci.yml glint check --source merge_request_event .gitlab-ci.yml
@@ -126,6 +172,8 @@ Examples:
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
glint check --cache-dir ~/.cache/glint .gitlab-ci.yml
glint check --offline --cache-dir ~/.cache/glint .gitlab-ci.yml
`) `)
} }
_ = fs.Parse(args) _ = fs.Parse(args)
@@ -136,13 +184,47 @@ Examples:
*source = "push" *source = "push"
} }
validFormats := map[string]bool{
"text": true, "json": true, "sarif": true, "junit": true, "github": true,
}
if !validFormats[*format] {
fmt.Fprintf(os.Stderr, "glint: unknown format %q; valid: text, json, sarif, junit, github\n", *format)
os.Exit(2)
}
if fs.NArg() != 1 { if fs.NArg() != 1 {
fs.Usage() fs.Usage()
os.Exit(2) os.Exit(2)
} }
path := fs.Arg(0) path := fs.Arg(0)
rootDir := filepath.Dir(filepath.Clean(path))
cfg := fetcher.AutoConfig().WithOverrides(*gitlabURL, *token) // Load project config (.glint.yml), searching from the pipeline directory
// up to the git root.
glintCfg, cfgErr := config.Load(rootDir)
if cfgErr != nil {
fmt.Fprintf(os.Stderr, "%s: [warning] %s: %v\n", path, config.Filename, cfgErr)
}
// CLI flags take priority over config file values, which take priority over
// environment variables (read by AutoConfig).
fetcherToken := *token
if fetcherToken == "" {
fetcherToken = glintCfg.Token
}
fetcherURL := *gitlabURL
if fetcherURL == "" {
fetcherURL = glintCfg.URL
}
resolvedCacheDir := *cacheDir
if resolvedCacheDir == "" {
resolvedCacheDir = glintCfg.CacheDir
}
if *offline && resolvedCacheDir == "" {
resolvedCacheDir = defaultCacheDir()
}
cfg := fetcher.AutoConfig().WithOverrides(fetcherURL, fetcherToken, resolvedCacheDir, *offline)
p, err := model.Parse(path) p, err := model.Parse(path)
if err != nil { if err != nil {
@@ -150,10 +232,22 @@ Examples:
os.Exit(2) os.Exit(2)
} }
rootDir := filepath.Dir(filepath.Clean(path)) // Merge config-defined stages into the pipeline before linting so that
// GL004 does not fire for jobs in stages declared only in .glint.yml.
stageSet := make(map[string]bool, len(p.Stages))
for _, s := range p.Stages {
stageSet[s] = true
}
for _, s := range glintCfg.Stages {
if !stageSet[s] {
p.Stages = append(p.Stages, s)
stageSet[s] = true
}
}
warnings, _ := resolver.ResolveIncludes(p, cfg, rootDir) warnings, _ := resolver.ResolveIncludes(p, cfg, rootDir)
for _, w := range warnings { for _, w := range warnings {
fmt.Fprintf(os.Stderr, "[WARNING] include %s\n", w) fmt.Fprintf(os.Stderr, "%s: [warning] include %s\n", path, w)
} }
extWarnings, err := resolver.Resolve(p) extWarnings, err := resolver.Resolve(p)
@@ -162,42 +256,56 @@ Examples:
os.Exit(2) os.Exit(2)
} }
for _, w := range extWarnings { for _, w := range extWarnings {
fmt.Fprintf(os.Stderr, "[WARNING] job %q extends unknown job %q; extends chain skipped\n", w.Job, w.Base) fmt.Fprintf(os.Stderr, "%s: [warning] job %q extends unknown job %q; extends chain skipped\n", path, w.Job, w.Base)
} }
ctx := cicontext.New(*branch, *tag, *source, vars) ctx := cicontext.New(*branch, *tag, *source, vars)
if !ctx.IsEmpty() { if !ctx.IsEmpty() {
enrichContext(ctx, p) if !enrichContext(ctx, p) {
fmt.Fprintf(os.Stderr, "%s: [warning] workflow:rules: pipeline would not start for this context\n", path)
}
} }
if *listVars { if *listVars {
printVars(p, ctx) printVars(p, ctx)
} }
if !ctx.IsEmpty() { // Context summary only makes sense in plain-text output; suppress it in
// structured formats so stdout contains only the machine-readable payload.
if !ctx.IsEmpty() && *format == "text" {
printContext(p, ctx) printContext(p, ctx)
} }
findings := linter.Lint(p) findings := linter.Lint(p)
hasErrors := false findings = applyConfig(findings, glintCfg, p.Suppressions)
errCount, _ := countSeverities(findings)
// In structured formats the summary line goes to stderr so stdout is clean.
summaryOut := os.Stdout
if *format != "text" {
summaryOut = os.Stderr
}
switch *format {
case "json":
writeJSON(os.Stdout, findings, path)
case "sarif":
writeSARIF(os.Stdout, findings, path)
case "junit":
writeJUnit(os.Stdout, findings, path)
case "github":
writeGitHub(os.Stdout, findings)
default: // "text"
for _, f := range findings { for _, f := range findings {
fmt.Println(f) fmt.Println(f)
if f.Severity == linter.Error {
hasErrors = true
} }
} }
if len(findings) == 0 { if len(findings) == 0 {
fmt.Printf("OK: %s — no issues found (%d job(s), %d stage(s))\n", path, len(p.Jobs), len(p.Stages)) fmt.Fprintf(summaryOut, "OK: %s — no issues found (%d job(s), %d stage(s))\n", path, len(p.Jobs), len(p.Stages))
} else { } else {
errCount := 0 fmt.Fprintf(summaryOut, "%d finding(s): %d error(s)\n", len(findings), errCount)
for _, f := range findings {
if f.Severity == linter.Error {
errCount++
}
}
fmt.Printf("%d finding(s): %d error(s)\n", len(findings), errCount)
} }
if hasErrors { if errCount > 0 {
os.Exit(1) os.Exit(1)
} }
} }
@@ -217,8 +325,11 @@ func cmdGraph(args []string) {
fs := flag.NewFlagSet("glint graph", flag.ExitOnError) fs := flag.NewFlagSet("glint graph", flag.ExitOnError)
token := fs.String("token", "", "GitLab personal access token (overrides GITLAB_TOKEN)") token := fs.String("token", "", "GitLab personal access token (overrides GITLAB_TOKEN)")
gitlabURL := fs.String("gitlab-url", "", "GitLab instance URL (overrides CI_SERVER_URL / GITLAB_URL)") gitlabURL := fs.String("gitlab-url", "", "GitLab instance URL (overrides CI_SERVER_URL / GITLAB_URL)")
cacheDir := fs.String("cache-dir", "", "directory to cache fetched remote includes (created if needed)")
offline := fs.Bool("offline", false, "skip all network calls; serve only from --cache-dir")
out := fs.String("out", "glint-out", "output directory for Mermaid graph files (pipeline mode)") out := fs.String("out", "glint-out", "output directory for Mermaid graph files (pipeline mode)")
fs.Usage = func() { fs.Usage = func() {
fmt.Fprintf(os.Stderr, "glint %s\n\n", version)
fmt.Fprint(os.Stderr, `Visualise the pipeline as a job tree and/or Mermaid graph. fmt.Fprint(os.Stderr, `Visualise the pipeline as a job tree and/or Mermaid graph.
Usage: glint graph [MODE] [OPTIONS] <PIPELINE> Usage: glint graph [MODE] [OPTIONS] <PIPELINE>
@@ -304,7 +415,12 @@ Examples:
} }
path := fs.Arg(0) path := fs.Arg(0)
cfg := fetcher.AutoConfig().WithOverrides(*gitlabURL, *token) resolvedCacheDir := *cacheDir
if *offline && resolvedCacheDir == "" {
resolvedCacheDir = defaultCacheDir()
}
cfg := fetcher.AutoConfig().WithOverrides(*gitlabURL, *token, resolvedCacheDir, *offline)
p, err := model.Parse(path) p, err := model.Parse(path)
if err != nil { if err != nil {
@@ -413,22 +529,21 @@ func varValueString(v any) string {
// enrichContext injects pipeline-level variable defaults and then // enrichContext injects pipeline-level variable defaults and then
// workflow-rule-generated variables into ctx before job evaluation. // workflow-rule-generated variables into ctx before job evaluation.
// Injection respects pinned variables (--branch/--tag/--source/--var always win). // Injection respects pinned variables (--branch/--tag/--source/--var always win).
func enrichContext(ctx *cicontext.Context, p *model.Pipeline) { // Returns false when workflow:rules: would prevent the pipeline from starting.
func enrichContext(ctx *cicontext.Context, p *model.Pipeline) bool {
// Pipeline variables: injected as defaults (lowest priority). // Pipeline variables: injected as defaults (lowest priority).
for k, v := range cicontext.ExtractStringVars(p.Variables) { for k, v := range cicontext.ExtractStringVars(p.Variables) {
ctx.Inject(k, v) ctx.Inject(k, v)
} }
// Workflow rules: evaluate to find which rule matches, then inject its variables. // Workflow rules: evaluate to find which rule matches, then inject its variables.
runs, ruleVars := cicontext.EvalWorkflow(p, ctx) 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 { for k, v := range ruleVars {
ctx.Inject(k, v) ctx.Inject(k, v)
} }
// Expand $VAR / ${VAR} references within variable values now that all // Expand $VAR / ${VAR} references within variable values now that all
// sources (pipeline, workflow rules, CLI) have been merged. // sources (pipeline, workflow rules, CLI) have been merged.
ctx.ExpandVars() ctx.ExpandVars()
return runs
} }
func printContext(p *model.Pipeline, ctx *cicontext.Context) { func printContext(p *model.Pipeline, ctx *cicontext.Context) {
+24 -2
View File
@@ -12,7 +12,7 @@ import (
// - Variable references: $VAR_NAME or ${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: == != =~ !~ (single = is accepted as == for user convenience)
// - Boolean: && || ! // - Boolean: && || !
// - Grouping: ( ) // - Grouping: ( )
// - Regex flags: /pattern/i (case-insensitive), /pattern/m, /pattern/s // - Regex flags: /pattern/i (case-insensitive), /pattern/m, /pattern/s
@@ -23,10 +23,21 @@ import (
// used by GitLab CI. Unsupported or unparseable expressions fall back to true // used by GitLab CI. Unsupported or unparseable expressions fall back to true
// (permissive) so the linter never silently drops jobs it cannot evaluate. // (permissive) so the linter never silently drops jobs it cannot evaluate.
func EvalIf(expr string, vars func(string) string) bool { func EvalIf(expr string, vars func(string) string) bool {
return evalIf(expr, vars, true)
}
// EvalIfStrict is like EvalIf but returns false (instead of true) when the
// expression cannot be fully parsed. Use for workflow:rules: evaluation where
// a failed parse should skip to the next rule rather than matching everything.
func EvalIfStrict(expr string, vars func(string) string) bool {
return evalIf(expr, vars, false)
}
func evalIf(expr string, vars func(string) string, permissive bool) bool {
p := &exprParser{s: strings.TrimSpace(expr), vars: vars} p := &exprParser{s: strings.TrimSpace(expr), vars: vars}
result, ok := p.parseOr() result, ok := p.parseOr()
if !ok || p.pos < len(p.s) { if !ok || p.pos < len(p.s) {
return true // unparseable → permissive return permissive
} }
return result return result
} }
@@ -200,6 +211,17 @@ func (p *exprParser) parseComparison() (bool, bool) {
return true, true // bad pattern → permissive return true, true // bad pattern → permissive
} }
return !re.MatchString(leftStr), true return !re.MatchString(leftStr), true
// Single = not followed by = or ~ — accepted as == (common user mistake;
// GitLab CI only supports == but = is frequently written by accident).
case p.peek() == '=' && !p.startsWith("==") && !p.startsWith("=~"):
p.pos++ // consume '='
p.skipWS()
rightStr, ok := p.parseValue()
if !ok {
return false, false
}
return leftStr == rightStr, true
} }
// No operator: variable is truthy when non-empty (defined and non-null). // No operator: variable is truthy when non-empty (defined and non-null).
+51
View File
@@ -120,6 +120,12 @@ func TestEvalIf(t *testing.T) {
// ── 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},
// ── Single = as alias for == ──────────────────────────────────────────
{"single eq match", `$CI_COMMIT_BRANCH = "develop"`, true},
{"single eq no match", `$CI_COMMIT_BRANCH = "main"`, false},
{"single eq in compound", `$CI_COMMIT_BRANCH = "develop" && $CI_PIPELINE_SOURCE = "push"`, true},
{"single eq compound false", `$CI_COMMIT_BRANCH = "main" && $CI_PIPELINE_SOURCE = "push"`, false},
} }
for _, tc := range tests { for _, tc := range tests {
@@ -131,3 +137,48 @@ func TestEvalIf(t *testing.T) {
}) })
} }
} }
func TestEvalIfStrict(t *testing.T) {
vars := func(key string) string {
m := map[string]string{
"CI_COMMIT_BRANCH": "develop",
"CI_PIPELINE_SOURCE": "push",
"WORKFLOW": "",
}
return m[key]
}
tests := []struct {
name string
expr string
want bool
}{
// Parseable expressions behave identically to EvalIf.
{"parseable match", `$CI_COMMIT_BRANCH == "develop"`, true},
{"parseable no match", `$CI_COMMIT_BRANCH == "main"`, false},
{"single eq match", `$CI_COMMIT_BRANCH = "develop"`, true},
// Empty expression: ruleIfMatchesStrict handles the empty→true case
// before calling EvalIfStrict, so empty falls through to false here.
{"empty expr", ``, false},
// Unparseable expressions return false (strict) instead of true (permissive).
{"unparseable returns false", `this is not valid syntax %%%`, false},
// The key workflow-rule scenario: a complex condition with an
// unevaluable sub-expression should not match (strict=false) so that
// later workflow rules can be evaluated.
{"workflow rule complex no match", `$WORKFLOW = "gitflow" && $CI_PIPELINE_SOURCE == /(push|web)/`, false},
// Compound with a bad second operand: strict returns false.
{"and with bad rhs strict false", `$CI_COMMIT_BRANCH == "develop" && !(((`, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := EvalIfStrict(tc.expr, vars)
if got != tc.want {
t.Errorf("EvalIfStrict(%q) = %v, want %v", tc.expr, got, tc.want)
}
})
}
}
+12 -1
View File
@@ -42,7 +42,11 @@ func EvalWorkflow(p *model.Pipeline, ctx *Context) (bool, map[string]string) {
} }
vars := ctx.Get vars := ctx.Get
for _, rule := range p.Workflow.Rules { for _, rule := range p.Workflow.Rules {
if !ruleIfMatches(rule.If, vars) { // Workflow rules use strict evaluation: an unparseable condition is
// treated as no-match so later rules (with valid conditions or a
// bare when:) are reached. Permissive-true would cause an early rule
// with a complex/invalid condition to block all subsequent rules.
if !ruleIfMatchesStrict(rule.If, vars) {
continue continue
} }
when := rule.When when := rule.When
@@ -94,6 +98,13 @@ func ruleIfMatches(ifExpr string, vars func(string) string) bool {
return EvalIf(ifExpr, vars) return EvalIf(ifExpr, vars)
} }
func ruleIfMatchesStrict(ifExpr string, vars func(string) string) bool {
if ifExpr == "" {
return true // no if: condition → rule always matches
}
return EvalIfStrict(ifExpr, vars)
}
func whenToState(when string) JobState { func whenToState(when string) JobState {
switch when { switch when {
case "never": case "never":
+75
View File
@@ -0,0 +1,75 @@
package config
import (
"fmt"
"os"
"path/filepath"
"gopkg.in/yaml.v3"
)
// Filename is the name of the project-level glint configuration file.
const Filename = ".glint.yml"
// Config holds project-level glint configuration loaded from .glint.yml.
type Config struct {
// Ignore lists rule IDs to suppress entirely (e.g. ["GL007", "GL032"]).
Ignore []string `yaml:"ignore"`
// Severity maps rule IDs to overridden severity levels.
// Valid values: "error", "warning", "ignore".
// "ignore" is equivalent to listing the rule in Ignore.
Severity map[string]string `yaml:"severity"`
// Stages lists additional stage names that are considered valid for this
// project, beyond what is declared in the pipeline's own stages: block.
// Jobs in these stages are not flagged by GL004.
Stages []string `yaml:"stages"`
// Token is a default GitLab personal access token used when neither the
// --token flag nor GITLAB_TOKEN (/ CI_JOB_TOKEN / GITLAB_PRIVATE_TOKEN)
// environment variables are set.
Token string `yaml:"token"`
// URL is the default GitLab instance URL. Overridden by --gitlab-url and
// the CI_SERVER_URL / GITLAB_URL environment variables.
URL string `yaml:"url"`
// CacheDir is the default directory for caching fetched remote includes.
// Overridden by the --cache-dir flag.
CacheDir string `yaml:"cache_dir"`
}
// Load searches for a .glint.yml file starting from dir and walking up toward
// the filesystem root. The walk stops at the first .git directory found (the
// repository root) or at the filesystem root. Returns an empty Config (and no
// error) when no config file is found.
func Load(dir string) (Config, error) {
dir = filepath.Clean(dir)
for {
candidate := filepath.Join(dir, Filename)
data, err := os.ReadFile(candidate)
if err == nil {
var cfg Config
if yerr := yaml.Unmarshal(data, &cfg); yerr != nil {
return Config{}, fmt.Errorf("parsing %s: %w", candidate, yerr)
}
return cfg, nil
}
if !os.IsNotExist(err) {
return Config{}, fmt.Errorf("reading %s: %w", candidate, err)
}
// Stop when we reach a git root so we don't wander into parent repos.
if _, serr := os.Stat(filepath.Join(dir, ".git")); serr == nil {
break
}
parent := filepath.Dir(dir)
if parent == dir {
break // filesystem root
}
dir = parent
}
return Config{}, nil
}
+112
View File
@@ -0,0 +1,112 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestLoad_NotFound(t *testing.T) {
tmp := t.TempDir()
cfg, err := Load(tmp)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(cfg.Ignore) != 0 || cfg.Token != "" || cfg.URL != "" {
t.Errorf("expected empty config, got %+v", cfg)
}
}
func TestLoad_Found(t *testing.T) {
tmp := t.TempDir()
content := `
ignore:
- GL007
- GL032
severity:
GL004: warning
stages:
- quality
token: glpat-test
url: https://gitlab.example.com
cache_dir: /tmp/glint-cache
`
if err := os.WriteFile(filepath.Join(tmp, Filename), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := Load(tmp)
if err != nil {
t.Fatalf("Load: %v", err)
}
if len(cfg.Ignore) != 2 || cfg.Ignore[0] != "GL007" {
t.Errorf("ignore = %v", cfg.Ignore)
}
if cfg.Severity["GL004"] != "warning" {
t.Errorf("severity = %v", cfg.Severity)
}
if len(cfg.Stages) != 1 || cfg.Stages[0] != "quality" {
t.Errorf("stages = %v", cfg.Stages)
}
if cfg.Token != "glpat-test" {
t.Errorf("token = %q", cfg.Token)
}
if cfg.URL != "https://gitlab.example.com" {
t.Errorf("url = %q", cfg.URL)
}
if cfg.CacheDir != "/tmp/glint-cache" {
t.Errorf("cache_dir = %q", cfg.CacheDir)
}
}
func TestLoad_WalksUp(t *testing.T) {
tmp := t.TempDir()
sub := filepath.Join(tmp, "subdir", "pipeline")
if err := os.MkdirAll(sub, 0o755); err != nil {
t.Fatal(err)
}
// Config at the top-level (no .git, so walk continues to tmp).
if err := os.WriteFile(filepath.Join(tmp, Filename), []byte("token: walked-up\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := Load(sub)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Token != "walked-up" {
t.Errorf("token = %q, want walked-up", cfg.Token)
}
}
func TestLoad_StopsAtGitRoot(t *testing.T) {
tmp := t.TempDir()
sub := filepath.Join(tmp, "repo", "src")
if err := os.MkdirAll(sub, 0o755); err != nil {
t.Fatal(err)
}
// .git marks the repo root — walk must stop here.
if err := os.Mkdir(filepath.Join(tmp, "repo", ".git"), 0o755); err != nil {
t.Fatal(err)
}
// Config placed ABOVE the .git root should not be found.
if err := os.WriteFile(filepath.Join(tmp, Filename), []byte("token: should-not-load\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := Load(sub)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Token != "" {
t.Errorf("token = %q, should not have crossed .git boundary", cfg.Token)
}
}
func TestLoad_InvalidYAML(t *testing.T) {
tmp := t.TempDir()
if err := os.WriteFile(filepath.Join(tmp, Filename), []byte("ignore: [unclosed\n"), 0o644); err != nil {
t.Fatal(err)
}
_, err := Load(tmp)
if err == nil {
t.Error("expected error for invalid YAML")
}
}
+41
View File
@@ -0,0 +1,41 @@
package fetcher
import (
"crypto/sha256"
"fmt"
"os"
"path/filepath"
)
// cacheRead returns the cached bytes for the given cache key, or (nil, false)
// on a miss (key not present, dir empty, or any read error).
func cacheRead(dir, key string) ([]byte, bool) {
if dir == "" {
return nil, false
}
data, err := os.ReadFile(cachePath(dir, key))
if err != nil {
return nil, false
}
return data, true
}
// cacheWrite stores bytes in the cache for the given key. Write errors are
// silently ignored so cache failures never block the normal fetch path.
func cacheWrite(dir, key string, data []byte) {
if dir == "" {
return
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return
}
_ = os.WriteFile(cachePath(dir, key), data, 0o644)
}
// cachePath returns the filesystem path for a cache entry.
// The filename is the SHA-256 hex digest of the key so arbitrary keys (URLs,
// "project:file@ref" strings) map to safe, stable filenames.
func cachePath(dir, key string) string {
h := sha256.Sum256([]byte(key))
return filepath.Join(dir, fmt.Sprintf("%x.yml", h))
}
+32 -7
View File
@@ -25,6 +25,8 @@ type GitLabConfig struct {
BaseURL string BaseURL string
Token string Token string
Source TokenSource Source TokenSource
CacheDir string // local cache directory; empty = caching disabled
Offline bool // when true, return an error instead of making network calls
} }
// AutoConfig builds a GitLabConfig from environment variables. // AutoConfig builds a GitLabConfig from environment variables.
@@ -54,8 +56,11 @@ func AutoConfig() GitLabConfig {
return cfg return cfg
} }
// WithOverrides returns a copy of cfg with non-empty overrides applied. // WithOverrides returns a copy of cfg with the provided overrides applied.
func (cfg GitLabConfig) WithOverrides(baseURL, token string) GitLabConfig { // Non-empty strings overwrite the corresponding field; booleans are always
// applied (so offline: false explicitly clears offline mode).
// cacheDir: empty string disables caching.
func (cfg GitLabConfig) WithOverrides(baseURL, token, cacheDir string, offline bool) GitLabConfig {
if baseURL != "" { if baseURL != "" {
cfg.BaseURL = strings.TrimRight(baseURL, "/") cfg.BaseURL = strings.TrimRight(baseURL, "/")
} }
@@ -63,6 +68,8 @@ func (cfg GitLabConfig) WithOverrides(baseURL, token string) GitLabConfig {
cfg.Token = token cfg.Token = token
cfg.Source = TokenPrivate cfg.Source = TokenPrivate
} }
cfg.CacheDir = cacheDir
cfg.Offline = offline
return cfg return cfg
} }
@@ -84,16 +91,24 @@ func (cfg GitLabConfig) HasToken() bool { return cfg.Token != "" }
// - filePath — repository file path, e.g. "/templates/ci.yml" // - filePath — repository file path, e.g. "/templates/ci.yml"
// - ref — branch, tag, or commit SHA; empty string defaults to HEAD // - ref — branch, tag, or commit SHA; empty string defaults to HEAD
func (cfg GitLabConfig) FetchFile(project, filePath, ref string) ([]byte, error) { func (cfg GitLabConfig) FetchFile(project, filePath, ref string) ([]byte, error) {
if ref == "" {
ref = "HEAD"
}
cKey := cfg.BaseURL + "|" + project + "|" + filePath + "|" + ref
if data, ok := cacheRead(cfg.CacheDir, cKey); ok {
return data, nil
}
if cfg.Offline {
return nil, fmt.Errorf("offline mode: %s:%s@%s is not in the local cache (run without --offline first to populate the cache)", project, filePath, ref)
}
encodedProject := url.PathEscape(project) encodedProject := url.PathEscape(project)
encodedFile := url.PathEscape(strings.TrimPrefix(filePath, "/")) encodedFile := url.PathEscape(strings.TrimPrefix(filePath, "/"))
apiURL := fmt.Sprintf("%s/api/v4/projects/%s/repository/files/%s/raw", apiURL := fmt.Sprintf("%s/api/v4/projects/%s/repository/files/%s/raw",
cfg.BaseURL, encodedProject, encodedFile) cfg.BaseURL, encodedProject, encodedFile)
if ref == "" {
ref = "HEAD"
}
req, err := http.NewRequest(http.MethodGet, apiURL, nil) req, err := http.NewRequest(http.MethodGet, apiURL, nil)
if err != nil { if err != nil {
return nil, fmt.Errorf("building request: %w", err) return nil, fmt.Errorf("building request: %w", err)
@@ -139,12 +154,21 @@ func (cfg GitLabConfig) FetchFile(project, filePath, ref string) ([]byte, error)
} }
} }
cacheWrite(cfg.CacheDir, cKey, body)
return body, nil return body, nil
} }
// FetchURL downloads the content at a plain HTTPS URL without authentication. // FetchURL downloads the content at a plain HTTPS URL without authentication.
// Used for include: remote: entries which are public by definition. // Used for include: remote: entries which are public by definition.
func FetchURL(rawURL string) ([]byte, error) { // Responses are read from and written to the local cache when CacheDir is set.
func (cfg GitLabConfig) FetchURL(rawURL string) ([]byte, error) {
if data, ok := cacheRead(cfg.CacheDir, rawURL); ok {
return data, nil
}
if cfg.Offline {
return nil, fmt.Errorf("offline mode: %s is not in the local cache (run without --offline first to populate the cache)", rawURL)
}
resp, err := http.Get(rawURL) //nolint:noctx resp, err := http.Get(rawURL) //nolint:noctx
if err != nil { if err != nil {
return nil, fmt.Errorf("GET %s: %w", rawURL, err) return nil, fmt.Errorf("GET %s: %w", rawURL, err)
@@ -157,6 +181,7 @@ func FetchURL(rawURL string) ([]byte, error) {
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GET %s: status %d", rawURL, resp.StatusCode) return nil, fmt.Errorf("GET %s: status %d", rawURL, resp.StatusCode)
} }
cacheWrite(cfg.CacheDir, rawURL, body)
return body, nil return body, nil
} }
+1 -1
View File
@@ -177,7 +177,7 @@ func (b *treeBuilder) recurseRemote(node *treeNode, rawURL string) {
} }
b.visited[key] = true b.visited[key] = true
data, err := fetcher.FetchURL(rawURL) data, err := b.cfg.FetchURL(rawURL)
if err != nil { if err != nil {
return return
} }
+90
View File
@@ -0,0 +1,90 @@
package linter
import (
"testing"
"git.k3nny.fr/glint/internal/model"
)
func TestCheckDeadRules(t *testing.T) {
cases := []struct {
name string
rules []model.Rule
wantHit bool // whether GL033 should fire
}{
{
name: "no rules — not dead",
rules: nil,
wantHit: false,
},
{
name: "single bare when:never — dead",
rules: []model.Rule{{When: "never"}},
wantHit: true,
},
{
name: "all rules when:never with if — dead",
rules: []model.Rule{
{If: `$CI_COMMIT_BRANCH == "main"`, When: "never"},
{If: `$CI_COMMIT_BRANCH == "develop"`, When: "never"},
{When: "never"},
},
wantHit: true,
},
{
name: "first rule on_success — not dead",
rules: []model.Rule{
{If: `$CI_COMMIT_BRANCH == "main"`, When: "on_success"},
{When: "never"},
},
wantHit: false,
},
{
name: "rule with empty when (defaults to on_success) — not dead",
rules: []model.Rule{
{If: `$CI_COMMIT_BRANCH == "main"`},
{When: "never"},
},
wantHit: false,
},
{
name: "when:manual — not dead",
rules: []model.Rule{{When: "manual"}},
wantHit: false,
},
{
name: "when:always — not dead",
rules: []model.Rule{{When: "always"}},
wantHit: false,
},
{
name: "when:on_failure — not dead",
rules: []model.Rule{{When: "on_failure"}},
wantHit: false,
},
{
name: "mixed never and manual — not dead",
rules: []model.Rule{
{If: `$CI_COMMIT_BRANCH == "main"`, When: "never"},
{When: "manual"},
},
wantHit: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
job := model.Job{Rules: tc.rules}
findings := checkDeadRules("test-job", job)
hit := false
for _, f := range findings {
if f.Rule == RuleDeadRules {
hit = true
}
}
if hit != tc.wantHit {
t.Errorf("checkDeadRules: got hit=%v, want hit=%v; findings=%v", hit, tc.wantHit, findings)
}
})
}
}
+819
View File
@@ -0,0 +1,819 @@
package linter
// RuleEntry holds the human-readable documentation for a single lint rule,
// shown by 'glint explain <RULE>'.
type RuleEntry struct {
Title string // short title (one line)
Severity Severity // Error or Warning
Description string // what triggers the rule and why it matters
Example string // YAML snippet that triggers the rule
Fix string // corrected YAML
}
// RuleCatalog maps stable rule IDs to their documentation entries.
var RuleCatalog = map[string]RuleEntry{
RuleNoStages: {
Title: "no stages defined",
Severity: Warning,
Description: "The pipeline has no 'stages:' block. GitLab falls back to the " +
"built-in default stages (build, test, deploy), which may not match your " +
"intended job ordering.",
Example: `# No stages: block
build-job:
script: make build
test-job:
script: make test`,
Fix: `stages:
- build
- test
build-job:
stage: build
script: make build
test-job:
stage: test
script: make test`,
},
RuleWorkflowWhen: {
Title: "workflow.rules.when invalid value",
Severity: Error,
Description: "'workflow.rules[n].when' only accepts 'always' or 'never'. Any " +
"other value (such as 'on_success' or 'manual') is invalid and will cause " +
"the pipeline to fail to create.",
Example: `workflow:
rules:
- if: $CI_COMMIT_BRANCH
when: on_success # invalid here`,
Fix: `workflow:
rules:
- if: $CI_COMMIT_BRANCH
when: always`,
},
RuleMissingScript: {
Title: "missing required 'script' field",
Severity: Error,
Description: "Every non-trigger, non-pages job must define 'script:' (or 'run:' " +
"for CI Steps). A job with no script will fail immediately when GitLab tries " +
"to run it.",
Example: `my-job:
stage: test
image: python:3.12
# Missing script:`,
Fix: `my-job:
stage: test
image: python:3.12
script: pytest`,
},
RuleUnknownStage: {
Title: "job references undeclared stage",
Severity: Error,
Description: "The job's 'stage:' value does not match any name in the pipeline's " +
"'stages:' list. GitLab will reject the pipeline configuration.",
Example: `stages:
- build
- test
deploy-job:
stage: production # not in stages:
script: ./deploy.sh`,
Fix: `stages:
- build
- test
- production
deploy-job:
stage: production
script: ./deploy.sh`,
},
RuleOnlyRulesConflict: {
Title: "'only:' and 'rules:' used together",
Severity: Error,
Description: "'only:' and 'rules:' are mutually exclusive. Using both on the " +
"same job is a configuration error that prevents the pipeline from being " +
"created.",
Example: `my-job:
only:
- main
rules:
- if: $CI_COMMIT_BRANCH
script: echo conflict`,
Fix: `# Remove only: and use rules: exclusively
my-job:
rules:
- if: $CI_COMMIT_BRANCH == "main"
script: echo ok`,
},
RuleExceptRulesConflict: {
Title: "'except:' and 'rules:' used together",
Severity: Error,
Description: "'except:' and 'rules:' are mutually exclusive. Using both on the " +
"same job is a configuration error that prevents the pipeline from being " +
"created.",
Example: `my-job:
except:
- tags
rules:
- if: $CI_COMMIT_BRANCH
script: echo conflict`,
Fix: `my-job:
rules:
- if: $CI_COMMIT_TAG
when: never
- if: $CI_COMMIT_BRANCH
script: echo ok`,
},
RuleDeprecatedOnly: {
Title: "'only:' / 'except:' deprecated",
Severity: Warning,
Description: "'only:' and 'except:' are deprecated in favour of 'rules:'. " +
"GitLab may remove support for these keywords in a future major version. " +
"'rules:' is more expressive and supports variable references, merge-request " +
"conditions, and fine-grained 'when:' control.",
Example: `my-job:
only:
- main
script: ./deploy.sh`,
Fix: `my-job:
rules:
- if: $CI_COMMIT_BRANCH == "main"
script: ./deploy.sh`,
},
RuleInvalidWhen: {
Title: "'when:' has invalid value",
Severity: Error,
Description: "Job-level 'when:' accepts: on_success, on_failure, always, " +
"manual, delayed, never. Any other value is rejected by GitLab.",
Example: `my-job:
when: sometimes # invalid
script: echo hi`,
Fix: `my-job:
when: on_success
script: echo hi`,
},
RuleDelayedNoStartIn: {
Title: "'when: delayed' without 'start_in:'",
Severity: Error,
Description: "'when: delayed' requires a 'start_in:' duration (e.g. " +
"'30 minutes', '1 hour'). Without it GitLab rejects the job configuration.",
Example: `my-job:
when: delayed
script: echo hi`,
Fix: `my-job:
when: delayed
start_in: 30 minutes
script: echo hi`,
},
RuleStartInNoDelayed: {
Title: "'start_in:' without 'when: delayed'",
Severity: Error,
Description: "'start_in:' is only meaningful when 'when: delayed'. Setting it " +
"alongside any other 'when:' value is a configuration error.",
Example: `my-job:
when: on_success
start_in: 30 minutes # only valid with delayed
script: echo hi`,
Fix: `my-job:
when: delayed
start_in: 30 minutes
script: echo hi`,
},
RuleInvalidParallel: {
Title: "'parallel:' value invalid",
Severity: Error,
Description: "'parallel:' must be an integer between 2 and 200 (inclusive), " +
"or a map with a 'matrix:' key for matrix jobs. Values outside this range " +
"or the wrong type are rejected by GitLab.",
Example: `my-job:
parallel: 1 # must be 2200
script: echo hi`,
Fix: `my-job:
parallel: 5
script: echo hi`,
},
RuleInvalidRetry: {
Title: "'retry:' value invalid",
Severity: Error,
Description: "'retry:' accepts an integer 02 or a map with optional 'max:' " +
"(02) and 'when:' keys. Values outside this range are rejected.",
Example: `my-job:
retry: 5 # max is 2
script: echo hi`,
Fix: `my-job:
retry: 2
script: echo hi`,
},
RuleInvalidRetryWhen: {
Title: "'retry.when:' has invalid failure type",
Severity: Error,
Description: "'retry.when:' must be a recognised GitLab CI failure type " +
"(e.g. script_failure, runner_system_failure) or 'always'. Unknown " +
"values are rejected.",
Example: `my-job:
retry:
max: 2
when: disk_full # not a valid failure type
script: echo hi`,
Fix: `my-job:
retry:
max: 2
when: runner_system_failure
script: echo hi`,
},
RuleInvalidAllowFailure: {
Title: "'allow_failure:' invalid value",
Severity: Error,
Description: "'allow_failure:' must be a boolean (true/false) or a map with " +
"an 'exit_codes:' key. Other forms are rejected by GitLab.",
Example: `my-job:
allow_failure: maybe # must be true/false or map
script: echo hi`,
Fix: `my-job:
allow_failure: true
script: echo hi`,
},
RuleInvalidInterruptible: {
Title: "'interruptible:' is not a boolean",
Severity: Error,
Description: "'interruptible:' must be a boolean (true or false). Other types " +
"are rejected.",
Example: `my-job:
interruptible: yes # must be a YAML boolean true/false
script: echo hi`,
Fix: `my-job:
interruptible: true
script: echo hi`,
},
RuleTriggerWithScript: {
Title: "trigger job also defines 'script:'",
Severity: Error,
Description: "Jobs with a 'trigger:' key (downstream pipeline triggers) cannot " +
"use 'script:'. These are mutually exclusive — a trigger job has no runner " +
"environment to execute scripts in.",
Example: `trigger-job:
trigger:
project: mygroup/myproject
script: echo this will fail`,
Fix: `trigger-job:
trigger:
project: mygroup/myproject`,
},
RuleInvalidTrigger: {
Title: "trigger: map missing 'project:' or 'include:'",
Severity: Error,
Description: "When 'trigger:' is a map it must specify either 'project:' " +
"(downstream project trigger) or 'include:' (dynamic child pipeline). " +
"Without one of these keys GitLab cannot determine what to trigger.",
Example: `trigger-job:
trigger:
strategy: depend # missing project: or include:`,
Fix: `trigger-job:
trigger:
project: mygroup/downstream
strategy: depend`,
},
RuleInvalidCoverage: {
Title: "'coverage:' is not a regex pattern",
Severity: Error,
Description: "'coverage:' must be a regex pattern wrapped in forward slashes " +
"(e.g. '/Coverage: \\d+\\.?\\d*%/'). GitLab uses this pattern to extract " +
"the coverage percentage from job output.",
Example: `my-job:
coverage: "\\d+%" # missing surrounding /…/
script: pytest --cov`,
Fix: `my-job:
coverage: '/Coverage: \d+\.?\d*%/'
script: pytest --cov`,
},
RuleInvalidRelease: {
Title: "'release:' missing required 'tag_name:'",
Severity: Error,
Description: "'release:' must be a map and must include a 'tag_name:' key. " +
"Without it GitLab cannot create the release.",
Example: `release-job:
script: echo releasing
release:
name: My Release # missing tag_name:`,
Fix: `release-job:
script: echo releasing
release:
tag_name: $CI_COMMIT_TAG
name: My Release`,
},
RuleInvalidEnvironment: {
Title: "'environment:' invalid url or action",
Severity: Error,
Description: "'environment.url' requires 'environment.name' to be set. " +
"'environment.action' must be one of: start, stop, prepare, verify, access.",
Example: `my-job:
script: ./deploy.sh
environment:
url: https://prod.example.com # name: is required`,
Fix: `my-job:
script: ./deploy.sh
environment:
name: production
url: https://prod.example.com`,
},
RuleInvalidArtifacts: {
Title: "'artifacts:' invalid configuration",
Severity: Error,
Description: "'artifacts.when' must be on_success, on_failure, or always. " +
"'artifacts.expose_as' requires 'artifacts.paths' to be set.",
Example: `my-job:
script: make
artifacts:
when: sometimes # invalid value`,
Fix: `my-job:
script: make
artifacts:
when: on_failure
paths:
- build/logs/`,
},
RulePagesPublic: {
Title: "pages job missing 'public' in artifacts.paths",
Severity: Warning,
Description: "The 'pages' job must include 'public' (or a path starting with " +
"'public/') in 'artifacts.paths' for GitLab Pages to deploy the site.",
Example: `pages:
script: hugo
artifacts:
paths:
- dist/ # should include public/`,
Fix: `pages:
script: hugo --destination public
artifacts:
paths:
- public/`,
},
RuleInvalidCache: {
Title: "'cache:' invalid when or policy",
Severity: Error,
Description: "'cache.when' must be on_success, on_failure, or always. " +
"'cache.policy' must be pull, push, or pull-push.",
Example: `my-job:
script: npm ci
cache:
paths: [node_modules/]
policy: read-only # invalid; use pull`,
Fix: `my-job:
script: npm ci
cache:
paths: [node_modules/]
policy: pull`,
},
RuleInvalidRulesWhen: {
Title: "'rules[n].when:' has invalid value",
Severity: Error,
Description: "Inside a 'rules:' block, 'when:' must be one of: on_success, " +
"on_failure, always, manual, delayed, never. Other values are rejected.",
Example: `my-job:
rules:
- if: $CI_COMMIT_BRANCH
when: conditional # not valid here
script: echo hi`,
Fix: `my-job:
rules:
- if: $CI_COMMIT_BRANCH
when: on_success
script: echo hi`,
},
RuleInvalidImage: {
Title: "'image:' map form missing 'name:'",
Severity: Error,
Description: "When 'image:' is a map, it must include a 'name:' key specifying " +
"the Docker image. Without it GitLab cannot resolve which image to pull.",
Example: `my-job:
image:
entrypoint: ["/bin/sh"] # missing name:
script: echo hi`,
Fix: `my-job:
image:
name: alpine:3.19
entrypoint: ["/bin/sh"]
script: echo hi`,
},
RuleInvalidInherit: {
Title: "'inherit.default' or 'inherit.variables' invalid type",
Severity: Error,
Description: "'inherit.default' and 'inherit.variables' must each be a boolean " +
"(true/false) or a list of field/variable names. Other types are rejected.",
Example: `my-job:
inherit:
default: "no" # must be true/false or a list
script: echo hi`,
Fix: `my-job:
inherit:
default: false
script: echo hi`,
},
RuleNeedsUnknown: {
Title: "'needs:' references unknown job",
Severity: Error,
Description: "A job in the 'needs:' list does not exist in the pipeline. " +
"GitLab will refuse to create the pipeline.",
Example: `build:
stage: build
script: make
test:
stage: test
needs: [build, lint] # lint does not exist
script: make test`,
Fix: `lint:
stage: build
script: make lint
build:
stage: build
script: make
test:
stage: test
needs: [build, lint]
script: make test`,
},
RuleNeedsStageOrder: {
Title: "'needs:' job is in a later stage",
Severity: Error,
Description: "A job listed in 'needs:' is in a later stage than the current " +
"job. GitLab does not allow needs: to reference future stages.",
Example: `stages: [build, test, deploy]
test:
stage: test
needs: [deploy-job] # deploy-job is in a later stage
script: make test
deploy-job:
stage: deploy
script: ./deploy.sh`,
Fix: `stages: [build, test, deploy]
test:
stage: test
needs: [build-job]
script: make test
build-job:
stage: build
script: make`,
},
RuleNeedsCycle: {
Title: "circular dependency in 'needs:' graph",
Severity: Error,
Description: "Two or more jobs in the 'needs:' graph form a cycle — A needs B " +
"and B needs A (directly or transitively). GitLab will reject the pipeline.",
Example: `job-a:
stage: test
needs: [job-b]
script: echo a
job-b:
stage: test
needs: [job-a]
script: echo b`,
Fix: `job-a:
stage: test
script: echo a
job-b:
stage: test
needs: [job-a]
script: echo b`,
},
RuleUnknownDependency: {
Title: "'dependencies:' references unknown job",
Severity: Error,
Description: "A job listed in 'dependencies:' does not exist in the pipeline. " +
"GitLab will refuse to create the pipeline.",
Example: `test:
stage: test
dependencies: [build, missing-job]
script: make test`,
Fix: `build:
stage: build
script: make
artifacts:
paths: [dist/]
test:
stage: test
dependencies: [build]
script: make test`,
},
RuleDependencyStage: {
Title: "'dependencies:' job in same or later stage",
Severity: Error,
Description: "'dependencies:' can only reference jobs in earlier stages. " +
"Referencing a job in the same or a later stage is not allowed because " +
"artifacts would not yet be available.",
Example: `stages: [test, deploy]
test:
stage: test
dependencies: [deploy-job] # deploy is a later stage
script: make test
deploy-job:
stage: deploy
script: ./deploy.sh`,
Fix: `stages: [build, test, deploy]
build:
stage: build
script: make
artifacts:
paths: [dist/]
test:
stage: test
dependencies: [build]
script: make test`,
},
RuleUndeclaredVariable: {
Title: "'rules:if:' references undeclared variable",
Severity: Warning,
Description: "A 'rules:if:' expression references a variable that is not " +
"declared in any 'variables:' block in the pipeline YAML. This may be a " +
"typo, or the variable may be set in GitLab project settings (invisible to " +
"glint). Predefined CI_* and GITLAB_* variables are always exempt.",
Example: `variables:
APP_ENV: staging
my-job:
rules:
- if: $DEPLOY_ENV == "prod" # DEPLOY_ENV not declared
script: ./deploy.sh`,
Fix: `variables:
APP_ENV: staging
DEPLOY_ENV: staging # declare it, or set it in GitLab project settings
my-job:
rules:
- if: $DEPLOY_ENV == "prod"
script: ./deploy.sh`,
},
RuleDeadRules: {
Title: "rules: block has all 'when: never' — job permanently excluded",
Severity: Warning,
Description: "Every rule in the job's 'rules:' block has an explicit " +
"'when: never'. No matter which conditions match, the job will never " +
"be included in any pipeline run.",
Example: `my-job:
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: never
- when: never
script: echo unreachable`,
Fix: `# Option 1: remove the job entirely
# Option 2: give at least one rule a non-never when:
my-job:
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: on_success
- when: never
script: echo deploy`,
},
RuleInvalidService: {
Title: "'services:' map form missing 'name:' or invalid 'alias:'",
Severity: Error,
Description: "When a service entry is a map it must include a 'name:' key. " +
"'alias:' (if set) must be a valid DNS label: alphanumeric characters, " +
"hyphens, and dots only; must start and end with alphanumeric.",
Example: `my-job:
script: pytest
services:
- alias: my_db # underscore is not valid in a DNS label`,
Fix: `my-job:
script: pytest
services:
- name: postgres:15
alias: my-db`,
},
RuleAbsoluteGlobPath: {
Title: "'rules:changes' or 'rules:exists' uses absolute path",
Severity: Warning,
Description: "Paths in 'rules:changes' and 'rules:exists' are always relative " +
"to the repository root. An absolute path starting with '/' will never " +
"match any file and will silently disable the rule.",
Example: `my-job:
rules:
- changes:
- /src/**/*.go # absolute — will never match
script: make`,
Fix: `my-job:
rules:
- changes:
- src/**/*.go
script: make`,
},
RuleInvalidTimeout: {
Title: "'timeout:' is not a valid duration string",
Severity: Error,
Description: "'timeout:' must be a GitLab CI duration string composed of " +
"recognised time units: weeks (w), days (d), hours (h), minutes (m/min), " +
"seconds (s). Examples: '1h 30m', '90 minutes', '2 hours'.",
Example: `my-job:
timeout: "1:30:00" # colon-separated format not supported
script: long-running-task.sh`,
Fix: `my-job:
timeout: 1h 30m
script: long-running-task.sh`,
},
RuleInvalidIDToken: {
Title: "'id_tokens:' entry missing required 'aud:' key",
Severity: Error,
Description: "Each entry under 'id_tokens:' must be a map with an 'aud:' " +
"(audience) key. Without 'aud:' GitLab cannot generate the ID token " +
"and the job will fail.",
Example: `my-job:
id_tokens:
MY_TOKEN:
# missing aud:
script: vault-login.sh`,
Fix: `my-job:
id_tokens:
MY_TOKEN:
aud: https://vault.example.com
script: vault-login.sh`,
},
RuleInvalidSecret: {
Title: "'secrets:' entry missing a provider key",
Severity: Error,
Description: "Each entry under 'secrets:' must specify a provider: one of " +
"'vault:', 'gcp_secret_manager:', or 'azure_key_vault:'. Without a " +
"provider GitLab cannot retrieve the secret.",
Example: `my-job:
secrets:
DB_PASSWORD:
path: secret/db/password # no provider key
script: run.sh`,
Fix: `my-job:
secrets:
DB_PASSWORD:
vault:
engine:
name: kv-v2
path: secret
path: db/password
field: password
script: run.sh`,
},
RulePagesPublish: {
Title: "'pages:' keyword publish directory missing from 'artifacts.paths'",
Severity: Warning,
Description: "A job using the 'pages:' keyword must include its publish " +
"directory in 'artifacts.paths'. Without this, GitLab Pages will not " +
"deploy the site.",
Example: `my-pages-job:
script: hugo
pages:
publish: public
artifacts:
paths:
- dist/ # missing public/`,
Fix: `my-pages-job:
script: hugo
pages:
publish: public
artifacts:
paths:
- public/`,
},
RuleDuplicateStage: {
Title: "duplicate stage name in 'stages:'",
Severity: Warning,
Description: "A stage name appears more than once in the 'stages:' list. " +
"GitLab silently merges duplicate stage entries, which can make the " +
"pipeline order confusing.",
Example: `stages:
- build
- test
- test # duplicate`,
Fix: `stages:
- build
- test`,
},
RuleInvalidCacheKeyFiles: {
Title: "'cache.key.files' contains a glob pattern",
Severity: Warning,
Description: "'cache.key.files' must be a list of exact file paths. Glob " +
"patterns (*, ?, [...]) are not expanded — the literal glob string is " +
"used as the cache key, which probably doesn't match your intent.",
Example: `my-job:
script: npm ci
cache:
key:
files:
- package*.json # glob — use exact path`,
Fix: `my-job:
script: npm ci
cache:
key:
files:
- package.json
- package-lock.json`,
},
RuleStaticDeadRules: {
Title: "rules:if: block statically never activates",
Severity: Warning,
Description: "Every 'rules:if:' condition in this job evaluates to false " +
"using the variable values declared in the pipeline YAML. The job will " +
"never be included in any pipeline run. This check only fires when ALL " +
"referenced variables are declared in the YAML (predefined CI_* variables " +
"are not evaluated to avoid false positives).",
Example: `variables:
ENABLE_DEPLOY: "false"
deploy:
rules:
- if: '$ENABLE_DEPLOY == "true"' # always false: ENABLE_DEPLOY is "false"
script: ./deploy.sh`,
Fix: `variables:
ENABLE_DEPLOY: "true" # fix the variable value
deploy:
rules:
- if: '$ENABLE_DEPLOY == "true"'
script: ./deploy.sh
# Or add an unconditional fallback:
deploy:
rules:
- if: '$ENABLE_DEPLOY == "true"'
- when: manual # allow manual trigger as a fallback
script: ./deploy.sh`,
},
RuleInheritNoDefault: {
Title: "'inherit: default:' declared but no 'default:' block",
Severity: Warning,
Description: "A job declares 'inherit: default:' but the pipeline has no " +
"'default:' block, so the declaration has no effect. This can also fire " +
"when 'inherit: default: [list]' names fields that are not set in the " +
"'default:' block.",
Example: `# No default: block exists
my-job:
inherit:
default: false # no-op: nothing to opt out of
script: echo hi`,
Fix: `# Either add a default: block...
default:
before_script:
- source venv/bin/activate
my-job:
inherit:
default: false
script: echo hi
# ...or remove the pointless inherit: declaration
my-job:
script: echo hi`,
},
}
+104
View File
@@ -0,0 +1,104 @@
package linter
import (
"git.k3nny.fr/glint/internal/cicontext"
"git.k3nny.fr/glint/internal/model"
)
// checkRulesIfReachability (GL042): warn when every rule in a job's rules: block
// can be statically proven to never activate given the values of variables
// declared in the pipeline YAML. Only fires when ALL variables referenced by the
// rules:if: expressions are declared in the pipeline or job variables: blocks —
// predefined CI_* / GITLAB_* variables or variables not declared in YAML are
// treated as unknown (could have any runtime value) so the check is skipped
// conservatively to avoid false positives.
func checkRulesIfReachability(p *model.Pipeline) []Finding {
if len(p.Jobs) == 0 {
return nil
}
// Collect scalar string values from pipeline-level variables.
pipelineVars := make(map[string]string, len(p.Variables))
for k, v := range p.Variables {
if s, ok := cicontext.ScalarString(v); ok {
pipelineVars[k] = s
}
}
var findings []Finding
for name, job := range p.Jobs {
if len(job.Rules) == 0 {
continue
}
// Merge pipeline vars with job-level variable overrides.
jobVars := make(map[string]string, len(pipelineVars)+len(job.Variables))
for k, v := range pipelineVars {
jobVars[k] = v
}
for k, v := range job.Variables {
if s, ok := cicontext.ScalarString(v); ok {
jobVars[k] = s
}
}
if f := evalRulesReachability(name, job, jobVars); f != nil {
findings = append(findings, *f)
}
}
return findings
}
// evalRulesReachability returns a GL042 finding when the job's rules: block
// can never activate given the provided variable map, or nil if the job
// might activate (or the check cannot be applied conservatively).
func evalRulesReachability(name string, job model.Job, jobVars map[string]string) *Finding {
lookup := func(k string) string { return jobVars[k] }
for _, rule := range job.Rules {
// Explicit when: never — this rule is provably dead; continue.
if rule.When == "never" {
continue
}
// No if: condition → this rule always matches → job CAN activate.
if rule.If == "" {
return nil
}
// Check whether all variables referenced in the if: expression are
// declared in the pipeline YAML. If any are not declared (predefined
// CI vars, project settings, etc.), we cannot evaluate the expression
// and must conservatively assume the job might activate.
refs := extractIfVars(rule.If)
allDeclared := true
for _, ref := range refs {
if _, ok := jobVars[ref]; !ok {
// Variable not in YAML (predefined or unknown) → cannot evaluate.
allDeclared = false
break
}
}
if !allDeclared {
return nil // conservative: job might activate
}
// All referenced variables are declared; evaluate the expression.
// Use strict mode (unparse → false) to avoid matching unparseable expressions.
if cicontext.EvalIfStrict(rule.If, lookup) {
// This rule's condition evaluates to true → job CAN activate.
return nil
}
// Expression evaluated to false → this rule cannot activate; continue.
}
// No rule could activate the job.
return &Finding{
Severity: Warning,
Rule: RuleStaticDeadRules,
Job: name,
File: job.File,
Line: job.Line,
Message: "rules: block can never activate: all if: conditions evaluate to false given the declared pipeline variables",
}
}
+152
View File
@@ -0,0 +1,152 @@
package linter
import (
"testing"
"git.k3nny.fr/glint/internal/model"
)
func TestCheckRulesIfReachability(t *testing.T) {
makeJob := func(vars map[string]any, rules []model.Rule) model.Job {
return model.Job{Variables: vars, Rules: rules}
}
cases := []struct {
name string
pipelineVars map[string]any
jobs map[string]model.Job
wantHit []string // job names that should produce GL042
wantMiss []string // job names that should NOT produce GL042
}{
{
name: "declared var always false — GL042 fires",
pipelineVars: map[string]any{"DEPLOY": "false"},
jobs: map[string]model.Job{
"deploy": makeJob(nil, []model.Rule{
{If: `$DEPLOY == "true"`, When: "on_success"},
}),
},
wantHit: []string{"deploy"},
wantMiss: nil,
},
{
name: "declared var matches — no finding",
pipelineVars: map[string]any{"DEPLOY": "true"},
jobs: map[string]model.Job{
"deploy": makeJob(nil, []model.Rule{
{If: `$DEPLOY == "true"`, When: "on_success"},
}),
},
wantHit: nil,
wantMiss: []string{"deploy"},
},
{
name: "predefined CI_ var — skip check conservatively",
pipelineVars: nil,
jobs: map[string]model.Job{
"branch-job": makeJob(nil, []model.Rule{
{If: `$CI_COMMIT_BRANCH == "never-exists"`, When: "on_success"},
}),
},
wantHit: nil,
wantMiss: []string{"branch-job"},
},
{
name: "undeclared var — skip check conservatively",
pipelineVars: nil,
jobs: map[string]model.Job{
"my-job": makeJob(nil, []model.Rule{
{If: `$UNKNOWN_VAR == "x"`, When: "on_success"},
}),
},
wantHit: nil,
wantMiss: []string{"my-job"},
},
{
name: "unconditional rule — job can always activate",
pipelineVars: map[string]any{"DEPLOY": "false"},
jobs: map[string]model.Job{
"my-job": makeJob(nil, []model.Rule{
{If: `$DEPLOY == "true"`, When: "never"},
{When: "on_success"},
}),
},
wantHit: nil,
wantMiss: []string{"my-job"},
},
{
name: "all rules when:never — GL042 fires (not GL033 territory overlap)",
pipelineVars: map[string]any{"DEPLOY": "false"},
jobs: map[string]model.Job{
"my-job": makeJob(nil, []model.Rule{
{If: `$DEPLOY == "true"`, When: "on_success"},
{When: "never"},
}),
},
// Rule 1 evaluates to false; Rule 2 is explicit never → all dead.
wantHit: []string{"my-job"},
wantMiss: nil,
},
{
name: "job-level var overrides pipeline var",
pipelineVars: map[string]any{"FLAG": "false"},
jobs: map[string]model.Job{
"my-job": makeJob(map[string]any{"FLAG": "true"}, []model.Rule{
{If: `$FLAG == "true"`, When: "on_success"},
}),
},
// Job-level FLAG="true" makes the if: evaluate to true → no finding.
wantHit: nil,
wantMiss: []string{"my-job"},
},
{
name: "no rules — nothing to check",
pipelineVars: map[string]any{"DEPLOY": "false"},
jobs: map[string]model.Job{
"my-job": makeJob(nil, nil),
},
wantHit: nil,
wantMiss: []string{"my-job"},
},
{
name: "boolean variable evaluates correctly",
pipelineVars: map[string]any{"FLAG": false}, // bool false
jobs: map[string]model.Job{
"my-job": makeJob(nil, []model.Rule{
{If: `$FLAG == "true"`, When: "on_success"},
}),
},
// ScalarString(false) → "false"; "false" == "true" → false → GL042 fires.
wantHit: []string{"my-job"},
wantMiss: nil,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
p := &model.Pipeline{
Variables: tc.pipelineVars,
Jobs: tc.jobs,
}
findings := checkRulesIfReachability(p)
hitSet := make(map[string]bool)
for _, f := range findings {
if f.Rule == RuleStaticDeadRules {
hitSet[f.Job] = true
}
}
for _, want := range tc.wantHit {
if !hitSet[want] {
t.Errorf("expected GL042 for job %q but it was not reported; findings: %v", want, findings)
}
}
for _, notWant := range tc.wantMiss {
if hitSet[notWant] {
t.Errorf("unexpected GL042 for job %q", notWant)
}
}
})
}
}
+112
View File
@@ -0,0 +1,112 @@
package linter
import (
"fmt"
"strings"
"git.k3nny.fr/glint/internal/model"
)
// checkInheritCompleteness (GL043): warn when a job's 'inherit: default:'
// declaration is dead — either because there is no 'default:' block in the
// pipeline, or because the list form references fields that are not defined
// in the 'default:' block.
func checkInheritCompleteness(p *model.Pipeline) []Finding {
var findings []Finding
for name, job := range p.Jobs {
findings = append(findings, checkJobInheritCompleteness(p, name, job)...)
}
return findings
}
func checkJobInheritCompleteness(p *model.Pipeline, name string, job model.Job) []Finding {
if job.Inherit == nil {
return nil
}
m, ok := job.Inherit.(map[string]any)
if !ok {
return nil
}
defaultVal, hasDefault := m["default"]
if !hasDefault {
return nil
}
// Case 1: no default: block at all — the entire declaration is a no-op.
if p.Default == nil {
return []Finding{{
Severity: Warning,
Rule: RuleInheritNoDefault,
Job: name,
File: job.File,
Line: job.Line,
Message: "'inherit: default:' is declared but the pipeline has no 'default:' block — declaration has no effect",
}}
}
// Case 2: list form — check for field names not set in default:.
list, ok := defaultVal.([]any)
if !ok {
// bool form (true/false) with a non-nil default: block is always valid.
return nil
}
var dead []string
for _, item := range list {
field, ok := item.(string)
if !ok {
continue
}
if !defaultBlockHasField(p.Default, field) {
dead = append(dead, field)
}
}
if len(dead) == 0 {
return nil
}
return []Finding{{
Severity: Warning,
Rule: RuleInheritNoDefault,
Job: name,
File: job.File,
Line: job.Line,
Message: fmt.Sprintf(
"'inherit: default: [%s]': %s not defined in the 'default:' block — %s",
strings.Join(dead, ", "),
pluralIs(len(dead)),
"these entries have no effect",
),
}}
}
// defaultBlockHasField reports whether the given field name has a non-zero value
// in the default: block. Unknown field names return true (conservative).
func defaultBlockHasField(d *model.DefaultConfig, field string) bool {
switch field {
case "image":
return d.Image != nil
case "before_script":
return d.BeforeScript != nil
case "after_script":
return d.AfterScript != nil
case "cache":
return d.Cache != nil
case "artifacts":
return d.Artifacts != nil
case "retry":
return d.Retry != nil
case "timeout":
return d.Timeout != ""
case "tags":
return len(d.Tags) > 0
}
// Unknown field name — conservatively assume it might be defined.
return true
}
func pluralIs(n int) string {
if n == 1 {
return "this field is"
}
return "these fields are"
}
@@ -0,0 +1,94 @@
package linter
import (
"testing"
"git.k3nny.fr/glint/internal/model"
)
func TestCheckInheritCompleteness(t *testing.T) {
cases := []struct {
name string
default_ *model.DefaultConfig
inherit any // job.Inherit value
wantHit bool
}{
{
name: "no inherit — no finding",
inherit: nil,
wantHit: false,
},
{
name: "inherit: default: false, no default block — GL043",
inherit: map[string]any{"default": false},
wantHit: true,
},
{
name: "inherit: default: true, no default block — GL043",
inherit: map[string]any{"default": true},
wantHit: true,
},
{
name: "inherit: default: [image], no default block — GL043",
inherit: map[string]any{"default": []any{"image"}},
wantHit: true,
},
{
name: "inherit: default: false, default block exists — no finding",
default_: &model.DefaultConfig{Image: "node:20"},
inherit: map[string]any{"default": false},
wantHit: false,
},
{
name: "inherit: default: [image], image in default — no finding",
default_: &model.DefaultConfig{Image: "node:20"},
inherit: map[string]any{"default": []any{"image"}},
wantHit: false,
},
{
name: "inherit: default: [before_script], before_script NOT in default — GL043",
default_: &model.DefaultConfig{Image: "node:20"},
inherit: map[string]any{"default": []any{"before_script"}},
wantHit: true,
},
{
name: "inherit: default: [image, before_script], only image in default — GL043 for before_script",
default_: &model.DefaultConfig{Image: "node:20"},
inherit: map[string]any{"default": []any{"image", "before_script"}},
wantHit: true,
},
{
name: "inherit: variables only — no default check",
default_: nil,
inherit: map[string]any{"variables": false},
wantHit: false,
},
{
name: "inherit: unknown field in list — conservative, no finding",
default_: &model.DefaultConfig{Image: "node:20"},
inherit: map[string]any{"default": []any{"nonexistent_field"}},
wantHit: false, // unknown fields return true from defaultBlockHasField
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
job := model.Job{Inherit: tc.inherit}
p := &model.Pipeline{
Default: tc.default_,
Jobs: map[string]model.Job{"test-job": job},
}
findings := checkInheritCompleteness(p)
hit := false
for _, f := range findings {
if f.Rule == RuleInheritNoDefault {
hit = true
}
}
if hit != tc.wantHit {
t.Errorf("GL043 hit=%v, want=%v; findings: %v", hit, tc.wantHit, findings)
}
})
}
}
+328 -2
View File
@@ -8,6 +8,15 @@ import (
"git.k3nny.fr/glint/internal/model" "git.k3nny.fr/glint/internal/model"
) )
// dnsLabelPattern matches a valid hostname label: starts and ends with
// alphanumeric, middle may contain hyphens and dots; max 63 chars per label.
var dnsLabelPattern = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9.-]{0,61}[a-zA-Z0-9])?$`)
// timeoutPattern matches a GitLab CI duration string: one or more pairs of
// "<number> <unit>" where unit is a recognised time word or abbreviation.
var timeoutPattern = regexp.MustCompile(
`(?i)^\s*(\d+\s*(weeks?|w|days?|d|hours?|h|minutes?|mins?|m|seconds?|secs?|s)\s*)+$`)
var validJobWhen = map[string]bool{ var validJobWhen = map[string]bool{
"on_success": true, "on_success": true,
"on_failure": true, "on_failure": true,
@@ -95,8 +104,16 @@ func checkJobKeywords(name string, job model.Job) []Finding {
findings = append(findings, checkArtifacts(name, job)...) findings = append(findings, checkArtifacts(name, job)...)
findings = append(findings, checkCache(name, job)...) findings = append(findings, checkCache(name, job)...)
findings = append(findings, checkRules(name, job)...) findings = append(findings, checkRules(name, job)...)
findings = append(findings, checkDeadRules(name, job)...)
findings = append(findings, checkImage(name, job)...) findings = append(findings, checkImage(name, job)...)
findings = append(findings, checkInherit(name, job)...) findings = append(findings, checkInherit(name, job)...)
findings = append(findings, checkServices(name, job)...)
findings = append(findings, checkRulesGlobs(name, job)...)
findings = append(findings, checkTimeout(name, job)...)
findings = append(findings, checkIDTokens(name, job)...)
findings = append(findings, checkSecrets(name, job)...)
findings = append(findings, checkPagesKeyword(name, job)...)
findings = append(findings, checkCacheKeyFiles(name, job)...)
return findings return findings
} }
@@ -401,8 +418,9 @@ func checkArtifacts(name string, job model.Job) []Finding {
}) })
} }
} }
// Pages job should publish to public/ // Pages job should publish to public/. Skip when pages: keyword is set;
if name == "pages" { // GL039 (checkPagesKeyword) handles that case with a more precise check.
if name == "pages" && job.Pages == nil {
if paths, ok := m["paths"].([]any); ok { if paths, ok := m["paths"].([]any); ok {
found := false found := false
for _, p := range paths { for _, p := range paths {
@@ -476,6 +494,28 @@ func checkRules(name string, job model.Job) []Finding {
return findings return findings
} }
// checkDeadRules reports when every rule in a job's rules: block has an
// explicit when: never, making the job permanently unreachable. This is a
// provably-correct static claim: no matter which if: condition matches, the
// outcome is always "never"; and if no rule matches, the implicit fallback is
// also skip. No if: evaluation is required.
func checkDeadRules(name string, job model.Job) []Finding {
if len(job.Rules) == 0 {
return nil
}
for _, r := range job.Rules {
if r.When != "never" {
return nil
}
}
return []Finding{{
Severity: Warning,
Rule: RuleDeadRules,
Job: name,
Message: "rules: block can never activate; every rule has 'when: never' — job is permanently excluded from the pipeline",
}}
}
func checkImage(name string, job model.Job) []Finding { func checkImage(name string, job model.Job) []Finding {
if job.Image == nil { if job.Image == nil {
return nil return nil
@@ -524,3 +564,289 @@ func checkInherit(name string, job model.Job) []Finding {
} }
return findings return findings
} }
// GL034: services: map form requires 'name'; 'alias' must be a valid DNS label.
func checkServices(name string, job model.Job) []Finding {
if len(job.Services) == 0 {
return nil
}
var findings []Finding
for i, svc := range job.Services {
m, ok := svc.(map[string]any)
if !ok {
continue // string form is valid
}
n := m["name"]
if n == nil || n == "" {
findings = append(findings, Finding{
Severity: Error,
Rule: RuleInvalidService,
Job: name,
Message: fmt.Sprintf("services[%d]: map form requires a 'name' key", i),
})
}
if alias, ok := m["alias"].(string); ok && alias != "" {
if !dnsLabelPattern.MatchString(alias) {
findings = append(findings, Finding{
Severity: Error,
Rule: RuleInvalidService,
Job: name,
Message: fmt.Sprintf("services[%d]: alias %q is not a valid DNS label (only letters, digits, hyphens, and dots; must start and end with alphanumeric)", i, alias),
})
}
}
}
return findings
}
// GL035: rules:changes and rules:exists paths must not be absolute.
func checkRulesGlobs(name string, job model.Job) []Finding {
var findings []Finding
for i, rule := range job.Rules {
findings = append(findings, checkAbsolutePaths(name, fmt.Sprintf("rules[%d].changes", i), rule.Changes)...)
findings = append(findings, checkAbsolutePaths(name, fmt.Sprintf("rules[%d].exists", i), rule.Exists)...)
}
return findings
}
func checkAbsolutePaths(name, field string, val any) []Finding {
var paths []string
switch v := val.(type) {
case []any:
for _, item := range v {
if s, ok := item.(string); ok {
paths = append(paths, s)
}
}
case map[string]any:
if ps, ok := v["paths"].([]any); ok {
for _, item := range ps {
if s, ok := item.(string); ok {
paths = append(paths, s)
}
}
}
}
var findings []Finding
for _, p := range paths {
if strings.HasPrefix(p, "/") {
findings = append(findings, Finding{
Severity: Warning,
Rule: RuleAbsoluteGlobPath,
Job: name,
Message: fmt.Sprintf("%s: path %q is absolute; GitLab CI paths are relative to the repository root — absolute paths will never match", field, p),
})
}
}
return findings
}
// GL036: timeout: must be a valid GitLab CI duration string.
func checkTimeout(name string, job model.Job) []Finding {
if job.Timeout == "" {
return nil
}
if !timeoutPattern.MatchString(job.Timeout) {
return []Finding{{
Severity: Error,
Rule: RuleInvalidTimeout,
Job: name,
Message: fmt.Sprintf("'timeout' has invalid value %q; expected a duration like '1h 30m', '90 minutes', '2 hours'", job.Timeout),
}}
}
return nil
}
// CheckDefaultTimeout validates the pipeline-level default: timeout: field.
// Exported so it can be called from linter.go.
func checkDefaultTimeout(timeout, file string) []Finding {
if timeout == "" {
return nil
}
if !timeoutPattern.MatchString(timeout) {
return []Finding{{
Severity: Error,
Rule: RuleInvalidTimeout,
File: file,
Message: fmt.Sprintf("'default.timeout' has invalid value %q; expected a duration like '1h 30m', '90 minutes', '2 hours'", timeout),
}}
}
return nil
}
// GL037: id_tokens: each entry must have an 'aud' key.
func checkIDTokens(name string, job model.Job) []Finding {
if job.IDTokens == nil {
return nil
}
m, ok := job.IDTokens.(map[string]any)
if !ok {
return nil
}
var findings []Finding
for tokenName, tokenVal := range m {
tokenMap, ok := tokenVal.(map[string]any)
if !ok {
findings = append(findings, Finding{
Severity: Error,
Rule: RuleInvalidIDToken,
Job: name,
Message: fmt.Sprintf("id_tokens.%s: must be a map with an 'aud' key", tokenName),
})
continue
}
if _, hasAud := tokenMap["aud"]; !hasAud {
findings = append(findings, Finding{
Severity: Error,
Rule: RuleInvalidIDToken,
Job: name,
Message: fmt.Sprintf("id_tokens.%s: missing required 'aud' key", tokenName),
})
}
}
return findings
}
// GL038: secrets: each entry must have a provider key (vault, gcp_secret_manager, azure_key_vault).
func checkSecrets(name string, job model.Job) []Finding {
if job.Secrets == nil {
return nil
}
m, ok := job.Secrets.(map[string]any)
if !ok {
return nil
}
validProviders := []string{"vault", "gcp_secret_manager", "azure_key_vault"}
var findings []Finding
for secretName, secretVal := range m {
secretMap, ok := secretVal.(map[string]any)
if !ok {
findings = append(findings, Finding{
Severity: Error,
Rule: RuleInvalidSecret,
Job: name,
Message: fmt.Sprintf("secrets.%s: must be a map with a provider key (vault, gcp_secret_manager, or azure_key_vault)", secretName),
})
continue
}
hasProvider := false
for _, provider := range validProviders {
if _, ok := secretMap[provider]; ok {
hasProvider = true
break
}
}
if !hasProvider {
findings = append(findings, Finding{
Severity: Error,
Rule: RuleInvalidSecret,
Job: name,
Message: fmt.Sprintf("secrets.%s: missing provider key; must include one of: vault, gcp_secret_manager, azure_key_vault", secretName),
})
}
}
return findings
}
// GL039: a job with the pages: keyword must have the publish directory in artifacts.paths.
func checkPagesKeyword(name string, job model.Job) []Finding {
if job.Pages == nil {
return nil
}
publishDir := "public"
if m, ok := job.Pages.(map[string]any); ok {
if p, ok := m["publish"].(string); ok && p != "" {
publishDir = p
}
}
if job.Artifacts == nil {
return []Finding{{
Severity: Warning,
Rule: RulePagesPublish,
Job: name,
Message: fmt.Sprintf("job uses 'pages:' keyword with publish dir %q but has no 'artifacts:' block — GitLab Pages will not deploy", publishDir),
}}
}
m, ok := job.Artifacts.(map[string]any)
if !ok {
return nil
}
paths, ok := m["paths"].([]any)
if !ok || len(paths) == 0 {
return []Finding{{
Severity: Warning,
Rule: RulePagesPublish,
Job: name,
Message: fmt.Sprintf("job uses 'pages:' keyword with publish dir %q but 'artifacts.paths' is missing — GitLab Pages will not deploy", publishDir),
}}
}
for _, p := range paths {
if s, ok := p.(string); ok && (s == publishDir || strings.HasPrefix(s, publishDir+"/")) {
return nil
}
}
return []Finding{{
Severity: Warning,
Rule: RulePagesPublish,
Job: name,
Message: fmt.Sprintf("'pages.publish' is %q but 'artifacts.paths' does not include it — GitLab Pages will not deploy", publishDir),
}}
}
// GL041: cache.key.files must be a list of exact file paths, not glob patterns.
func checkCacheKeyFiles(name string, job model.Job) []Finding {
if job.Cache == nil {
return nil
}
var maps []map[string]any
switch v := job.Cache.(type) {
case map[string]any:
maps = []map[string]any{v}
case []any:
for _, item := range v {
if m, ok := item.(map[string]any); ok {
maps = append(maps, m)
}
}
}
var findings []Finding
for _, m := range maps {
keyVal, ok := m["key"]
if !ok {
continue
}
keyMap, ok := keyVal.(map[string]any)
if !ok {
continue
}
filesVal, ok := keyMap["files"]
if !ok {
continue
}
filesList, ok := filesVal.([]any)
if !ok {
findings = append(findings, Finding{
Severity: Error,
Rule: RuleInvalidCacheKeyFiles,
Job: name,
Message: "'cache.key.files' must be a list of file paths",
})
continue
}
for _, item := range filesList {
s, ok := item.(string)
if !ok {
continue
}
if strings.ContainsAny(s, "*?[") {
findings = append(findings, Finding{
Severity: Warning,
Rule: RuleInvalidCacheKeyFiles,
Job: name,
Message: fmt.Sprintf("cache.key.files: %q looks like a glob pattern; 'key.files' must be exact file paths, not globs", s),
})
}
}
}
return findings
}
+43 -1
View File
@@ -1,7 +1,9 @@
package linter package linter
import ( import (
"cmp"
"fmt" "fmt"
"slices"
"strings" "strings"
"git.k3nny.fr/glint/internal/model" "git.k3nny.fr/glint/internal/model"
@@ -48,15 +50,29 @@ func (f Finding) String() string {
return fmt.Sprintf("%s%s%s %s", loc, rule, sev, msg) return fmt.Sprintf("%s%s%s %s", loc, rule, sev, msg)
} }
// Lint runs all rules against p and returns findings sorted by job name. // Lint runs all rules against p and returns findings sorted by (File, Line, Rule).
// Findings with no File (pipeline-level) sort before file-scoped ones.
func Lint(p *model.Pipeline) []Finding { func Lint(p *model.Pipeline) []Finding {
var findings []Finding var findings []Finding
findings = append(findings, checkStages(p)...) findings = append(findings, checkStages(p)...)
findings = append(findings, checkDuplicateStages(p)...)
findings = append(findings, checkDefault(p)...)
findings = append(findings, checkWorkflow(p)...) findings = append(findings, checkWorkflow(p)...)
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)...) findings = append(findings, checkVariableRefs(p)...)
findings = append(findings, checkRulesIfReachability(p)...)
findings = append(findings, checkInheritCompleteness(p)...)
slices.SortStableFunc(findings, func(a, b Finding) int {
if c := cmp.Compare(a.File, b.File); c != 0 {
return c
}
if c := cmp.Compare(a.Line, b.Line); c != 0 {
return c
}
return cmp.Compare(a.Rule, b.Rule)
})
return findings return findings
} }
@@ -73,6 +89,32 @@ func checkStages(p *model.Pipeline) []Finding {
return findings return findings
} }
// GL040: warn when a stage name appears more than once in stages:.
func checkDuplicateStages(p *model.Pipeline) []Finding {
seen := make(map[string]bool, len(p.Stages))
var findings []Finding
for _, s := range p.Stages {
if seen[s] {
findings = append(findings, Finding{
Severity: Warning,
Rule: RuleDuplicateStage,
File: p.SourceFile,
Message: fmt.Sprintf("stage %q appears more than once in 'stages'; GitLab silently merges duplicate stage entries", s),
})
}
seen[s] = true
}
return findings
}
// checkDefault validates the pipeline-level default: block.
func checkDefault(p *model.Pipeline) []Finding {
if p.Default == nil {
return nil
}
return checkDefaultTimeout(p.Default.Timeout, p.SourceFile)
}
func checkWorkflow(p *model.Pipeline) []Finding { func checkWorkflow(p *model.Pipeline) []Finding {
if p.Workflow == nil { if p.Workflow == nil {
return nil return nil
+40
View File
@@ -114,4 +114,44 @@ const (
// the job's own variables:, or any workflow:rules:variables: block. // 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. // May be a false positive for variables set in GitLab CI/CD project settings.
RuleUndeclaredVariable = "GL032" RuleUndeclaredVariable = "GL032"
// GL033: every rule in a job's rules: block has when: never, so the job
// can never be included in any pipeline run.
RuleDeadRules = "GL033"
// GL034: services: map form is missing 'name', or 'alias' is not a valid DNS label.
RuleInvalidService = "GL034"
// GL035: rules:changes or rules:exists contains an absolute path (starts with /);
// GitLab CI paths are relative to the repository root and absolute paths never match.
RuleAbsoluteGlobPath = "GL035"
// GL036: timeout: is not a valid GitLab CI duration string (e.g. '1h 30m', '90 minutes').
RuleInvalidTimeout = "GL036"
// GL037: id_tokens: entry is missing the required 'aud' key.
RuleInvalidIDToken = "GL037"
// GL038: secrets: entry is missing a provider key (vault, gcp_secret_manager, or azure_key_vault).
RuleInvalidSecret = "GL038"
// GL039: a job has the pages: keyword but artifacts.paths does not include the publish directory.
RulePagesPublish = "GL039"
// GL040: a stage name appears more than once in stages:; GitLab silently merges duplicates.
RuleDuplicateStage = "GL040"
// GL041: cache.key.files contains a glob pattern; it must be a list of exact file paths.
RuleInvalidCacheKeyFiles = "GL041"
// GL042: every rules:if: condition in a job's rules: block evaluates to false
// given the values of variables declared in the pipeline YAML, so the job can
// never be active. Only fires when all referenced variables are declared
// (predefined CI_* / GITLAB_* vars are not evaluated to avoid false positives).
RuleStaticDeadRules = "GL042"
// GL043: a job declares 'inherit: default:' (true, false, or list) but the
// pipeline has no 'default:' block, making the declaration a no-op. Also fires
// when 'inherit: default: [list]' names fields not set in the default: block.
RuleInheritNoDefault = "GL043"
) )
+42
View File
@@ -3,6 +3,7 @@ package model
import ( import (
"fmt" "fmt"
"os" "os"
"strings"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
@@ -60,6 +61,14 @@ func ParseBytes(data []byte) (*Pipeline, error) {
continue continue
} }
// Extract any inline suppression directive from the job's head or line comment.
if rules := parseSuppressComment(keyNode.HeadComment, keyNode.LineComment); len(rules) > 0 {
if p.Suppressions == nil {
p.Suppressions = map[string][]string{}
}
p.Suppressions[key] = rules
}
var rawMap map[string]any var rawMap map[string]any
if err := valNode.Decode(&rawMap); err != nil { if err := valNode.Decode(&rawMap); err != nil {
return nil, fmt.Errorf("parsing raw job %q: %w", key, err) return nil, fmt.Errorf("parsing raw job %q: %w", key, err)
@@ -78,6 +87,39 @@ func ParseBytes(data []byte) (*Pipeline, error) {
return p, nil return p, nil
} }
// parseSuppressComment scans head/line comments from a YAML key node for a
// "# glint: ignore RULE [RULE ...]" directive. Returns the list of rule IDs
// to suppress (uppercased), or []string{"*"} for "# glint: ignore all".
// Returns nil when no directive is found.
func parseSuppressComment(headComment, lineComment string) []string {
for _, raw := range []string{headComment, lineComment} {
for _, line := range strings.Split(raw, "\n") {
line = strings.TrimLeft(line, "# \t")
if !strings.HasPrefix(line, "glint:") {
continue
}
rest := strings.TrimSpace(strings.TrimPrefix(line, "glint:"))
if !strings.HasPrefix(rest, "ignore") {
continue
}
rest = strings.TrimSpace(strings.TrimPrefix(rest, "ignore"))
if rest == "" || strings.EqualFold(rest, "all") {
return []string{"*"}
}
var rules []string
for _, part := range strings.FieldsFunc(rest, func(r rune) bool {
return r == ',' || r == ' ' || r == '\t'
}) {
if p := strings.TrimSpace(part); p != "" {
rules = append(rules, strings.ToUpper(p))
}
}
return rules
}
}
return nil
}
// sanitizeYAMLEscapes rewrites double-quoted YAML strings, replacing the \/ // sanitizeYAMLEscapes rewrites double-quoted YAML strings, replacing the \/
// escape sequence (unrecognised by gopkg.in/yaml.v3) with \\/ so that the // escape sequence (unrecognised by gopkg.in/yaml.v3) with \\/ so that the
// parser produces a literal backslash+slash — preserving regex patterns like // parser produces a literal backslash+slash — preserving regex patterns like
+125
View File
@@ -0,0 +1,125 @@
package model
import "testing"
func TestParseSuppressComment(t *testing.T) {
cases := []struct {
name string
head string
line string
wantRules []string
wantNil bool
}{
{
name: "no comment",
head: "",
line: "",
wantNil: true,
},
{
name: "single rule head comment",
head: "# glint: ignore GL007",
wantRules: []string{"GL007"},
},
{
name: "multiple rules comma-separated",
head: "# glint: ignore GL007, GL032",
wantRules: []string{"GL007", "GL032"},
},
{
name: "multiple rules space-separated",
head: "# glint: ignore GL007 GL032",
wantRules: []string{"GL007", "GL032"},
},
{
name: "ignore all",
head: "# glint: ignore all",
wantRules: []string{"*"},
},
{
name: "ignore all bare",
head: "# glint: ignore",
wantRules: []string{"*"},
},
{
name: "lowercase rule ID is uppercased",
head: "# glint: ignore gl007",
wantRules: []string{"GL007"},
},
{
name: "line comment",
head: "",
line: "# glint: ignore GL004",
wantRules: []string{"GL004"},
},
{
name: "unrelated comment",
head: "# This job deploys to production",
wantNil: true,
},
{
name: "partial match - not a glint directive",
head: "# hint: ignore this",
wantNil: true,
},
{
name: "multi-line head comment with directive on second line",
head: "# Some description\n# glint: ignore GL007",
wantRules: []string{"GL007"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := parseSuppressComment(tc.head, tc.line)
if tc.wantNil {
if got != nil {
t.Errorf("expected nil, got %v", got)
}
return
}
if len(got) != len(tc.wantRules) {
t.Fatalf("got %v, want %v", got, tc.wantRules)
}
for i, r := range tc.wantRules {
if got[i] != r {
t.Errorf("[%d] got %q, want %q", i, got[i], r)
}
}
})
}
}
func TestParseBytes_Suppressions(t *testing.T) {
yaml := `
stages:
- build
# glint: ignore GL007
deprecated-job:
stage: build
only:
- main
normal-job:
stage: build
script: echo ok
`
p, err := ParseBytes([]byte(yaml))
if err != nil {
t.Fatalf("ParseBytes: %v", err)
}
if p.Suppressions == nil {
t.Fatal("Suppressions is nil")
}
rules, ok := p.Suppressions["deprecated-job"]
if !ok {
t.Fatal("no suppression for deprecated-job")
}
if len(rules) != 1 || rules[0] != "GL007" {
t.Errorf("rules = %v, want [GL007]", rules)
}
if _, ok := p.Suppressions["normal-job"]; ok {
t.Error("normal-job should have no suppressions")
}
}
+4
View File
@@ -12,6 +12,10 @@ type Pipeline struct {
// Jobs holds every non-reserved top-level key (i.e. job definitions). // Jobs holds every non-reserved top-level key (i.e. job definitions).
Jobs map[string]Job `yaml:"-"` Jobs map[string]Job `yaml:"-"`
RawJobs map[string]map[string]any `yaml:"-"` // pre-resolution raw maps, used by the resolver RawJobs map[string]map[string]any `yaml:"-"` // pre-resolution raw maps, used by the resolver
// Suppressions maps job names to lists of suppressed rule IDs parsed from
// "# glint: ignore RULE" comments in the pipeline YAML. Only populated for
// the root pipeline file (not for included templates).
Suppressions map[string][]string `yaml:"-"`
} }
// SetJobOrigin sets the File field on all jobs that don't already have one. // SetJobOrigin sets the File field on all jobs that don't already have one.
+5
View File
@@ -80,6 +80,11 @@ func Resolve(p *model.Pipeline) ([]ExtendWarning, error) {
return extWarnings, fmt.Errorf("job %q: re-decoding merged definition: %w", name, err) return extWarnings, fmt.Errorf("job %q: re-decoding merged definition: %w", name, err)
} }
j.Name = name j.Name = name
// Preserve source location — File/Line are not part of the YAML map
// and are lost during the encode/decode round-trip.
orig := p.Jobs[name]
j.File = orig.File
j.Line = orig.Line
p.Jobs[name] = j p.Jobs[name] = j
} }
+95 -16
View File
@@ -4,12 +4,27 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"strings" "strings"
"git.k3nny.fr/glint/internal/fetcher" "git.k3nny.fr/glint/internal/fetcher"
"git.k3nny.fr/glint/internal/model" "git.k3nny.fr/glint/internal/model"
) )
// maxIncludeDepth is the maximum nesting level for include: chains.
// This matches GitLab's own documented limit and also guards against
// include cycles that slip past the visited-key deduplication.
const maxIncludeDepth = 100
// inputPlaceholderRe matches GitLab CI component input references:
//
// $[[ inputs.KEY ]]
// $[[ inputs.KEY | default('value') ]]
// $[[ inputs.KEY | default(true) ]]
// $[[ inputs.KEY | default(123) ]]
var inputPlaceholderRe = regexp.MustCompile(
`\$\[\[\s*inputs\.(\w+)(?:\s*\|\s*default\(([^)]*)\))?\s*\]\]`)
// IncludeWarning describes an include entry that could not be resolved. // IncludeWarning describes an include entry that could not be resolved.
// These are surfaced to the user as [WARNING] lines before the lint findings. // These are surfaced to the user as [WARNING] lines before the lint findings.
type IncludeWarning struct { type IncludeWarning struct {
@@ -53,11 +68,18 @@ type ExtendWarning struct {
// processing included files (forwarded from resolver.Resolve calls). // processing included files (forwarded from resolver.Resolve calls).
func ResolveIncludes(p *model.Pipeline, cfg fetcher.GitLabConfig, rootDir string) ([]IncludeWarning, []ExtendWarning) { func ResolveIncludes(p *model.Pipeline, cfg fetcher.GitLabConfig, rootDir string) ([]IncludeWarning, []ExtendWarning) {
visited := map[string]bool{} visited := map[string]bool{}
return resolveIncludes(p, p.Include, cfg, rootDir, visited) return resolveIncludes(p, p.Include, cfg, rootDir, visited, 0)
} }
// resolveIncludes is the recursive core of ResolveIncludes. // resolveIncludes is the recursive core of ResolveIncludes.
func resolveIncludes(p *model.Pipeline, includes []any, cfg fetcher.GitLabConfig, rootDir string, visited map[string]bool) ([]IncludeWarning, []ExtendWarning) { func resolveIncludes(p *model.Pipeline, includes []any, cfg fetcher.GitLabConfig, rootDir string, visited map[string]bool, depth int) ([]IncludeWarning, []ExtendWarning) {
if depth > maxIncludeDepth {
return []IncludeWarning{{
Label: "includes",
Err: fmt.Errorf("include nesting depth exceeded (%d levels) — possible include cycle detected", maxIncludeDepth),
}}, nil
}
var warnings []IncludeWarning var warnings []IncludeWarning
var extWarnings []ExtendWarning var extWarnings []ExtendWarning
@@ -68,14 +90,15 @@ func resolveIncludes(p *model.Pipeline, includes []any, cfg fetcher.GitLabConfig
} }
if project, _ := entry["project"].(string); project != "" { if project, _ := entry["project"].(string); project != "" {
w, ew := resolveProjectInclude(p, entry, project, cfg, rootDir, visited) w, ew := resolveProjectInclude(p, entry, project, cfg, rootDir, visited, depth)
warnings = append(warnings, w...) warnings = append(warnings, w...)
extWarnings = append(extWarnings, ew...) extWarnings = append(extWarnings, ew...)
continue continue
} }
if compRef, _ := entry["component"].(string); compRef != "" { if compRef, _ := entry["component"].(string); compRef != "" {
w, ew, hadErr := resolveComponentInclude(p, compRef, cfg, rootDir, visited) inputs := extractInputs(entry)
w, ew, hadErr := resolveComponentInclude(p, compRef, inputs, cfg, rootDir, visited, depth)
if hadErr { if hadErr {
warnings = append(warnings, w) warnings = append(warnings, w)
} }
@@ -84,14 +107,14 @@ func resolveIncludes(p *model.Pipeline, includes []any, cfg fetcher.GitLabConfig
} }
if local, _ := entry["local"].(string); local != "" { if local, _ := entry["local"].(string); local != "" {
w, ew := resolveLocalInclude(p, local, cfg, rootDir, visited) w, ew := resolveLocalInclude(p, local, cfg, rootDir, visited, depth)
warnings = append(warnings, w...) warnings = append(warnings, w...)
extWarnings = append(extWarnings, ew...) extWarnings = append(extWarnings, ew...)
continue continue
} }
if remote, _ := entry["remote"].(string); remote != "" { if remote, _ := entry["remote"].(string); remote != "" {
w, ew := resolveRemoteInclude(p, remote, cfg, rootDir, visited) w, ew := resolveRemoteInclude(p, remote, cfg, rootDir, visited, depth)
warnings = append(warnings, w...) warnings = append(warnings, w...)
extWarnings = append(extWarnings, ew...) extWarnings = append(extWarnings, ew...)
continue continue
@@ -105,7 +128,7 @@ func resolveIncludes(p *model.Pipeline, includes []any, cfg fetcher.GitLabConfig
// resolveLocalInclude reads a local file from disk (paths are always relative // resolveLocalInclude reads a local file from disk (paths are always relative
// to the repository root, with or without a leading slash), recursively // to the repository root, with or without a leading slash), recursively
// resolves its own includes, and merges it into p. // resolves its own includes, and merges it into p.
func resolveLocalInclude(p *model.Pipeline, rawPath string, cfg fetcher.GitLabConfig, rootDir string, visited map[string]bool) ([]IncludeWarning, []ExtendWarning) { func resolveLocalInclude(p *model.Pipeline, rawPath string, cfg fetcher.GitLabConfig, rootDir string, visited map[string]bool, depth int) ([]IncludeWarning, []ExtendWarning) {
relPath := strings.TrimPrefix(rawPath, "/") relPath := strings.TrimPrefix(rawPath, "/")
absPath := filepath.Join(rootDir, relPath) absPath := filepath.Join(rootDir, relPath)
label := "local " + rawPath label := "local " + rawPath
@@ -131,7 +154,7 @@ func resolveLocalInclude(p *model.Pipeline, rawPath string, cfg fetcher.GitLabCo
var warnings []IncludeWarning var warnings []IncludeWarning
var extWarnings []ExtendWarning var extWarnings []ExtendWarning
if len(included.Include) > 0 { if len(included.Include) > 0 {
w, ew := resolveIncludes(included, included.Include, cfg, rootDir, visited) w, ew := resolveIncludes(included, included.Include, cfg, rootDir, visited, depth+1)
warnings = append(warnings, w...) warnings = append(warnings, w...)
extWarnings = append(extWarnings, ew...) extWarnings = append(extWarnings, ew...)
} }
@@ -142,7 +165,7 @@ func resolveLocalInclude(p *model.Pipeline, rawPath string, cfg fetcher.GitLabCo
// resolveRemoteInclude fetches a plain HTTPS URL, parses it as CI YAML, and // 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. // 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) { func resolveRemoteInclude(p *model.Pipeline, rawURL string, cfg fetcher.GitLabConfig, rootDir string, visited map[string]bool, depth int) ([]IncludeWarning, []ExtendWarning) {
label := "remote " + rawURL label := "remote " + rawURL
if visited[rawURL] { if visited[rawURL] {
@@ -150,7 +173,7 @@ func resolveRemoteInclude(p *model.Pipeline, rawURL string, cfg fetcher.GitLabCo
} }
visited[rawURL] = true visited[rawURL] = true
data, err := fetcher.FetchURL(rawURL) data, err := cfg.FetchURL(rawURL)
if err != nil { if err != nil {
return []IncludeWarning{{Label: label, Err: err}}, nil return []IncludeWarning{{Label: label, Err: err}}, nil
} }
@@ -164,7 +187,7 @@ func resolveRemoteInclude(p *model.Pipeline, rawURL string, cfg fetcher.GitLabCo
var warnings []IncludeWarning var warnings []IncludeWarning
var extWarnings []ExtendWarning var extWarnings []ExtendWarning
if len(included.Include) > 0 { if len(included.Include) > 0 {
w, ew := resolveIncludes(included, included.Include, cfg, rootDir, visited) w, ew := resolveIncludes(included, included.Include, cfg, rootDir, visited, depth+1)
warnings = append(warnings, w...) warnings = append(warnings, w...)
extWarnings = append(extWarnings, ew...) extWarnings = append(extWarnings, ew...)
} }
@@ -175,7 +198,7 @@ func resolveRemoteInclude(p *model.Pipeline, rawURL string, cfg fetcher.GitLabCo
// 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, depth int) ([]IncludeWarning, []ExtendWarning) {
ref, _ := entry["ref"].(string) ref, _ := entry["ref"].(string)
var warnings []IncludeWarning var warnings []IncludeWarning
var extWarnings []ExtendWarning var extWarnings []ExtendWarning
@@ -186,6 +209,12 @@ func resolveProjectInclude(p *model.Pipeline, entry map[string]any, project stri
label += "@" + ref label += "@" + ref
} }
visitKey := fmt.Sprintf("project:%s:%s@%s", project, filePath, ref)
if visited[visitKey] {
continue
}
visited[visitKey] = true
if !cfg.HasToken() { if !cfg.HasToken() {
warnings = append(warnings, IncludeWarning{Label: label, Skipped: true}) warnings = append(warnings, IncludeWarning{Label: label, Skipped: true})
continue continue
@@ -205,7 +234,7 @@ func resolveProjectInclude(p *model.Pipeline, entry map[string]any, project stri
included.SetJobOrigin(label) included.SetJobOrigin(label)
if len(included.Include) > 0 { if len(included.Include) > 0 {
w, ew := resolveIncludes(included, included.Include, cfg, rootDir, visited) w, ew := resolveIncludes(included, included.Include, cfg, rootDir, visited, depth+1)
warnings = append(warnings, w...) warnings = append(warnings, w...)
extWarnings = append(extWarnings, ew...) extWarnings = append(extWarnings, ew...)
} }
@@ -215,9 +244,11 @@ func resolveProjectInclude(p *model.Pipeline, entry map[string]any, project stri
return warnings, extWarnings return warnings, extWarnings
} }
// resolveComponentInclude fetches a CI/CD catalog component and merges it into p. // resolveComponentInclude fetches a CI/CD catalog component, substitutes any
// $[[ inputs.KEY ]] placeholders with values from `inputs` (the include's
// with: block), and merges the result into p.
// Returns (warning, extWarnings, true) if something went wrong; (zero, nil, false) on success. // Returns (warning, extWarnings, true) if something went wrong; (zero, nil, false) on success.
func resolveComponentInclude(p *model.Pipeline, ref string, cfg fetcher.GitLabConfig, rootDir string, visited map[string]bool) (IncludeWarning, []ExtendWarning, bool) { func resolveComponentInclude(p *model.Pipeline, ref string, inputs map[string]any, cfg fetcher.GitLabConfig, rootDir string, visited map[string]bool, depth int) (IncludeWarning, []ExtendWarning, bool) {
label := "component " + ref label := "component " + ref
if strings.ContainsRune(ref, '$') { if strings.ContainsRune(ref, '$') {
@@ -227,6 +258,12 @@ func resolveComponentInclude(p *model.Pipeline, ref string, cfg fetcher.GitLabCo
}, nil, true }, nil, true
} }
visitKey := "component:" + ref
if visited[visitKey] {
return IncludeWarning{}, nil, false
}
visited[visitKey] = true
host, project, component, version, err := parseComponentRef(ref) host, project, component, version, err := parseComponentRef(ref)
if err != nil { if err != nil {
return IncludeWarning{Label: label, Err: err}, nil, true return IncludeWarning{Label: label, Err: err}, nil, true
@@ -238,6 +275,9 @@ func resolveComponentInclude(p *model.Pipeline, ref string, cfg fetcher.GitLabCo
return IncludeWarning{Label: label, Err: err}, nil, true return IncludeWarning{Label: label, Err: err}, nil, true
} }
// Substitute $[[ inputs.KEY ]] placeholders with the values from with:.
data = substituteInputs(data, inputs)
included, err := model.ParseBytes(data) included, err := model.ParseBytes(data)
if err != nil { if err != nil {
return IncludeWarning{Label: label, Err: fmt.Errorf("parsing component YAML: %w", err)}, nil, true return IncludeWarning{Label: label, Err: fmt.Errorf("parsing component YAML: %w", err)}, nil, true
@@ -246,7 +286,7 @@ func resolveComponentInclude(p *model.Pipeline, ref string, cfg fetcher.GitLabCo
var extWarnings []ExtendWarning var extWarnings []ExtendWarning
if len(included.Include) > 0 { if len(included.Include) > 0 {
_, ew := resolveIncludes(included, included.Include, cfg, rootDir, visited) _, ew := resolveIncludes(included, included.Include, cfg, rootDir, visited, depth+1)
extWarnings = append(extWarnings, ew...) extWarnings = append(extWarnings, ew...)
} }
@@ -254,6 +294,45 @@ func resolveComponentInclude(p *model.Pipeline, ref string, cfg fetcher.GitLabCo
return IncludeWarning{}, extWarnings, false return IncludeWarning{}, extWarnings, false
} }
// substituteInputs replaces all $[[ inputs.KEY ]] and
// $[[ inputs.KEY | default('…') ]] placeholders in data with the corresponding
// values from inputs (the with: block of the component include entry).
// Missing keys with no default become empty strings.
// Missing keys with a default use the default value (single/double quotes stripped).
func substituteInputs(data []byte, inputs map[string]any) []byte {
if len(data) == 0 {
return data
}
return inputPlaceholderRe.ReplaceAllFunc(data, func(match []byte) []byte {
groups := inputPlaceholderRe.FindSubmatch(match)
if len(groups) < 2 {
return match
}
if val, ok := inputs[string(groups[1])]; ok {
return []byte(fmt.Sprintf("%v", val))
}
// Use the default value when the key is absent from inputs.
if len(groups) >= 3 && len(groups[2]) > 0 {
def := strings.TrimSpace(string(groups[2]))
if (strings.HasPrefix(def, "'") && strings.HasSuffix(def, "'")) ||
(strings.HasPrefix(def, `"`) && strings.HasSuffix(def, `"`)) {
def = def[1 : len(def)-1]
}
return []byte(def)
}
return []byte("")
})
}
// extractInputs returns the with: map from a component include entry, or nil.
func extractInputs(entry map[string]any) map[string]any {
with, ok := entry["with"].(map[string]any)
if !ok {
return nil
}
return with
}
// parseComponentRef parses a CI/CD component reference of the form: // parseComponentRef parses a CI/CD component reference of the form:
// //
// <host>/<project-path>/<component-name>@<version> // <host>/<project-path>/<component-name>@<version>
+90
View File
@@ -0,0 +1,90 @@
package resolver
import (
"testing"
)
func TestSubstituteInputs(t *testing.T) {
cases := []struct {
name string
data string
inputs map[string]any
want string
}{
{
name: "simple substitution",
data: `stage: $[[ inputs.STAGE ]]`,
inputs: map[string]any{"STAGE": "deploy"},
want: `stage: deploy`,
},
{
name: "default string used when key absent",
data: `image: $[[ inputs.IMAGE | default('ubuntu:22.04') ]]`,
inputs: map[string]any{},
want: `image: ubuntu:22.04`,
},
{
name: "input overrides default",
data: `image: $[[ inputs.IMAGE | default('ubuntu:22.04') ]]`,
inputs: map[string]any{"IMAGE": "alpine:3.18"},
want: `image: alpine:3.18`,
},
{
name: "integer input",
data: `variables:\n RETRIES: $[[ inputs.RETRY_COUNT ]]`,
inputs: map[string]any{"RETRY_COUNT": 3},
want: `variables:\n RETRIES: 3`,
},
{
name: "boolean default",
data: `variables:\n ENABLED: $[[ inputs.ENABLE | default(true) ]]`,
inputs: map[string]any{},
want: `variables:\n ENABLED: true`,
},
{
name: "numeric default",
data: `variables:\n COUNT: $[[ inputs.COUNT | default(5) ]]`,
inputs: map[string]any{},
want: `variables:\n COUNT: 5`,
},
{
name: "missing key no default becomes empty",
data: `script: $[[ inputs.CMD ]]`,
inputs: map[string]any{},
want: `script: `,
},
{
name: "no placeholders unchanged",
data: `stage: build`,
inputs: nil,
want: `stage: build`,
},
{
name: "multiple placeholders in one document",
data: "stage: $[[ inputs.STAGE ]]\nimage: $[[ inputs.IMAGE | default('alpine') ]]",
inputs: map[string]any{"STAGE": "test"},
want: "stage: test\nimage: alpine",
},
{
name: "double-quoted default",
data: `image: $[[ inputs.IMAGE | default("debian:12") ]]`,
inputs: map[string]any{},
want: `image: debian:12`,
},
{
name: "whitespace inside brackets",
data: `stage: $[[ inputs.STAGE ]]`,
inputs: map[string]any{"STAGE": "build"},
want: `stage: build`,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := string(substituteInputs([]byte(tc.data), tc.inputs))
if got != tc.want {
t.Errorf("substituteInputs:\n got %q\n want %q", got, tc.want)
}
})
}
}
+10
View File
@@ -0,0 +1,10 @@
stages:
- build
# GL007 (only/except deprecated) would normally fire here, but the .glint.yml
# in this directory ignores GL007.
deprecated-job:
stage: build
only:
- main
script: echo ok
+2
View File
@@ -0,0 +1,2 @@
ignore:
- GL007
+8
View File
@@ -0,0 +1,8 @@
stages:
- build
# GL004 (undefined stage) is demoted to warning by .glint.yml, so the exit
# code must be 0 even though there is a finding.
bad-stage-job:
stage: nonexistent
script: echo hi
+2
View File
@@ -0,0 +1,2 @@
severity:
GL004: warning
+15
View File
@@ -0,0 +1,15 @@
stages:
- build
# glint: ignore GL007
suppressed-job:
stage: build
only:
- main
script: echo ok
still-flagged-job:
stage: build
only:
- main
script: echo ok
+59
View File
@@ -0,0 +1,59 @@
---
# dead_rules.yml
# Exercises GL033: rules: block where every rule has when: never.
# All flagged jobs produce a WARNING (exit 0). Valid jobs must not be flagged.
stages:
- build
- test
- deploy
# ── Jobs that should trigger GL033 ─────────────────────────────────────────
# Single bare catch-all never — job is always excluded.
disabled-job:
stage: build
script: echo disabled
rules:
- when: never
# Multiple rules, all when: never — no branch can activate this job.
dead-multi:
stage: test
script: echo dead
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
when: never
- if: '$CI_COMMIT_BRANCH == "develop"'
when: never
- when: never
# ── Jobs that must NOT trigger GL033 ───────────────────────────────────────
# First rule can activate.
main-only:
stage: build
script: echo main
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
when: on_success
- when: never
# Rule with no when: — defaults to on_success, so job is reachable.
implicit-on-success:
stage: test
script: echo implicit
rules:
- if: '$CI_COMMIT_BRANCH == "develop"'
# Manual is not never — job is reachable (just gated).
manual-gate:
stage: deploy
script: echo deploy
rules:
- when: manual
# No rules at all — always active.
always-active:
stage: build
script: echo always
+26
View File
@@ -0,0 +1,26 @@
stages:
- build
- test
# No default: block — any inherit: default: declaration is a no-op.
# GL043: inherit: default: false but no default: block.
isolated-job:
stage: build
inherit:
default: false
script: echo isolated
# GL043: inherit: default: [list] but no default: block.
selective-inherit-job:
stage: test
inherit:
default: [image, tags]
script: echo selective
# Not GL043: inherit only touches variables: (no default: check needed).
vars-inherit-job:
stage: test
inherit:
variables: false
script: echo vars-only
+20
View File
@@ -0,0 +1,20 @@
stages:
- build
- test
default:
image: node:20
# GL043: before_script is in the list but not defined in default:.
selective-bad:
stage: build
inherit:
default: [image, before_script] # before_script not in default:
script: echo hi
# Not GL043: image IS defined in default: — list is valid.
selective-good:
stage: test
inherit:
default: [image]
script: echo hi
+4
View File
@@ -9,6 +9,10 @@ workflow:
when: always when: always
- when: never - when: never
default:
tags:
- docker
variables: variables:
GO_VERSION: "1.26" GO_VERSION: "1.26"
+81
View File
@@ -0,0 +1,81 @@
stages:
- build
- test
- test
variables:
DEPLOY_ENV: staging
# GL040: duplicate stage "test" above triggers a warning
# GL034: services map form missing name
service-no-name:
stage: build
script: [echo ok]
services:
- alias: my-svc
# GL034: services map form with invalid alias (contains spaces)
service-bad-alias:
stage: build
script: [echo ok]
services:
- name: redis:latest
alias: "my bad alias"
# GL035: rules:changes with absolute path
absolute-changes:
stage: test
script: [echo test]
rules:
- changes:
- /src/main.go
# GL035: rules:exists with absolute path
absolute-exists:
stage: test
script: [echo test]
rules:
- exists:
- /Dockerfile
# GL036: invalid timeout format
bad-timeout:
stage: build
script: [echo build]
timeout: forever
# GL037: id_tokens entry missing aud
bad-token:
stage: test
script: [echo test]
id_tokens:
MY_TOKEN:
expire: 3600
# GL038: secrets entry missing provider
bad-secret:
stage: test
script: [echo test]
secrets:
DB_PASSWORD:
expire: 3600
# GL039: pages keyword but publish dir not in artifacts.paths
bad-pages:
stage: build
script: [mkdocs build]
pages:
publish: dist
artifacts:
paths:
- public
# GL041: cache.key.files contains a glob
bad-cache-glob:
stage: build
script: [echo build]
cache:
key:
files:
- "*.sum"
+116
View File
@@ -0,0 +1,116 @@
stages:
- build
- test
- deploy
variables:
DEPLOY_ENV: staging
# GL034: services — string form and map form with name are both valid
service-string:
stage: build
script: [echo ok]
services:
- redis:latest
- postgres:14
service-map:
stage: build
script: [echo ok]
services:
- name: postgres:14
alias: db
- name: redis:latest
alias: cache-svc
# GL035: rules:changes/exists — relative paths are valid
rules-relative:
stage: test
script: [echo test]
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
changes:
- src/**/*.go
- tests/*.go
- exists:
- Dockerfile
- docker-compose.yml
when: on_success
# GL036: timeout — valid duration strings
timeout-short:
stage: build
script: [echo build]
timeout: 30m
timeout-long:
stage: build
script: [echo build]
timeout: 1h 30m
timeout-words:
stage: test
script: [echo test]
timeout: 90 minutes
timeout-combined:
stage: deploy
script: [echo deploy]
timeout: 2 hours 30 minutes
# GL037: id_tokens — entry with valid aud
token-job:
stage: build
script: [echo build]
id_tokens:
VAULT_TOKEN:
aud: https://vault.example.com
SIGSTORE_TOKEN:
aud: sigstore
# GL038: secrets — valid provider keys
secret-vault:
stage: deploy
script: [echo deploy]
secrets:
DB_PASSWORD:
vault: production/db/password@ops
secret-gcp:
stage: deploy
script: [echo deploy]
secrets:
API_KEY:
gcp_secret_manager:
name: my-api-key
version: latest
# GL039: pages keyword — publish dir present in artifacts.paths
pages-keyword:
stage: deploy
script: [mkdocs build]
pages:
publish: site
artifacts:
paths:
- site
pages-keyword-default:
stage: deploy
script: [make docs]
pages: true
artifacts:
paths:
- public
# GL040: no duplicate stages (unique stages defined above)
# GL041: cache.key.files — list of exact paths
cache-key-job:
stage: build
script: [echo build]
cache:
key:
files:
- go.sum
- go.mod
+48
View File
@@ -0,0 +1,48 @@
stages:
- deploy
- test
variables:
DEPLOY: "false"
SKIP_TEST: "true"
# GL042: both if: conditions always evaluate to false given the declared variables.
always-dead-deploy:
stage: deploy
rules:
- if: '$DEPLOY == "true"'
when: on_success
script: echo deploy
# GL042: if: is false, explicit when:on_success rule — still never fires.
always-dead-test:
stage: test
rules:
- if: '$SKIP_TEST == "false"'
when: on_success
script: echo test
# Not GL042: references a predefined CI_ variable → unknown value → skip check.
predefined-var-job:
stage: test
rules:
- if: '$CI_COMMIT_BRANCH == "nonexistent-branch"'
when: on_success
script: echo branch
# Not GL042: has an unconditional rule (no if: → always matches).
unconditional-job:
stage: test
rules:
- if: '$DEPLOY == "true"'
when: never
- when: on_success
script: echo always
# Not GL042: references undeclared variable → unknown → conservative.
undeclared-var-job:
stage: test
rules:
- if: '$UNDECLARED_VAR == "something"'
when: on_success
script: echo undeclared
+12 -6
View File
@@ -4,10 +4,10 @@
# The matching workflow rule sets DEPLOY_TARGET; job rules use it. # The matching workflow rule sets DEPLOY_TARGET; job rules use it.
# #
# Expected behaviour per context: # Expected behaviour per context:
# (no context) → all jobs active (no context evaluation) # --branch main → only build active (no workflow rule matches; WORKFLOW var not set)
# --branch main → deploy-prod active, deploy-staging skipped # --branch develop → deploy-staging active, deploy-prod skipped
# --branch develop → deploy-prod skipped, deploy-staging active # --branch feat/x → only build active (when: always fallback, no deploy vars)
# --branch feat/x → deploy-prod skipped, deploy-staging skipped (manual) # --var WORKFLOW=gitflow --branch us/feature → deploy-prod + build active
stages: stages:
- build - build
@@ -20,9 +20,15 @@ variables:
workflow: workflow:
rules: rules:
- if: '$CI_COMMIT_BRANCH == "main"' - if: "
$WORKFLOW = 'gitflow' &&
$CI_PIPELINE_SOURCE == /(push|web)/ &&
$CI_COMMIT_BRANCH =~ /^us\//
"
variables: variables:
DEPLOY_TARGET: production DEPLOY_TARGET: production
DEPLOY: true
BUILD: true
- if: '$CI_COMMIT_BRANCH == "develop"' - if: '$CI_COMMIT_BRANCH == "develop"'
variables: variables:
DEPLOY_TARGET: staging DEPLOY_TARGET: staging
@@ -38,7 +44,7 @@ deploy-prod:
stage: deploy stage: deploy
script: make deploy ENV=production script: make deploy ENV=production
rules: rules:
- if: '$DEPLOY_TARGET == "production"' - if: '$DEPLOY_TARGET == "production" && $DEPLOY == "true"'
when: on_success when: on_success
- when: never - when: never