4 Commits

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 12:32:47 +02:00
25 changed files with 2126 additions and 107 deletions
+84
View File
@@ -5,6 +5,90 @@ 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.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
### Added
- **Variable expansion (`$VAR` / `${VAR}`)** — variable values that reference other variables are now expanded in the effective context after all sources are merged. Transitive chains (`A=$B`, `B=$C`) are resolved over up to ten passes; circular references are left as-is. The expanded values are visible in `--list-vars` output under "Effective context variables" and are used when evaluating `rules:if:` expressions.
- **Non-string scalar variables (`bool`, `int`, `float64`)** — variables declared with bare `true`/`false` or integer values (e.g. `BUILD: true`, `RETRIES: 3`) are now handled correctly in all variable processing paths. Previously they were rendered as `(complex)` in `--list-vars` output and silently dropped from the effective context; they now render and inject as their string equivalents (`"true"`, `"3"`), matching GitLab CI's own behaviour where all variable values are strings.
### Fixed
- **YAML `\/` escape in double-quoted strings** — regex patterns containing `\/` (escaped forward-slash) in double-quoted `if:` expressions (e.g. `$CI_COMMIT_BRANCH =~ /^us\//`) caused a YAML parse error (`found unknown escape character`) with `gopkg.in/yaml.v3`, which does not implement this YAML 1.2 escape. The parser now preprocesses the raw bytes before unmarshalling: inside double-quoted strings, `\/` is rewritten to `\\/`, which `yaml.v3` parses as a literal backslash followed by a slash — preserving the regex intent.
## [0.2.11] - 2026-06-12 ## [0.2.11] - 2026-06-12
### Added ### Added
+92 -1
View File
@@ -6,7 +6,7 @@
<p align="center"> <p align="center">
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-blue.svg" alt="License"></a> <a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-blue.svg" alt="License"></a>
<a href="CHANGELOG.md"><img src="https://img.shields.io/badge/release-v0.2.11-blue.svg" alt="Release"></a> <a href="CHANGELOG.md"><img src="https://img.shields.io/badge/release-v0.2.18-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.
@@ -28,6 +28,24 @@ A local tool to validate and lint `.gitlab-ci.yml` pipelines without needing a G
- **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 - **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 - **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 - **Context simulation** — pass `--branch`, `--tag`, or `--source` to `glint check` or `glint graph` to see which jobs would be active, manual, or skipped for a specific pipeline event; evaluates `rules:if:` expressions and `only`/`except` filters
- **Variable expansion** — `$VAR` and `${VAR}` references inside variable values are expanded after all sources are merged (pipeline defaults → workflow-rule overrides → CLI flags); transitive chains resolve automatically; visible via `--list-vars`
- **Non-string variable scalars** — `BUILD: true`, `RETRIES: 3` and other bare boolean/integer variable values are handled correctly throughout: they render in `--list-vars` output and are injected into the evaluation context as their string equivalents, matching GitLab CI's behaviour
- **Static reachability (GL033)** — warns when a job's `rules:` block can never activate: if every rule has `when: never` the job is permanently excluded from any pipeline run, provable without evaluating any `if:` expressions
- **`services:` validation (GL034)** — map form requires a `name` key; `alias` must be a valid DNS label (letters, digits, hyphens, dots; no leading/trailing hyphens)
- **`rules:changes` / `rules:exists` glob safety (GL035)** — warns when paths are absolute (start with `/`), which can never match since GitLab CI paths are always relative to the repository root
- **`timeout:` format (GL036)** — validates that job and `default:` timeout values are valid GitLab CI duration strings (`1h 30m`, `90 minutes`, `2 hours`, etc.)
- **`id_tokens:` validation (GL037)** — each OIDC token entry must have an `aud` key (missing `aud` is a GitLab API error at runtime)
- **`secrets:` validation (GL038)** — each secret entry must declare exactly one provider (`vault`, `gcp_secret_manager`, or `azure_key_vault`)
- **`pages:` keyword + `artifacts.paths` (GL039)** — warns when a job uses the `pages:` keyword but `artifacts.paths` does not include the publish directory (default: `public`)
- **Duplicate stage names (GL040)** — warns when a stage name appears more than once in `stages:`; GitLab silently merges duplicates, which can cause confusing ordering
- **`cache.key.files` glob detection (GL041)** — warns when `cache.key.files` entries contain glob metacharacters; this field requires exact file paths, not patterns
- **Recursive include depth limit** — include chains are capped at 100 nesting levels (matching GitLab's own limit); project and component includes are now tracked in the visited-file set to prevent cross-include cycles
- **`include: inputs:` substitution** — when a `component:` entry has a `with:` block, all `$[[ inputs.KEY ]]` and `$[[ inputs.KEY | default(…) ]]` placeholders in the fetched template are substituted before parsing, so component-scoped jobs get their correct `stage:` and keyword values instead of `$[[…]]` placeholders
- **Offline mode + include cache** — pass `--cache-dir DIR` to cache fetched remote templates (project: and component: includes) to disk; `--offline` serves entirely from the cache without making network calls
- **Structured output formats** — `--format json` emits a stable JSON report; `--format sarif` emits SARIF 2.1.0 (consumed by GitHub Code Scanning and GitLab SAST); `--format junit` emits JUnit XML (consumable as a CI test-report artifact); `--format github` emits GitHub Actions annotation lines (`::error file=…::`) so findings appear as inline PR comments
- **Sorted findings output** — findings are sorted by source file then line number, so all issues from the same file appear together in order; pipeline-level findings (no file) sort first
- **Consistent ruff-style warnings** — all warnings (unresolvable includes, skipped extends chains, workflow non-start) use the same `path: [warning] message` format as lint findings
- **`--version` / `-v` flag** — prints the compiled version string (e.g. `glint v0.2.14`); the version is also shown at the top of every `--help` output
See [ROADMAP.md](ROADMAP.md) for planned improvements. See [ROADMAP.md](ROADMAP.md) for planned improvements.
@@ -70,6 +88,50 @@ glint check .gitlab-ci.yml
Exits `0` when no errors are found, `1` when at least one error is reported. Exits `0` when no errors are found, `1` when at least one error is reported.
### 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
(`OK: … no issues found` or `N finding(s): M error(s)`) 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.18",
"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'
```
### Remote project includes ### Remote project includes
Pipelines that include templates from other GitLab projects are supported. Pipelines that include templates from other GitLab projects are supported.
@@ -92,6 +154,24 @@ glint check --token glpat-xxxx --gitlab-url https://gitlab.example.com .gitlab-c
**Project includes** require a token; without one they are skipped with a **Project includes** require a token; without one they are skipped with a
warning and the rest of the pipeline is linted as-is. warning and the rest of the pipeline is linted as-is.
### Include cache and offline mode
Pass `--cache-dir` to cache fetched remote templates so repeated runs skip the network:
```bash
# First run: fetches and caches
glint check --cache-dir ~/.cache/glint .gitlab-ci.yml
# Subsequent runs: served from cache
glint check --cache-dir ~/.cache/glint .gitlab-ci.yml
# Fully offline (uses ~/.cache/glint automatically when --cache-dir is absent)
glint check --offline .gitlab-ci.yml
glint check --offline --cache-dir ~/.cache/glint .gitlab-ci.yml
```
Cache entries are keyed by SHA-256 of the full request URL/coordinates and stored as plain YAML files in the cache directory. There is currently no automatic expiry — delete the directory or individual entries to force a fresh fetch.
**Component includes** (`include: component: ...`) attempt the fetch **Component includes** (`include: component: ...`) attempt the fetch
unauthenticated, so public [CI/CD Catalog](https://gitlab.com/explore/catalog) unauthenticated, so public [CI/CD Catalog](https://gitlab.com/explore/catalog)
components work without a token. A warning is emitted if the fetch fails. components work without a token. A warning is emitted if the fetch fails.
@@ -200,6 +280,7 @@ glint check --branch main --var DEPLOY_ENV=production .gitlab-ci.yml
**Evaluated:** **Evaluated:**
- `rules:if:` — full expression language: `==`, `!=`, `=~`, `!~`, `&&`, `||`, `!`, `()`, `$VAR`, string literals, `null` - `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 - `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:`. **Not evaluated** (no git tree at lint time): `rules:changes:`, `rules:exists:`.
Rules without an `if:` clause always match. Rules without an `if:` clause always match.
@@ -257,6 +338,8 @@ Every finding includes a stable rule ID (e.g. `GL003`) that can be used to filte
|----|----------|------| |----|----------|------|
| GL002 | ERROR | `workflow.rules[*].when` is not `always` or `never` | | GL002 | ERROR | `workflow.rules[*].when` is not `always` or `never` |
| GL001 | WARNING | No `stages` defined (GitLab falls back to default stages) | | GL001 | WARNING | No `stages` defined (GitLab falls back to default stages) |
| GL036 | ERROR | `default.timeout` is not a valid GitLab CI duration string |
| GL040 | WARNING | A stage name appears more than once in `stages:` |
### Job-level — structure ### Job-level — structure
@@ -291,6 +374,12 @@ Every finding includes a stable rule ID (e.g. `GL003`) that can be used to filte
| GL024 | ERROR | `rules[*].when` is not one of the valid `when` values | | GL024 | ERROR | `rules[*].when` is not one of the valid `when` values |
| GL025 | ERROR | `image` map form missing `name` key | | GL025 | ERROR | `image` map form missing `name` key |
| GL026 | ERROR | `inherit.default` / `inherit.variables` is not a boolean or list | | GL026 | ERROR | `inherit.default` / `inherit.variables` is not a boolean or list |
| GL034 | ERROR | `services:` map form missing `name`, or `alias` is not a valid DNS label |
| GL036 | ERROR | `timeout:` is not a valid GitLab CI duration string (e.g. `1h 30m`, `90 minutes`) |
| GL037 | ERROR | `id_tokens:` entry is missing the required `aud` key |
| GL038 | ERROR | `secrets:` entry is missing a provider key (`vault`, `gcp_secret_manager`, or `azure_key_vault`) |
| GL039 | WARNING | Job has `pages:` keyword but `artifacts.paths` does not include the publish directory |
| GL041 | WARNING | `cache.key.files` entry looks like a glob pattern; must be an exact file path |
### Cross-job graph ### Cross-job graph
@@ -307,6 +396,8 @@ Every finding includes a stable rule ID (e.g. `GL003`) that can be used to filte
| ID | Severity | Rule | | 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 | | GL032 | WARNING | `rules:if:` references `$VAR` not declared in `variables:` (pipeline, job, or `workflow:rules:variables:`) — may be a false positive for variables set in GitLab CI/CD project settings |
| GL033 | WARNING | Every rule in `rules:` has `when: never` — job is permanently excluded from the pipeline (statically provable without context) |
| GL035 | WARNING | `rules:changes` / `rules:exists` path is absolute; GitLab CI paths are relative to the repo root — absolute paths will never match |
### Hidden jobs (templates) ### Hidden jobs (templates)
+34 -20
View File
@@ -4,7 +4,7 @@ This document tracks planned improvements to `glint`. Items are grouped by theme
--- ---
## Context-aware validation — ✓ single-context shipped in v0.2.0; expression evaluator hardened post-v0.2.0; implicit defaults and --list-vars shipped v0.2.11 ## Context-aware validation — ✓ single-context shipped in v0.2.0; expression evaluator hardened post-v0.2.0; implicit defaults and --list-vars shipped v0.2.11; variable expansion and scalar handling shipped v0.2.13; workflow evaluation and output fixes shipped v0.2.14
Single-context simulation is fully implemented. Pass `--branch`, `--tag`, `--source`, or `--var` to either `glint check` or `glint graph`; jobs are evaluated and shown as active / manual / skipped. 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.
@@ -30,6 +30,21 @@ glint graph tree --branch main .gitlab-ci.yml # tree annotated with [skipped]
~~**`--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. ~~**`--list-vars` debug flag**~~ — ✓ shipped v0.2.11; prints sorted `KEY=VALUE` of all collected variables (pipeline YAML + included files + workflow-rule union + effective context) to stderr.
**Shipped in v0.2.13**
-**Variable expansion**`$VAR` / `${VAR}` references within variable values are expanded after all sources are merged; transitive chains resolve over multiple passes; visible in `--list-vars` effective-context output.
-**Non-string scalar variables**`BUILD: true`, `RETRIES: 3` and similar bare boolean/integer values now render correctly in `--list-vars` and are injected into the evaluation context as string equivalents; previously shown as `(complex)` and silently dropped.
-**YAML `\/` escape in double-quoted strings** — regex patterns like `/^us\//` in double-quoted `if:` blocks no longer cause a parse error; the raw bytes are preprocessed before YAML unmarshalling.
**Shipped in v0.2.14**
-**Workflow rule strict evaluation** — workflow `rules:if:` now uses strict mode (parse failure → skip rule, not match); fixes premature matching that blocked later rules and injected wrong variables.
-**Single `=` operator**`=` is now accepted as an alias for `==` in `rules:if:` expressions, matching common user intent.
-**Source location through `extends:` resolution**`File` and `Line` are now preserved when a job is rebuilt via extends, so findings reference the correct source location.
-**Sorted findings output** — findings are sorted by `(File, Line, Rule)`; same-file issues group together in line order.
-**Consistent warning format** — all warnings use ruff-style `path: [warning] message` format.
-**`--version` / `-v` flag** — prints compiled version; version also shown at the top of every `--help` output.
**Remaining work** **Remaining work**
- **Multi-context simulation** — run multiple contexts in one invocation and print a comparison table: - **Multi-context simulation** — run multiple contexts in one invocation and print a comparison table:
@@ -46,15 +61,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
- ~~**`pages:publish` + `artifacts.paths` consistency (GL039)**~~ — ✓ shipped v0.2.16; warns when the publish directory is missing from `artifacts.paths`
- ~~**Duplicate stage names (GL040)**~~ — ✓ shipped v0.2.16; warns when a stage appears more than once in `stages:`
- ~~**`cache:key:files` must be exact paths (GL041)**~~ — ✓ shipped v0.2.16; warns when entries look like glob patterns
- ~~**Unreachable jobs**~~ — covered by GL033 (shipped v0.2.15); every-`when:never` rules block is statically dead
- **`inherit:` completeness** — flag when a job overrides a default field that would require `inherit: default: false` to suppress - **`inherit:` completeness** — flag when a job overrides a default field that would require `inherit: default: false` to suppress
- **Unreachable jobs** — detect jobs that can never run because every `rules:` branch evaluates to `never` (static analysis only, no variable expansion)
- **Duplicate stage names** — GitLab silently merges them; warn to avoid confusion
- **`cache:key:files`** — must be a list of paths, not a glob
--- ---
@@ -62,20 +78,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
--- ---
@@ -131,7 +145,7 @@ The SVG renderer and terminal tree cover the basic layout. These would bring it
## Reliability and developer experience ## Reliability and developer experience
- ~~**Structured rule IDs**~~ — ✓ 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
- **`--explain <rule-id>`** — print the rule description, rationale, and an example fix - **`--explain <rule-id>`** — print the rule description, rationale, and an example fix
- ~~**Semantic versioning and first release**~~ — shipped as `v0.1.0` (2026-06-07) - ~~**Semantic versioning and first release**~~ — shipped as `v0.1.0` (2026-06-07)
- ~~**Subcommand CLI**~~ — shipped as `v0.2.0` (2026-06-11); `glint check` / `glint graph [mode]` with ruff-style `--help` - ~~**Subcommand CLI**~~ — shipped as `v0.2.0` (2026-06-11); `glint check` / `glint graph [mode]` with ruff-style `--help`
+19 -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,26 @@ 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
lint-go: lint-go:
desc: Run go vet on all packages desc: Run go vet on all packages
@@ -112,7 +128,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 +144,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
+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)
}
}
+108 -33
View File
@@ -16,6 +16,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>
@@ -25,7 +40,8 @@ Commands:
graph Visualise the pipeline as a job tree or Mermaid graph graph Visualise the pipeline as a job tree or Mermaid graph
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`" + `.
` `
@@ -41,7 +57,10 @@ func main() {
case "graph": case "graph":
cmdGraph(os.Args[2:]) cmdGraph(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 +80,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 +90,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 +102,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 +115,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 +157,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 +168,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 +180,27 @@ 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)
cfg := fetcher.AutoConfig().WithOverrides(*gitlabURL, *token) // --offline with no explicit --cache-dir defaults to ~/.cache/glint.
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 {
@@ -153,7 +211,7 @@ Examples:
rootDir := filepath.Dir(filepath.Clean(path)) rootDir := filepath.Dir(filepath.Clean(path))
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 +220,55 @@ 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 errCount, _ := countSeverities(findings)
for _, f := range findings {
fmt.Println(f) // In structured formats the summary line goes to stderr so stdout is clean.
if f.Severity == linter.Error { summaryOut := os.Stdout
hasErrors = true 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 {
fmt.Println(f)
} }
} }
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 +288,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 +378,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 {
@@ -404,14 +483,8 @@ func printVarMap(m map[string]any) {
} }
func varValueString(v any) string { func varValueString(v any) string {
switch val := v.(type) { if s, ok := cicontext.ScalarString(v); ok {
case string: return s
return val
case map[string]any:
if s, ok := val["value"].(string); ok {
return s
}
return "(complex)"
} }
return "(complex)" return "(complex)"
} }
@@ -419,19 +492,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
// sources (pipeline, workflow rules, CLI) have been merged.
ctx.ExpandVars()
return runs
} }
func printContext(p *model.Pipeline, ctx *cicontext.Context) { func printContext(p *model.Pipeline, ctx *cicontext.Context) {
+118 -10
View File
@@ -1,6 +1,9 @@
package cicontext package cicontext
import "strings" import (
"fmt"
"strings"
)
// Context holds the simulated CI execution environment used for context-aware // Context holds the simulated CI execution environment used for context-aware
// pipeline evaluation (rules:if:, only:, except:, workflow:rules:). // pipeline evaluation (rules:if:, only:, except:, workflow:rules:).
@@ -117,26 +120,131 @@ func (c *Context) Summary() string {
// ExtractStringVars converts a map[string]any variable block (as used by // ExtractStringVars converts a map[string]any variable block (as used by
// Pipeline.Variables and Rule.Variables) to a flat map[string]string. // Pipeline.Variables and Rule.Variables) to a flat map[string]string.
// Plain string values are used directly. Extended {value: "..."} map form // Plain string values are used directly. Extended {value: ...} map form uses
// uses the "value" key. Other forms are skipped. // the "value" key. Scalar non-string values (bool, int, float64) are
// converted to their string representation, matching GitLab's own behaviour
// where all CI variable values are strings.
func ExtractStringVars(m map[string]any) map[string]string { func ExtractStringVars(m map[string]any) map[string]string {
if len(m) == 0 { if len(m) == 0 {
return nil return nil
} }
out := make(map[string]string, len(m)) out := make(map[string]string, len(m))
for k, v := range m { for k, v := range m {
switch val := v.(type) { if s, ok := ScalarString(v); ok {
case string: out[k] = s
out[k] = val
case map[string]any:
if s, ok := val["value"].(string); ok {
out[k] = s
}
} }
} }
return out return out
} }
// ScalarString converts a YAML-decoded CI variable value to its string
// representation. Handles plain scalars and the extended {value: ...} map
// form. Returns (s, true) on success, ("", false) for unrecognised forms.
func ScalarString(v any) (string, bool) {
switch val := v.(type) {
case string:
return val, true
case bool:
return fmt.Sprintf("%t", val), true
case int:
return fmt.Sprintf("%d", val), true
case float64:
return fmt.Sprintf("%g", val), true
case map[string]any:
inner, ok := val["value"]
if !ok {
return "", false
}
return ScalarString(inner)
}
return "", false
}
// ExpandVars expands $VAR and ${VAR} references within every value in
// ctx.Vars, using the same map as the expansion source. Iteration repeats
// (up to 10 passes) so transitive chains like A=$B, B=$C resolve fully.
// Variables that form circular references are left as-is after the limit.
func (c *Context) ExpandVars() {
if c == nil || len(c.Vars) == 0 {
return
}
for range 10 {
changed := false
for k, v := range c.Vars {
expanded := expandVarRefs(v, c.Vars)
if expanded != v {
c.Vars[k] = expanded
changed = true
}
}
if !changed {
return
}
}
}
// expandVarRefs replaces $VAR and ${VAR} occurrences in s with their values
// from vars. Unknown variables are left unchanged.
func expandVarRefs(s string, vars map[string]string) string {
if !strings.Contains(s, "$") {
return s
}
var sb strings.Builder
i := 0
for i < len(s) {
if s[i] != '$' {
sb.WriteByte(s[i])
i++
continue
}
i++ // consume '$'
if i >= len(s) {
sb.WriteByte('$')
break
}
if s[i] == '{' {
i++ // consume '{'
j := i
for j < len(s) && isIdentByte(s[j]) {
j++
}
if j < len(s) && s[j] == '}' {
name := s[i:j]
if val, ok := vars[name]; ok {
sb.WriteString(val)
} else {
sb.WriteString("${")
sb.WriteString(name)
sb.WriteByte('}')
}
i = j + 1
} else {
// Malformed ${…} — emit literally
sb.WriteString("${")
i = j
}
} else {
j := i
for j < len(s) && isIdentByte(s[j]) {
j++
}
if j > i {
name := s[i:j]
if val, ok := vars[name]; ok {
sb.WriteString(val)
} else {
sb.WriteByte('$')
sb.WriteString(name)
}
i = j
} else {
sb.WriteByte('$')
}
}
}
return sb.String()
}
// slugify converts a ref name to its GitLab slug form: // slugify converts a ref name to its GitLab slug form:
// lowercased, non-alphanumeric characters replaced with '-', leading/trailing '-' removed. // lowercased, non-alphanumeric characters replaced with '-', leading/trailing '-' removed.
func slugify(s string) string { func slugify(s string) string {
+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":
+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))
}
+35 -10
View File
@@ -22,9 +22,11 @@ const (
// GitLabConfig holds everything needed to reach a GitLab instance. // GitLabConfig holds everything needed to reach a GitLab instance.
type GitLabConfig struct { 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)
}
})
}
}
+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
}
+41 -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,27 @@ 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)...)
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 +87,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
+29
View File
@@ -114,4 +114,33 @@ 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"
) )
+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)
}
})
}
}
+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
+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
+13 -7
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
@@ -46,6 +52,6 @@ deploy-staging:
stage: deploy stage: deploy
script: make deploy ENV=staging script: make deploy ENV=staging
rules: rules:
- if: '$DEPLOY_TARGET == "staging"' - if: '$DEPLOY_TARGET == "staging" '
when: on_success when: on_success
- when: never - when: never