Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 02d8e63a98 | |||
| f5f8546bcf | |||
| 54b5850835 | |||
| 5fee51ec7d | |||
| cbed44b1e9 | |||
| 1339ab4149 | |||
| fef1536e1b | |||
| 46a1cf3c08 | |||
| 18c8fc82c9 | |||
| f48bf02152 | |||
| 6d0aefca5b | |||
| de6a526560 | |||
| a0e2582cf1 | |||
| e931b9d1c9 | |||
| b21a7d60dc | |||
| d34c39927d | |||
| a303f63a5e | |||
| a962c996c1 | |||
| e5f926b55f | |||
| 8c3ce050f5 | |||
| c4ab64391d |
@@ -0,0 +1,40 @@
|
|||||||
|
name: ci
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- '**'
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
ci:
|
||||||
|
name: vet, staticcheck, test, build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: golang:1.26-alpine
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Install tools
|
||||||
|
run: apk add --no-cache git
|
||||||
|
|
||||||
|
- name: Checkout
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
SERVER_URL: ${{ github.server_url }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
REF: ${{ github.ref_name }}
|
||||||
|
run: |
|
||||||
|
git clone --depth 1 --branch "$REF" \
|
||||||
|
"$(echo "$SERVER_URL" | sed "s|https://|https://oauth2:${TOKEN}@|")/${REPO}.git" .
|
||||||
|
|
||||||
|
- name: vet
|
||||||
|
run: go vet ./...
|
||||||
|
|
||||||
|
- name: staticcheck
|
||||||
|
run: go tool staticcheck ./...
|
||||||
|
|
||||||
|
- name: test
|
||||||
|
run: go test ./...
|
||||||
|
|
||||||
|
- name: build
|
||||||
|
run: go build ./cmd/glint/...
|
||||||
+140
-1
@@ -5,7 +5,146 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||||
This project uses [Semantic Versioning](https://semver.org).
|
This project uses [Semantic Versioning](https://semver.org).
|
||||||
|
|
||||||
## [Unreleased]
|
## [0.2.19] - 2026-06-14
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **`.glint.yml` project config file** — glint now searches for a `.glint.yml` file starting from the pipeline file's directory and walking up to the first `.git` boundary. Supported keys:
|
||||||
|
- `ignore: [GL007, GL032]` — suppress rules globally for the project.
|
||||||
|
- `severity: {GL004: warning}` — override rule severity (`error`, `warning`, or `ignore`). `ignore` is equivalent to listing the rule in `ignore:`.
|
||||||
|
- `stages: [quality]` — declare extra stage names that are valid beyond those in the pipeline's own `stages:` block; jobs in these stages are not flagged by GL004.
|
||||||
|
- `token: glpat-xxx` — default GitLab personal access token (lower priority than the `--token` flag and `GITLAB_TOKEN` env var).
|
||||||
|
- `url: https://gitlab.example.com` — default GitLab instance URL.
|
||||||
|
- `cache_dir: ~/.cache/glint` — default cache directory for fetched remote includes.
|
||||||
|
|
||||||
|
- **Inline suppression comments** — a `# glint: ignore RULE` comment placed immediately before a job definition suppresses that rule for the specific job. Multiple rules can be comma- or space-separated (`# glint: ignore GL007, GL032`). Use `# glint: ignore all` to suppress every finding for the job. Suppressions are scoped to the annotated job; pipeline-level findings are unaffected.
|
||||||
|
|
||||||
|
## [0.2.18] - 2026-06-14
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **`--format json`** — `glint check` can now emit a structured JSON report instead of plain text. The schema (version `1`) includes `glint_version`, `pipeline`, a `findings` array (each finding has `rule`, `severity`, `file`, `line`, `job`, `message`), and a `summary` block (`total`, `errors`, `warnings`). An empty findings array is `[]`, not `null`. In this mode the human-readable summary line is written to stderr so stdout contains only the JSON payload.
|
||||||
|
|
||||||
|
- **`--format sarif`** — emits a SARIF 2.1.0 JSON document (schema `https://json.schemastore.org/sarif-2.1.0.json`). The `runs[0].tool.driver` lists every unique rule ID found in findings; each `result` carries `ruleId`, `level` (`error`/`warning`), `message.text` (including the job name prefix), and `locations[0].physicalLocation` with `artifactLocation.uri` and `region.startLine` (when available). Pipeline-level findings without a file have no `locations` entry. This format is consumed natively by GitHub Code Scanning and GitLab SAST.
|
||||||
|
|
||||||
|
- **`--format junit`** — emits a JUnit XML document compatible with CI test-report artifact parsers (GitLab: `artifacts:reports:junit`; GitHub: upload-artifact + test-reporter). Each finding becomes a `<testcase>` with a `<failure>` child whose `message` and `type` attributes carry the finding details. A clean pipeline produces a single passing `<testcase>` with no `<failure>` element.
|
||||||
|
|
||||||
|
- **`--format github`** — emits GitHub Actions workflow-command annotation lines (`::error file=…,line=…,title=RULE::message` / `::warning …`). GitHub CI renders these as inline comments on the relevant file in pull requests. Pipeline-level findings without a file omit the `file=` parameter.
|
||||||
|
|
||||||
|
- **Format validation** — an unknown `--format` value exits with code 2 and a helpful error message listing the valid formats.
|
||||||
|
|
||||||
|
## [0.2.17] - 2026-06-14
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Recursive include depth limit** — `resolveIncludes` now accepts a `depth` counter and returns a warning when the nesting depth exceeds 100 (matching GitLab's documented limit). This guards against pathologically deep include chains.
|
||||||
|
|
||||||
|
- **Cycle detection for project and component includes** — `project:` and `component:` includes were not previously tracked in the `visited` map (only `local:` and `remote:` were). They are now registered before recursing into their sub-includes, preventing cross-file include cycles from causing infinite loops.
|
||||||
|
|
||||||
|
- **`include: inputs:` substitution** — when a `component:` include entry has a `with:` block, all `$[[ inputs.KEY ]]` and `$[[ inputs.KEY | default('…') ]]` placeholders in the fetched template YAML are substituted with the corresponding values before parsing. Supported default value forms: `'single quoted'`, `"double quoted"`, bare booleans (`true`/`false`), and bare integers. Missing keys without a default become empty strings. This replaces the previous behaviour of leaving `$[[…]]` tokens in the YAML, which caused false-positive lint findings.
|
||||||
|
|
||||||
|
- **Include cache (`--cache-dir`)** — pass `--cache-dir DIR` to `glint check` or `glint graph` to persist fetched remote templates (both `project:` and `component:` includes and `remote:` URLs) to a local directory. Cache entries are keyed by the SHA-256 of the full request coordinates (base URL + project + path + ref). The directory is created automatically on first use.
|
||||||
|
|
||||||
|
- **Offline mode (`--offline`)** — pass `--offline` to skip all network calls. Remote includes not present in the cache are surfaced as warnings (same UX as "no token"). When `--offline` is set without an explicit `--cache-dir`, the default platform cache directory (`$XDG_CACHE_HOME/glint` or `~/.cache/glint`) is used automatically.
|
||||||
|
|
||||||
|
## [0.2.16] - 2026-06-14
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **`services:` validation (GL034)** — the map form of a service entry requires a `name` key; emits an `ERROR` when absent. The optional `alias` field must be a valid DNS label (letters, digits, hyphens, and dots; must start and end with an alphanumeric character); invalid aliases emit an `ERROR`.
|
||||||
|
|
||||||
|
- **`rules:changes` / `rules:exists` absolute path detection (GL035)** — emits a `WARNING` when a path in `rules:changes:` or `rules:exists:` starts with `/`. GitLab CI evaluates these paths relative to the repository root, so absolute paths can never match. Applies to both the list form and the `{paths: …}` map form.
|
||||||
|
|
||||||
|
- **`timeout:` format validation (GL036)** — emits an `ERROR` when a job's `timeout:` (or the pipeline-level `default.timeout:`) is not a valid GitLab CI duration string. Valid formats: `30m`, `1h 30m`, `90 minutes`, `2 hours 30 minutes`, `1 day`, etc.
|
||||||
|
|
||||||
|
- **`id_tokens:` `aud` validation (GL037)** — emits an `ERROR` for each OIDC token entry in `id_tokens:` that is missing the required `aud` key. GitLab returns an API error at pipeline start when `aud` is absent.
|
||||||
|
|
||||||
|
- **`secrets:` provider validation (GL038)** — emits an `ERROR` for each secret entry in `secrets:` that does not declare a provider key (`vault`, `gcp_secret_manager`, or `azure_key_vault`).
|
||||||
|
|
||||||
|
- **`pages:` keyword + `artifacts.paths` consistency (GL039)** — emits a `WARNING` when a job uses the `pages:` keyword but `artifacts.paths` does not include the publish directory (default `public`, or the value of `pages.publish`). GitLab Pages will not deploy unless the publish directory is listed as an artifact.
|
||||||
|
|
||||||
|
- **Duplicate stage names (GL040)** — emits a `WARNING` when a stage name appears more than once in the top-level `stages:` list. GitLab silently merges duplicate stage entries, which can produce confusing pipeline ordering.
|
||||||
|
|
||||||
|
- **`cache.key.files` glob detection (GL041)** — emits a `WARNING` when an entry in `cache.key.files` contains glob metacharacters (`*`, `?`, `[`). The `files` field requires exact file paths; GitLab does not expand globs there.
|
||||||
|
|
||||||
|
## [0.2.15] - 2026-06-13
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Static reachability check (GL033)** — warns when every rule in a job's `rules:` block has an explicit `when: never`, making the job permanently excluded from any pipeline run. This is a purely static claim: no matter which `if:` condition evaluates to true, the outcome is always "skip"; and if no rule matches, the implicit fallback is also skip. No expression evaluation or context is required. The finding is a `WARNING` (may be intentional as a "disabled job" pattern). Only jobs where every rule has the literal `when: never` value are flagged; rules with no `when:` (default `on_success`), `when: manual`, `when: always`, or `when: on_failure` are not.
|
||||||
|
|
||||||
|
## [0.2.14] - 2026-06-13
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **`--version` / `-v` flag** — `glint --version`, `glint -v`, and `glint version` all print the compiled version string (e.g. `glint v0.2.14`). The version is also shown at the top of every `--help` output (global, `check --help`, `graph --help`). The version is injected at build time via `-ldflags "-X main.version=..."` using `git describe --tags --always --dirty`.
|
||||||
|
|
||||||
|
- **Sorted findings output** — `Lint` now returns findings sorted by `(File, Line, Rule)`. All issues from the same source file appear together in ascending line order; pipeline-level findings with no file location sort first. Previously findings were emitted in map-iteration order (non-deterministic).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Warning format consistency** — include-resolution warnings, extends-chain warnings, and the workflow non-start warning now use the same ruff-style `path: [warning] message` format as lint findings instead of the old `[WARNING] …` prefix with no file context.
|
||||||
|
|
||||||
|
- **Workflow rule permissive evaluation** — workflow `rules:if:` expressions are now evaluated in strict mode: an expression that cannot be fully parsed returns `false` (skip this rule, try the next) instead of `true` (match everything). Previously, a complex or partially-unsupported condition on the first workflow rule would match every context, blocking all subsequent rules and injecting the wrong variables. Job rules retain permissive evaluation (`true` on parse failure) to avoid silently dropping jobs.
|
||||||
|
|
||||||
|
- **Single `=` operator in `rules:if:`** — a bare `=` not followed by `=` or `~` is now accepted as an alias for `==`. This is a common mistake in GitLab CI YAML; previously it caused a parse failure and triggered the permissive fallback.
|
||||||
|
|
||||||
|
- **Source location lost through `extends:` resolution** — when a job was resolved via `extends:`, the merged definition was re-encoded and re-decoded as a fresh `model.Job` struct, which does not carry `File` or `Line` (they are not YAML keys). Those fields are now explicitly copied back from the original job before replacing it in `p.Jobs`, so extended jobs report the correct source file and line number in findings.
|
||||||
|
|
||||||
|
## [0.2.13] - 2026-06-12
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Variable expansion (`$VAR` / `${VAR}`)** — variable values that reference other variables are now expanded in the effective context after all sources are merged. Transitive chains (`A=$B`, `B=$C`) are resolved over up to ten passes; circular references are left as-is. The expanded values are visible in `--list-vars` output under "Effective context variables" and are used when evaluating `rules:if:` expressions.
|
||||||
|
|
||||||
|
- **Non-string scalar variables (`bool`, `int`, `float64`)** — variables declared with bare `true`/`false` or integer values (e.g. `BUILD: true`, `RETRIES: 3`) are now handled correctly in all variable processing paths. Previously they were rendered as `(complex)` in `--list-vars` output and silently dropped from the effective context; they now render and inject as their string equivalents (`"true"`, `"3"`), matching GitLab CI's own behaviour where all variable values are strings.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **YAML `\/` escape in double-quoted strings** — regex patterns containing `\/` (escaped forward-slash) in double-quoted `if:` expressions (e.g. `$CI_COMMIT_BRANCH =~ /^us\//`) caused a YAML parse error (`found unknown escape character`) with `gopkg.in/yaml.v3`, which does not implement this YAML 1.2 escape. The parser now preprocesses the raw bytes before unmarshalling: inside double-quoted strings, `\/` is rewritten to `\\/`, which `yaml.v3` parses as a literal backslash followed by a slash — preserving the regex intent.
|
||||||
|
|
||||||
|
## [0.2.11] - 2026-06-12
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Ruff-style finding output** — findings now follow the `file:line: RULEID [severity] message` format (e.g. `.gitlab-ci.yml:14: GL004 [error] job "deploy": stage "production" is not defined in 'stages'`), matching the output convention used by [ruff](https://docs.astral.sh/ruff/) and other modern linters. Job-scoped findings prefix the message with `job "name": `; pipeline-level findings omit the job prefix. Severity is lowercase inside brackets (`[error]`, `[warning]`).
|
||||||
|
|
||||||
|
- **Implicit default context (`--branch main --source push`)** — when `glint check` or `glint graph` is invoked without any of `--branch`, `--tag`, `--source`, or `--var`, the context now defaults to `--branch main --source push` so that `rules:if:` expressions are always evaluated. Previously the context was empty and no rule evaluation occurred. Any explicit context flag bypasses the defaults entirely.
|
||||||
|
|
||||||
|
- **`--list-vars` debug flag** — available on both `glint check` and `glint graph`; prints all pipeline-level variables collected from the root file and every included file (sorted `KEY=VALUE`) to stderr, then continues normally. When a context is active, also prints the effective merged variable set (pipeline defaults + workflow-rule variables + CLI flags). Useful for diagnosing GL032 false positives.
|
||||||
|
|
||||||
|
- **Included-file variables now visible to all lint rules** — `variables:` blocks declared in included files (local, remote, project, and component includes) are now merged into the pipeline's variable namespace before linting. This eliminates false-positive GL032 warnings for variables declared in shared CI templates. Root-pipeline variables take precedence over included-file variables when the same key appears in both (matching GitLab's own override behaviour).
|
||||||
|
|
||||||
|
- **Variable reference validation (GL032)** — glint now warns when a `rules:if:` expression references a variable (`$VAR` or `${VAR}`) that is not declared anywhere in the pipeline YAML: pipeline-level `variables:`, the job's own `variables:`, or any `workflow:rules:variables:` block. Predefined GitLab CI variable namespaces (`CI_*`, `GITLAB_*`, `FF_*`, `RUNNER_*`, `TRIGGER_*`, `CHAT_*`) are exempt. Because variables can also be set in GitLab CI/CD project settings (invisible to glint), the finding is a `[WARNING]` rather than an error. Each undeclared variable is reported at most once per job to keep the output concise.
|
||||||
|
|
||||||
|
- **Structured rule IDs** — every finding now carries a stable `GL###` identifier (e.g. `GL003`) that appears in the output alongside the location and message: `.gitlab-ci.yml:14: GL003 [error] job "deploy": missing required field 'script'`. IDs are assigned per check function across 31 rules (GL001–GL031) and are stable across versions. The `linter.Finding` struct exposes the ID as a `Rule string` field for programmatic consumers. The README lint rules table is updated with ID columns.
|
||||||
|
|
||||||
|
- **`workflow:rules:variables:` now propagate to job rule evaluation** — when a `workflow:rules:` entry matches, any `variables:` it defines are injected into the evaluation context so job `rules:if:` expressions can reference them. Pipeline-level `variables:` are also available as defaults (lower priority). Variable priority order, highest first: `--var` CLI overrides → `--branch`/`--tag`/`--source` shortcuts → workflow-rule variables → pipeline-level variable defaults. This means `$DEPLOY_TARGET == "production"` in a job rule correctly evaluates when a workflow rule sets `DEPLOY_TARGET: production` for the matching branch. The `glint graph tree` command benefits from the same enrichment.
|
||||||
|
|
||||||
|
- **`rules:if:` expression evaluator improvements** — six correctness fixes to the GitLab CI expression parser:
|
||||||
|
- **Multi-line expressions** — newlines (`\n`, `\r`) are now treated as whitespace between tokens, so block-scalar `if:` values (e.g. `if: | ...`) and folded YAML scalars with `||`/`&&` on a continuation line are parsed correctly instead of falling back to permissive `true`.
|
||||||
|
- **`${VAR}` curly-brace variable syntax** — `${CI_COMMIT_BRANCH}` is now equivalent to `$CI_COMMIT_BRANCH` everywhere a value is expected.
|
||||||
|
- **Regex flags** — `/pattern/i`, `/pattern/m`, `/pattern/s` are now honoured; the `i` flag (case-insensitive) is translated to Go's `(?i)` prefix before compiling. Unknown flags are silently ignored.
|
||||||
|
- **Variable as regex RHS** — `$BRANCH =~ $PATTERN` where `$PATTERN` holds a `/regex/[flags]` string is now evaluated by extracting and compiling the pattern from the variable's value; if the value is empty or does not look like a regex literal the expression falls back to permissive `true`.
|
||||||
|
- **`true` / `false` keywords** — bare `true` and `false` (without quotes) are now recognised as the string values `"true"` and `"false"`, matching GitLab CI's own behaviour. `$GATEWAY_ENABLED == true` and `$FEATURE_FLAG == false` now evaluate correctly.
|
||||||
|
- **Integer literals** — bare integers (e.g. `$PARALLEL == 4`, `$ENABLED == 1`, `$DISABLED == 0`) are now parsed as their decimal string representations and compared accordingly.
|
||||||
|
|
||||||
|
- **File and line numbers on findings** — every finding now includes the source file and line where the job is defined, e.g. `[ERROR] job "deploy" (src/deploy.yml:14): …`. For jobs that come from local or fetched includes the file reflects the include source. Pipeline-level findings (workflow rules, missing stages) reference the root pipeline file.
|
||||||
|
|
||||||
|
- **`glint graph includes` shows jobs per file** — each node in the Mermaid include dependency graph now shows the jobs defined directly in that file. Jobs are rendered as rounded nodes (`(name)`) in a distinct light-purple style, connected with dashed arrows (`-.->`) to distinguish ownership from the include hierarchy (solid `-->` arrows). The root pipeline file always shows its direct jobs; local and fetched project/component nodes show theirs when the file can be read.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **`include: remote:` URL includes are now fetched and merged** — glint fetches plain HTTPS URLs in `include: remote:` entries (no authentication), parses the resulting YAML, merges its jobs into the pipeline, and recursively resolves any sub-includes the remote file itself declares. Unreachable or unparseable URLs emit a `[WARNING]` and lint continues on the rest of the pipeline. The `glint graph includes` command now expands remote nodes with their jobs and sub-include tree, matching the behaviour of local and project includes.
|
||||||
|
|
||||||
|
- **`needs: optional: true` downgraded to warning** — a `needs:` entry that carries `optional: true` and references a job not present in the pipeline now emits `[WARNING]` instead of `[ERROR]`. GitLab CI silently skips such dependencies at runtime (the job is absent when its include was not triggered), so the finding was a false positive. Non-optional missing needs remain errors. Optional missing deps are also excluded from the cycle-detection graph.
|
||||||
|
|
||||||
|
- **`extends:` jobs with missing script downgraded to warning** — a job that declares `extends:` but has no `script` after resolution now emits `[WARNING]` instead of `[ERROR]`. The script may legitimately come from a base job in a remote include that could not be fetched at lint time (e.g. no token configured).
|
||||||
|
|
||||||
|
- **Variable map form now parses correctly** — `variables:` entries that use the extended `{value, description, options}` form (GitLab CI 13.7+) no longer cause `yaml: cannot unmarshal !!map into string`. Both `Pipeline.Variables` and per-job `Variables` now accept either plain strings or map-form declarations.
|
||||||
|
- **`default.image` map form now parses correctly** — `default: image: {name: ..., pull_policy: ...}` used to cause `yaml: cannot unmarshal !!map into string`; `DefaultConfig.Image` is now typed as `any` to match `Job.Image`.
|
||||||
|
- **`default.before_script` / `default.after_script` now accept both list and scalar forms** — previously `DefaultConfig.BeforeScript` and `DefaultConfig.AfterScript` were `[]string`, causing a parse error when the field was written as a block scalar string. They are now typed as `any` to match the corresponding `Job` fields.
|
||||||
|
- **`rules.changes` / `rules.exists` map form now parses correctly** — extended `changes: {paths: [...], compare_to: "..."}` syntax (GitLab CI 15.3+) used to cause `yaml: cannot unmarshal !!map into []string`.
|
||||||
|
|
||||||
## [0.2.0] - 2026-06-11
|
## [0.2.0] - 2026-06-11
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
# glint
|
<p align="center">
|
||||||
|
<img src="assets/glint-logo.png" alt="glint logo" width="220" />
|
||||||
|
</p>
|
||||||
|
|
||||||
[](LICENSE)
|
<h1 align="center">glint</h1>
|
||||||
[](CHANGELOG.md)
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-blue.svg" alt="License"></a>
|
||||||
|
<a href="CHANGELOG.md"><img src="https://img.shields.io/badge/release-v0.2.19-blue.svg" alt="Release"></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
> **Disclaimer:** This tool was built through iterative AI-assisted development with [Claude](https://claude.ai). It is experimental, incomplete, and not intended for production use. Coverage of GitLab CI keywords is best-effort and may lag behind GitLab's evolving spec. Use it at your own discretion — no correctness guarantees are made. Contributions and bug reports are welcome.
|
> **Disclaimer:** This tool was built through iterative AI-assisted development with [Claude](https://claude.ai). It is experimental, incomplete, and not intended for production use. Coverage of GitLab CI keywords is best-effort and may lag behind GitLab's evolving spec. Use it at your own discretion — no correctness guarantees are made. Contributions and bug reports are welcome.
|
||||||
|
|
||||||
@@ -19,8 +25,29 @@ A local tool to validate and lint `.gitlab-ci.yml` pipelines without needing a G
|
|||||||
- **CI/CD catalog components** — resolves `include: component:` references from the GitLab CI/CD Catalog; public components work without a token
|
- **CI/CD catalog components** — resolves `include: component:` references from the GitLab CI/CD Catalog; public components work without a token
|
||||||
- **Deprecation warnings** — flags `only`/`except` usage in favour of `rules`
|
- **Deprecation warnings** — flags `only`/`except` usage in favour of `rules`
|
||||||
- **Local include resolution** — `include: local:` entries are read from disk and recursively merged before linting, so multi-file pipelines are fully validated
|
- **Local include resolution** — `include: local:` entries are read from disk and recursively merged before linting, so multi-file pipelines are fully validated
|
||||||
|
- **Extended variable declarations** — `variables:` entries may use the `{value, description, options}` map form (GitLab CI 13.7+); `default.image` accepts both string and map form; `rules.changes`/`rules.exists` accept both list and `{paths, compare_to}` map form
|
||||||
- **Graph output** — `glint graph` prints a job tree (stages → jobs) to the terminal; `glint graph includes` emits a Mermaid include dependency diagram; `glint graph pipeline` renders a GitLab CI-style PNG/SVG
|
- **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
|
||||||
|
- **`.glint.yml` project config** — rule suppression (`ignore: [GL007]`), severity overrides (`severity: {GL004: warning}`), extra stages allowlist (`stages: [quality]`), and default token/URL/cache-dir so flags are not needed on every invocation
|
||||||
|
- **Inline suppression comments** — `# glint: ignore GL007` (or `# glint: ignore all`) immediately before a job definition suppresses the specified rule(s) for that job without touching other jobs
|
||||||
|
- **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.
|
||||||
|
|
||||||
@@ -63,6 +90,112 @@ 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'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Project configuration (`.glint.yml`)
|
||||||
|
|
||||||
|
Place a `.glint.yml` file next to your pipeline (or anywhere in the directory tree up to the repository root) to configure glint for that project. glint searches upward from the pipeline file's directory, stopping at the first `.git` boundary.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# .glint.yml
|
||||||
|
|
||||||
|
# Suppress specific rules entirely.
|
||||||
|
ignore:
|
||||||
|
- GL007 # we still use only:/except:, migration in progress
|
||||||
|
- GL032 # lots of dynamic variables injected by CI
|
||||||
|
|
||||||
|
# Override the severity of specific rules.
|
||||||
|
severity:
|
||||||
|
GL004: warning # demote stage errors to warnings during a migration
|
||||||
|
GL035: error # promote absolute-path warning to error for this project
|
||||||
|
|
||||||
|
# Extra stages that are valid but not declared in the pipeline YAML itself
|
||||||
|
# (e.g. injected by an include template we can't edit).
|
||||||
|
stages:
|
||||||
|
- quality
|
||||||
|
- security
|
||||||
|
|
||||||
|
# Default token — overridden by --token flag and GITLAB_TOKEN env.
|
||||||
|
token: glpat-xxxx
|
||||||
|
|
||||||
|
# Default GitLab instance URL.
|
||||||
|
url: https://gitlab.example.com
|
||||||
|
|
||||||
|
# Default cache directory for fetched remote includes.
|
||||||
|
cache_dir: ~/.cache/glint
|
||||||
|
```
|
||||||
|
|
||||||
|
**Priority chain for token and URL:** `--token`/`--gitlab-url` flags > `.glint.yml` values > `GITLAB_TOKEN`/`CI_SERVER_URL` environment variables.
|
||||||
|
|
||||||
|
### Inline suppression (`# glint: ignore`)
|
||||||
|
|
||||||
|
Suppress a finding for a specific job by placing a `# glint: ignore RULE` comment immediately before the job definition:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# glint: ignore GL007
|
||||||
|
legacy-job:
|
||||||
|
stage: build
|
||||||
|
only:
|
||||||
|
- main
|
||||||
|
script: echo ok
|
||||||
|
|
||||||
|
# Multiple rules — comma- or space-separated:
|
||||||
|
# glint: ignore GL007, GL032
|
||||||
|
another-job:
|
||||||
|
stage: build
|
||||||
|
script: echo ok
|
||||||
|
|
||||||
|
# Suppress all rules for this job:
|
||||||
|
# glint: ignore all
|
||||||
|
noisy-job:
|
||||||
|
stage: build
|
||||||
|
script: echo ok
|
||||||
|
```
|
||||||
|
|
||||||
|
Inline suppressions are scoped to the single job they precede. They do not affect other jobs or pipeline-level findings. For project-wide suppression use `.glint.yml` `ignore:`.
|
||||||
|
|
||||||
### 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.
|
||||||
@@ -85,6 +218,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.
|
||||||
@@ -193,6 +344,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.
|
||||||
@@ -209,8 +361,12 @@ Rules without an `if:` clause always match.
|
|||||||
### Example output
|
### Example output
|
||||||
|
|
||||||
```
|
```
|
||||||
# Clean pipeline, no context
|
# Clean pipeline (implicit default: --branch main --source push)
|
||||||
OK: .gitlab-ci.yml — no issues found (5 jobs, 3 stages)
|
Context: branch=main, source=push
|
||||||
|
|
||||||
|
Active (5): build, deploy-staging, test, ...
|
||||||
|
|
||||||
|
OK: .gitlab-ci.yml — no issues found (5 job(s), 3 stage(s))
|
||||||
|
|
||||||
# With --branch develop context
|
# With --branch develop context
|
||||||
Context: branch=develop, source=push
|
Context: branch=develop, source=push
|
||||||
@@ -218,7 +374,7 @@ Context: branch=develop, source=push
|
|||||||
Active (3): build, deploy-staging, test
|
Active (3): build, deploy-staging, test
|
||||||
Skipped (2): deploy-prod, release-notes
|
Skipped (2): deploy-prod, release-notes
|
||||||
|
|
||||||
OK: .gitlab-ci.yml — no issues found (5 jobs, 3 stages)
|
OK: .gitlab-ci.yml — no issues found (5 job(s), 3 stage(s))
|
||||||
|
|
||||||
# With --tag v1.0.0 context
|
# With --tag v1.0.0 context
|
||||||
Context: tag=v1.0.0, source=push
|
Context: tag=v1.0.0, source=push
|
||||||
@@ -226,75 +382,86 @@ Context: tag=v1.0.0, source=push
|
|||||||
Active (4): build, deploy-prod, release-notes, test
|
Active (4): build, deploy-prod, release-notes, test
|
||||||
Skipped (1): deploy-staging
|
Skipped (1): deploy-staging
|
||||||
|
|
||||||
OK: .gitlab-ci.yml — no issues found (5 jobs, 3 stages)
|
OK: .gitlab-ci.yml — no issues found (5 job(s), 3 stage(s))
|
||||||
|
|
||||||
# Pipeline with issues
|
# Pipeline with issues
|
||||||
[ERROR] job "deploy": stage "production" is not defined in 'stages'
|
.gitlab-ci.yml:14: GL004 [error] job "deploy": stage "production" is not defined in 'stages'
|
||||||
[ERROR] job "test": needs unknown job "build-app"
|
.gitlab-ci.yml:22: GL027 [error] job "test": needs unknown job "build-app"
|
||||||
[WARNING] job "old-job": 'only'/'except' are deprecated; prefer 'rules'
|
.gitlab-ci.yml:31: GL007 [warning] job "old-job": 'only'/'except' are deprecated; prefer 'rules'
|
||||||
|
|
||||||
3 finding(s): 2 error(s)
|
3 finding(s): 2 error(s)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Lint rules
|
## Lint rules
|
||||||
|
|
||||||
|
Every finding includes a stable rule ID (e.g. `GL003`) that can be used to filter output or reference a specific check in documentation.
|
||||||
|
|
||||||
### Pipeline-level
|
### Pipeline-level
|
||||||
|
|
||||||
| Severity | Rule |
|
| ID | Severity | Rule |
|
||||||
|----------|------|
|
|----|----------|------|
|
||||||
| ERROR | `workflow.rules[*].when` is not `always` or `never` |
|
| GL002 | ERROR | `workflow.rules[*].when` is not `always` or `never` |
|
||||||
| WARNING | No `stages` defined (GitLab falls back to default stages) |
|
| GL001 | WARNING | No `stages` defined (GitLab falls back to default stages) |
|
||||||
|
| 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
|
||||||
|
|
||||||
| Severity | Rule |
|
| ID | Severity | Rule |
|
||||||
|----------|------|
|
|----|----------|------|
|
||||||
| ERROR | Job is missing required `script` (or `run`) — non-trigger, non-template jobs |
|
| GL003 | ERROR | Job is missing required `script` (or `run`) — non-trigger, non-template jobs |
|
||||||
| ERROR | Job references a `stage` not declared in `stages` |
|
| GL004 | ERROR | Job references a `stage` not declared in `stages` |
|
||||||
| ERROR | `only` and `rules` used together on the same job |
|
| GL005 | ERROR | `only` and `rules` used together on the same job |
|
||||||
| ERROR | `except` and `rules` used together on the same job |
|
| GL006 | ERROR | `except` and `rules` used together on the same job |
|
||||||
| WARNING | `only`/`except` used (deprecated, prefer `rules`) |
|
| GL007 | WARNING | `only`/`except` used (deprecated, prefer `rules`) |
|
||||||
|
|
||||||
### Job-level — keyword constraints
|
### Job-level — keyword constraints
|
||||||
|
|
||||||
| Severity | Rule |
|
| ID | Severity | Rule |
|
||||||
|----------|------|
|
|----|----------|------|
|
||||||
| ERROR | `when` is not one of `on_success`, `on_failure`, `always`, `manual`, `delayed`, `never` |
|
| GL008 | ERROR | `when` is not one of `on_success`, `on_failure`, `always`, `manual`, `delayed`, `never` |
|
||||||
| ERROR | `when: delayed` without `start_in` |
|
| GL009 | ERROR | `when: delayed` without `start_in` |
|
||||||
| ERROR | `start_in` set but `when` is not `delayed` |
|
| GL010 | ERROR | `start_in` set but `when` is not `delayed` |
|
||||||
| ERROR | `parallel` integer not in range 2–200 |
|
| GL011 | ERROR | `parallel` integer not in range 2–200, or map form missing `matrix` key |
|
||||||
| ERROR | `parallel` map form missing `matrix` key |
|
| GL012 | ERROR | `retry` integer not in range 0–2, or `retry.max` out of range |
|
||||||
| ERROR | `retry` integer not in range 0–2 |
|
| GL013 | ERROR | `retry.when` contains an unrecognised failure type |
|
||||||
| ERROR | `retry.max` not in range 0–2 |
|
| GL014 | ERROR | `allow_failure` is not a boolean or a map with `exit_codes` |
|
||||||
| ERROR | `retry.when` contains an invalid failure type |
|
| GL015 | ERROR | `interruptible` is not a boolean |
|
||||||
| ERROR | `allow_failure` is not a boolean or a map with `exit_codes` |
|
| GL016 | ERROR | `trigger` job also has `script` |
|
||||||
| ERROR | `interruptible` is not a boolean |
|
| GL017 | ERROR | `trigger` map missing `project` or `include` |
|
||||||
| ERROR | `trigger` job also has `script` |
|
| GL018 | ERROR | `coverage` is not a regex pattern wrapped in `/` |
|
||||||
| ERROR | `trigger` map missing `project` or `include` |
|
| GL019 | ERROR | `release` missing required `tag_name`, or is not a map |
|
||||||
| ERROR | `coverage` is not a regex pattern wrapped in `/` |
|
| GL020 | ERROR | `environment.url` set without `environment.name`, or invalid `environment.action` |
|
||||||
| ERROR | `release` missing required `tag_name` |
|
| GL021 | ERROR | `artifacts.when` invalid, or `artifacts.expose_as` set without `artifacts.paths` |
|
||||||
| ERROR | `environment.url` set without `environment.name` |
|
| GL022 | WARNING | `pages` job `artifacts.paths` does not include `public` |
|
||||||
| ERROR | `environment.action` is not one of `start`, `stop`, `prepare`, `verify`, `access` |
|
| GL023 | ERROR | `cache.when` or `cache.policy` has an invalid value |
|
||||||
| ERROR | `artifacts.when` is not `on_success`, `on_failure`, or `always` |
|
| GL024 | ERROR | `rules[*].when` is not one of the valid `when` values |
|
||||||
| ERROR | `artifacts.expose_as` set without `artifacts.paths` |
|
| GL025 | ERROR | `image` map form missing `name` key |
|
||||||
| ERROR | `cache.when` is not `on_success`, `on_failure`, or `always` |
|
| GL026 | ERROR | `inherit.default` / `inherit.variables` is not a boolean or list |
|
||||||
| ERROR | `cache.policy` is not `pull`, `push`, or `pull-push` |
|
| GL034 | ERROR | `services:` map form missing `name`, or `alias` is not a valid DNS label |
|
||||||
| ERROR | `rules[*].when` is not one of the valid `when` values |
|
| GL036 | ERROR | `timeout:` is not a valid GitLab CI duration string (e.g. `1h 30m`, `90 minutes`) |
|
||||||
| ERROR | `image` map form missing `name` key |
|
| GL037 | ERROR | `id_tokens:` entry is missing the required `aud` key |
|
||||||
| ERROR | `inherit.default` / `inherit.variables` is not a boolean or list |
|
| GL038 | ERROR | `secrets:` entry is missing a provider key (`vault`, `gcp_secret_manager`, or `azure_key_vault`) |
|
||||||
| WARNING | `pages` job `artifacts.paths` does not include `public` |
|
| 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
|
||||||
|
|
||||||
| Severity | Rule |
|
| ID | Severity | Rule |
|
||||||
|----------|------|
|
|----|----------|------|
|
||||||
| ERROR | `needs:` references a job that does not exist |
|
| GL027 | ERROR/WARNING | `needs:` references a job that does not exist (WARNING when `optional: true`) |
|
||||||
| ERROR | `needs:` references a job in a later stage |
|
| GL028 | ERROR | `needs:` references a job in a later stage |
|
||||||
| ERROR | Circular dependency detected in `needs:` graph |
|
| GL029 | ERROR | Circular dependency detected in `needs:` graph |
|
||||||
| ERROR | `dependencies:` references a job that does not exist |
|
| GL030 | ERROR | `dependencies:` references a job that does not exist |
|
||||||
| ERROR | `dependencies:` references a job in the same or a later stage |
|
| GL031 | ERROR | `dependencies:` references a job in the same or a later stage |
|
||||||
| WARNING | `extends:` references an unknown base job (resolver warning; extends chain skipped for that job) |
|
|
||||||
| ERROR | Cycle detected in `extends:` graph |
|
### Expression validation
|
||||||
|
|
||||||
|
| ID | Severity | Rule |
|
||||||
|
|----|----------|------|
|
||||||
|
| GL032 | WARNING | `rules:if:` references `$VAR` not declared in `variables:` (pipeline, job, or `workflow:rules:variables:`) — may be a false positive for variables set in GitLab CI/CD project settings |
|
||||||
|
| 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)
|
||||||
|
|
||||||
|
|||||||
+69
-30
@@ -4,7 +4,7 @@ This document tracks planned improvements to `glint`. Items are grouped by theme
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Context-aware validation — ✓ single-context shipped in v0.2.0
|
## Context-aware validation — ✓ single-context shipped in v0.2.0; expression evaluator hardened post-v0.2.0; implicit defaults and --list-vars shipped v0.2.11; variable expansion and scalar handling shipped v0.2.13; workflow evaluation and output fixes shipped v0.2.14
|
||||||
|
|
||||||
Single-context simulation is fully implemented. Pass `--branch`, `--tag`, `--source`, or `--var` to either `glint check` or `glint graph`; jobs are evaluated and shown as active / manual / skipped.
|
Single-context simulation is fully implemented. Pass `--branch`, `--tag`, `--source`, or `--var` to either `glint check` or `glint graph`; jobs are evaluated and shown as active / manual / skipped.
|
||||||
|
|
||||||
@@ -16,6 +16,35 @@ glint check --source merge_request_event --var CI_MERGE_REQUEST_TARGET_BRANCH_NA
|
|||||||
glint graph tree --branch main .gitlab-ci.yml # tree annotated with [skipped] / [manual]
|
glint graph tree --branch main .gitlab-ci.yml # tree annotated with [skipped] / [manual]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Shipped post-v0.2.0 (unreleased)**
|
||||||
|
|
||||||
|
- ✓ **`workflow:rules:variables:` propagation** — variables defined on the matching `workflow:rules:` entry are injected into the evaluation context before job `rules:if:` expressions are evaluated. Pipeline-level `variables:` defaults are also available. Priority chain (highest wins): `--var` > shortcuts > workflow-rule vars > pipeline defaults.
|
||||||
|
- ✓ **Expression evaluator: multi-line expressions** — newlines in block-scalar and folded YAML `if:` values are now treated as whitespace; `||` / `&&` on a continuation line evaluate correctly.
|
||||||
|
- ✓ **Expression evaluator: `${VAR}` curly-brace syntax** — `${CI_COMMIT_BRANCH}` is equivalent to `$CI_COMMIT_BRANCH` everywhere.
|
||||||
|
- ✓ **Expression evaluator: regex flags** — `/pattern/i`, `/pattern/m`, `/pattern/s` are now supported; `i` maps to `(?i)` in Go's regexp.
|
||||||
|
- ✓ **Expression evaluator: variable as regex RHS** — `$BRANCH =~ $PATTERN` where `$PATTERN` holds a `/regex/` string is evaluated correctly.
|
||||||
|
- ✓ **Expression evaluator: bare `true` / `false` keywords** — treated as the strings `"true"` / `"false"` matching GitLab CI's own behaviour; `$GATEWAY_ENABLED == true` now evaluates correctly.
|
||||||
|
- ✓ **Expression evaluator: integer literals** — `$COUNT == 4`, `$ENABLED == 1`, `$DISABLED == 0` compare as decimal strings.
|
||||||
|
|
||||||
|
~~**Implicit default context**~~ — ✓ shipped v0.2.11; `glint check` and `glint graph` default to `--branch main --source push` when no context flag is given, so `rules:if:` expressions are always evaluated out of the box.
|
||||||
|
|
||||||
|
~~**`--list-vars` debug flag**~~ — ✓ shipped v0.2.11; prints sorted `KEY=VALUE` of all collected variables (pipeline YAML + included files + workflow-rule union + effective context) to stderr.
|
||||||
|
|
||||||
|
**Shipped in v0.2.13**
|
||||||
|
|
||||||
|
- ✓ **Variable expansion** — `$VAR` / `${VAR}` references within variable values are expanded after all sources are merged; transitive chains resolve over multiple passes; visible in `--list-vars` effective-context output.
|
||||||
|
- ✓ **Non-string scalar variables** — `BUILD: true`, `RETRIES: 3` and similar bare boolean/integer values now render correctly in `--list-vars` and are injected into the evaluation context as string equivalents; previously shown as `(complex)` and silently dropped.
|
||||||
|
- ✓ **YAML `\/` escape in double-quoted strings** — regex patterns like `/^us\//` in double-quoted `if:` blocks no longer cause a parse error; the raw bytes are preprocessed before YAML unmarshalling.
|
||||||
|
|
||||||
|
**Shipped in v0.2.14**
|
||||||
|
|
||||||
|
- ✓ **Workflow rule strict evaluation** — workflow `rules:if:` now uses strict mode (parse failure → skip rule, not match); fixes premature matching that blocked later rules and injected wrong variables.
|
||||||
|
- ✓ **Single `=` operator** — `=` is now accepted as an alias for `==` in `rules:if:` expressions, matching common user intent.
|
||||||
|
- ✓ **Source location through `extends:` resolution** — `File` and `Line` are now preserved when a job is rebuilt via extends, so findings reference the correct source location.
|
||||||
|
- ✓ **Sorted findings output** — findings are sorted by `(File, Line, Rule)`; same-file issues group together in line order.
|
||||||
|
- ✓ **Consistent warning format** — all warnings use ruff-style `path: [warning] message` format.
|
||||||
|
- ✓ **`--version` / `-v` flag** — prints compiled version; version also shown at the top of every `--help` output.
|
||||||
|
|
||||||
**Remaining work**
|
**Remaining work**
|
||||||
|
|
||||||
- **Multi-context simulation** — run multiple contexts in one invocation and print a comparison table:
|
- **Multi-context simulation** — run multiple contexts in one invocation and print a comparison table:
|
||||||
@@ -23,7 +52,7 @@ glint graph tree --branch main .gitlab-ci.yml # tree annotated with [skipped]
|
|||||||
glint check --context branch=main --context branch=develop --context tag=v1.0.0 .gitlab-ci.yml
|
glint check --context branch=main --context branch=develop --context tag=v1.0.0 .gitlab-ci.yml
|
||||||
```
|
```
|
||||||
- **Context-scoped linting** — skip `needs:`/`dependencies:` cross-checks for jobs that are statically unreachable in the given context
|
- **Context-scoped linting** — skip `needs:`/`dependencies:` cross-checks for jobs that are statically unreachable in the given context
|
||||||
- **`rules:changes:` evaluation** — path glob evaluation against the local git tree (expression evaluator priority 5)
|
- **`rules:changes:` evaluation** — path glob evaluation against the local git tree
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -31,37 +60,36 @@ glint graph tree --branch main .gitlab-ci.yml # tree annotated with [skipped]
|
|||||||
|
|
||||||
The current rule set covers the most common sources of broken pipelines. These are the gaps most likely to matter in practice.
|
The current rule set covers the most common sources of broken pipelines. These are the gaps most likely to matter in practice.
|
||||||
|
|
||||||
- **Variable reference validation** — warn when a job references `$VAR` (or `${VAR}`) that is not declared anywhere in `variables:`, `default.variables`, or the job itself
|
- ~~**Variable reference validation (GL032)**~~ — ✓ shipped v0.2.11; warns when a `rules:if:` expression references `$VAR` / `${VAR}` not declared anywhere in pipeline YAML; predefined GitLab namespaces (`CI_*`, `GITLAB_*`, …) exempt; variables from included files are also considered
|
||||||
- **`services:` validation** — map form requires `name`; `alias` must be a valid DNS label
|
- ~~**`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
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Include resolution
|
## Include resolution
|
||||||
|
|
||||||
- ~~**`include: local:`** full resolution~~ — ✓ shipped in v0.2.0; local files are read from disk, recursively resolved, and merged before linting
|
- ~~**`include: local:`** full resolution~~ — ✓ shipped in v0.2.0; local files are read from disk, recursively resolved, and merged before linting
|
||||||
- **`include: remote:`** (URL) — fetch and merge plain HTTP/HTTPS URLs (no auth required)
|
- ~~**`include: remote:`** (URL)~~ — ✓ shipped post-v0.2.0; plain HTTPS URLs are fetched (unauthenticated), parsed, and merged; sub-includes are resolved recursively; unreachable URLs emit `[WARNING]` and linting continues
|
||||||
- **Recursive include depth limit** — guard against include cycles across files
|
- ~~**Recursive include depth limit**~~ — ✓ 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
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -70,6 +98,7 @@ Right now the only output is plain-text findings. Structured output enables inte
|
|||||||
The SVG renderer and terminal tree cover the basic layout. These would bring it closer to GitLab's full interactive view.
|
The SVG renderer and terminal tree cover the basic layout. These would bring it closer to GitLab's full interactive view.
|
||||||
|
|
||||||
- ~~**Terminal job tree**~~ — ✓ shipped in v0.2.0 as `glint graph tree`; stages as branches, jobs as leaves, context-aware annotations
|
- ~~**Terminal job tree**~~ — ✓ shipped in v0.2.0 as `glint graph tree`; stages as branches, jobs as leaves, context-aware annotations
|
||||||
|
- ~~**`glint graph includes` shows jobs per file**~~ — ✓ shipped post-v0.2.0; each include node shows the jobs it defines as dashed-arrow rounded nodes in a distinct style
|
||||||
- **Multi-job connector accuracy** — draw one connector per job pair rather than one per stage pair in classic mode, so pipelines with uneven columns look correct
|
- **Multi-job connector accuracy** — draw one connector per job pair rather than one per stage pair in classic mode, so pipelines with uneven columns look correct
|
||||||
- **Job tooltip / detail panel** — embed a hidden `<title>` and `<desc>` per chip so SVG viewers show `stage`, `when`, `image`, and `needs` on hover
|
- **Job tooltip / detail panel** — embed a hidden `<title>` and `<desc>` per chip so SVG viewers show `stage`, `when`, `image`, and `needs` on hover
|
||||||
- **`when: on_failure` visual distinction** — dashed border or distinct icon for failure-path jobs
|
- **`when: on_failure` visual distinction** — dashed border or distinct icon for failure-path jobs
|
||||||
@@ -79,6 +108,20 @@ The SVG renderer and terminal tree cover the basic layout. These would bring it
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Findings quality — ✓ file and line numbers shipped post-v0.2.0; ruff-style format shipped v0.2.11
|
||||||
|
|
||||||
|
~~**File and line numbers on findings**~~ — ✓ shipped post-v0.2.0; every finding includes the source file and exact line of the job key. Works across local includes, remote project templates, and fetched component templates.
|
||||||
|
|
||||||
|
~~**Ruff-style output format**~~ — ✓ shipped v0.2.11; findings follow `file:line: RULEID [severity] message` matching the convention used by ruff and other modern linters.
|
||||||
|
|
||||||
|
**Remaining improvements**
|
||||||
|
|
||||||
|
- ~~**`needs: optional: true` false-positive errors**~~ — ✓ shipped post-v0.2.0; optional missing needs are downgraded to `[WARNING]`
|
||||||
|
- ~~**`extends:` jobs with missing script false errors**~~ — ✓ shipped post-v0.2.0; jobs using `extends:` that have no `script` after resolution emit `[WARNING]` (the script may come from an unfetchable remote base)
|
||||||
|
- **`rules:if:` static reachability** — report when a job's entire `rules:` block can never evaluate to `when: on_success` given the declared pipeline variables (pure static, no context required)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## CI / editor integration
|
## CI / editor integration
|
||||||
|
|
||||||
- **GitLab CI template** — a `.gitlab-ci.yml` snippet that runs `glint` as a pipeline-validation job before the real pipeline executes; publishable to the GitLab CI/CD Catalog
|
- **GitLab CI template** — a `.gitlab-ci.yml` snippet that runs `glint` as a pipeline-validation job before the real pipeline executes; publishable to the GitLab CI/CD Catalog
|
||||||
@@ -89,20 +132,16 @@ The SVG renderer and terminal tree cover the basic layout. These would bring it
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Configuration
|
## Configuration — ✓ shipped v0.2.19
|
||||||
|
|
||||||
- **`.glint.yml` config file** — project-level configuration for:
|
- ~~**`.glint.yml` config file**~~ — ✓ shipped v0.2.19; `ignore:`, `severity:`, `stages:`, `token:`, `url:`, `cache_dir:`; searched from the pipeline directory up to the git root
|
||||||
- Rule suppression by rule ID (e.g. `ignore: [no-only, missing-stages]`)
|
- ~~**Inline suppression comments**~~ — ✓ shipped v0.2.19; `# glint: ignore GL007` before a job definition; comma/space-separated rules; `# glint: ignore all` wildcard
|
||||||
- Severity overrides (demote specific errors to warnings)
|
|
||||||
- Custom `stages` allowlist for projects that use a non-standard default set
|
|
||||||
- Token and URL defaults so flags are not needed in every invocation
|
|
||||||
- **Inline suppression comments** — `# glint: ignore next-line <rule-id>` in the pipeline YAML
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Reliability and developer experience
|
## Reliability and developer experience
|
||||||
|
|
||||||
- **Structured rule IDs** — assign a stable short ID to every rule (e.g. `GS001`) so suppression, documentation, and SARIF output are stable across versions
|
- ~~**Structured rule IDs**~~ — ✓ shipped post-v0.2.0; GL001–GL031 assigned; GL032 added v0.2.11; GL033 added v0.2.15; GL034–GL041 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`
|
||||||
|
|||||||
+58
-7
@@ -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
|
||||||
@@ -41,10 +43,14 @@ tasks:
|
|||||||
ignore_error: true
|
ignore_error: true
|
||||||
- cmd: ./{{.BINARY}} check testdata/keywords_invalid.yml
|
- cmd: ./{{.BINARY}} check testdata/keywords_invalid.yml
|
||||||
ignore_error: true
|
ignore_error: true
|
||||||
|
- cmd: ./{{.BINARY}} check testdata/includes_remote.yml
|
||||||
|
ignore_error: false
|
||||||
- cmd: ./{{.BINARY}} check testdata/includes_project.yml
|
- cmd: ./{{.BINARY}} check testdata/includes_project.yml
|
||||||
ignore_error: false
|
ignore_error: false
|
||||||
- cmd: ./{{.BINARY}} check testdata/includes_component.yml
|
- cmd: ./{{.BINARY}} check testdata/includes_component.yml
|
||||||
ignore_error: false
|
ignore_error: false
|
||||||
|
- cmd: ./{{.BINARY}} check testdata/script_multiline.yml
|
||||||
|
ignore_error: false
|
||||||
- cmd: ./{{.BINARY}} check testdata/context_rules.yml
|
- cmd: ./{{.BINARY}} check testdata/context_rules.yml
|
||||||
ignore_error: false
|
ignore_error: false
|
||||||
- cmd: ./{{.BINARY}} check --branch main testdata/context_rules.yml
|
- cmd: ./{{.BINARY}} check --branch main testdata/context_rules.yml
|
||||||
@@ -55,21 +61,66 @@ tasks:
|
|||||||
ignore_error: false
|
ignore_error: false
|
||||||
- cmd: ./{{.BINARY}} check --tag v1.0.0 testdata/context_rules.yml
|
- cmd: ./{{.BINARY}} check --tag v1.0.0 testdata/context_rules.yml
|
||||||
ignore_error: false
|
ignore_error: false
|
||||||
- cmd: ./{{.BINARY}} check samba-testdata/.gitlab-ci.yml
|
- cmd: ./{{.BINARY}} check testdata/rules_if_expr.yml
|
||||||
ignore_error: false
|
ignore_error: false
|
||||||
- cmd: ./{{.BINARY}} check samba-testdata/.gitlab-ci-coverage.yml
|
- cmd: ./{{.BINARY}} check --branch main testdata/rules_if_expr.yml
|
||||||
ignore_error: false
|
ignore_error: false
|
||||||
- cmd: ./{{.BINARY}} check samba-testdata/.gitlab-ci-private.yml
|
- cmd: ./{{.BINARY}} check --branch feat/x testdata/rules_if_expr.yml
|
||||||
|
ignore_error: false
|
||||||
|
- cmd: ./{{.BINARY}} check testdata/workflow_vars.yml
|
||||||
|
ignore_error: false
|
||||||
|
- cmd: ./{{.BINARY}} check --branch main testdata/workflow_vars.yml
|
||||||
|
ignore_error: false
|
||||||
|
- cmd: ./{{.BINARY}} check --branch develop testdata/workflow_vars.yml
|
||||||
|
ignore_error: false
|
||||||
|
- cmd: ./{{.BINARY}} check --branch feat/x testdata/workflow_vars.yml
|
||||||
|
ignore_error: false
|
||||||
|
- cmd: ./{{.BINARY}} check testdata/workflow_escape.yml
|
||||||
|
ignore_error: false
|
||||||
|
- cmd: ./{{.BINARY}} check testdata/variable_refs.yml
|
||||||
|
ignore_error: false
|
||||||
|
- cmd: ./{{.BINARY}} check testdata/variable_refs_included.yml
|
||||||
|
ignore_error: false
|
||||||
|
- cmd: ./{{.BINARY}} check testdata/dead_rules.yml
|
||||||
|
ignore_error: false
|
||||||
|
- cmd: ./{{.BINARY}} check testdata/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
|
||||||
|
ignore_error: false
|
||||||
|
- cmd: ./{{.BINARY}} check testdata/samba/.gitlab-ci-coverage.yml
|
||||||
|
ignore_error: false
|
||||||
|
- cmd: ./{{.BINARY}} check testdata/samba/.gitlab-ci-private.yml
|
||||||
|
ignore_error: false
|
||||||
|
- cmd: ./{{.BINARY}} check --format json testdata/valid.yml
|
||||||
|
ignore_error: false
|
||||||
|
- cmd: ./{{.BINARY}} check --format sarif testdata/valid.yml
|
||||||
|
ignore_error: false
|
||||||
|
- cmd: ./{{.BINARY}} check --format junit testdata/valid.yml
|
||||||
|
ignore_error: false
|
||||||
|
- cmd: ./{{.BINARY}} check --format github testdata/invalid.yml
|
||||||
|
ignore_error: true
|
||||||
|
- cmd: ./{{.BINARY}} check testdata/config_ignored/.gitlab-ci.yml
|
||||||
|
ignore_error: false
|
||||||
|
- cmd: ./{{.BINARY}} check testdata/config_severity/.gitlab-ci.yml
|
||||||
|
ignore_error: false
|
||||||
|
- cmd: ./{{.BINARY}} check testdata/config_suppress/.gitlab-ci.yml
|
||||||
ignore_error: false
|
ignore_error: false
|
||||||
|
|
||||||
lint-go:
|
lint-go:
|
||||||
desc: Run go vet on all packages
|
desc: Run go vet on all packages
|
||||||
cmd: "{{.GO}} vet ./..."
|
cmd: "{{.GO}} vet ./..."
|
||||||
|
|
||||||
|
lint-static:
|
||||||
|
desc: Run staticcheck on all packages
|
||||||
|
cmd: "{{.GO}} tool staticcheck ./..."
|
||||||
|
|
||||||
ci:
|
ci:
|
||||||
desc: Full CI check — vet, test, build, validate
|
desc: Full CI check — vet, staticcheck, test, build, validate
|
||||||
cmds:
|
cmds:
|
||||||
- task: lint-go
|
- task: lint-go
|
||||||
|
- task: lint-static
|
||||||
- task: test
|
- task: test
|
||||||
- task: build
|
- task: build
|
||||||
- task: validate
|
- task: validate
|
||||||
@@ -83,7 +134,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
|
||||||
@@ -99,7 +150,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
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 106 KiB |
@@ -0,0 +1,87 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.k3nny.fr/glint/internal/config"
|
||||||
|
"git.k3nny.fr/glint/internal/linter"
|
||||||
|
)
|
||||||
|
|
||||||
|
// applyConfig filters and adjusts findings according to the project config
|
||||||
|
// and inline suppression comments parsed from the pipeline YAML.
|
||||||
|
//
|
||||||
|
// Processing order:
|
||||||
|
// 1. Build a combined ignore set from config.Ignore and any severity entry
|
||||||
|
// whose value is "ignore".
|
||||||
|
// 2. Drop findings whose rule is in the ignore set.
|
||||||
|
// 3. Drop findings suppressed by an inline "# glint: ignore" comment on the
|
||||||
|
// job definition (from p.Suppressions).
|
||||||
|
// 4. Apply severity overrides ("error" / "warning") from config.Severity.
|
||||||
|
func applyConfig(findings []linter.Finding, cfg config.Config, suppressions map[string][]string) []linter.Finding {
|
||||||
|
// Build the global ignore set (uppercased rule IDs).
|
||||||
|
ignoreSet := make(map[string]bool, len(cfg.Ignore))
|
||||||
|
for _, r := range cfg.Ignore {
|
||||||
|
ignoreSet[strings.ToUpper(r)] = true
|
||||||
|
}
|
||||||
|
// Severity entries with value "ignore" are equivalent to Ignore entries.
|
||||||
|
sevMap := make(map[string]string, len(cfg.Severity)) // upperRule → lowerLevel
|
||||||
|
for rule, sev := range cfg.Severity {
|
||||||
|
upper := strings.ToUpper(rule)
|
||||||
|
lower := strings.ToLower(sev)
|
||||||
|
sevMap[upper] = lower
|
||||||
|
if lower == "ignore" {
|
||||||
|
ignoreSet[upper] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(ignoreSet) == 0 && len(sevMap) == 0 && len(suppressions) == 0 {
|
||||||
|
return findings
|
||||||
|
}
|
||||||
|
|
||||||
|
kept := findings[:0:0] // reuse underlying array but return fresh slice
|
||||||
|
for _, f := range findings {
|
||||||
|
ruleUpper := strings.ToUpper(f.Rule)
|
||||||
|
|
||||||
|
// Global ignore.
|
||||||
|
if ignoreSet[ruleUpper] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inline suppression.
|
||||||
|
if isSuppressed(f.Job, ruleUpper, suppressions) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Severity override.
|
||||||
|
if level, ok := sevMap[ruleUpper]; ok {
|
||||||
|
switch level {
|
||||||
|
case "error":
|
||||||
|
f.Severity = linter.Error
|
||||||
|
case "warning":
|
||||||
|
f.Severity = linter.Warning
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
kept = append(kept, f)
|
||||||
|
}
|
||||||
|
return kept
|
||||||
|
}
|
||||||
|
|
||||||
|
// isSuppressed reports whether jobName has a "# glint: ignore" directive that
|
||||||
|
// covers ruleUpper. The wildcard entry "*" (from "# glint: ignore all")
|
||||||
|
// suppresses every rule.
|
||||||
|
func isSuppressed(jobName, ruleUpper string, suppressions map[string][]string) bool {
|
||||||
|
if jobName == "" || len(suppressions) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
rules, ok := suppressions[jobName]
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range rules {
|
||||||
|
if r == "*" || r == ruleUpper {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.k3nny.fr/glint/internal/config"
|
||||||
|
"git.k3nny.fr/glint/internal/linter"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestApplyConfig(t *testing.T) {
|
||||||
|
findings := []linter.Finding{
|
||||||
|
{Severity: linter.Error, Rule: "GL004", Job: "deploy", File: "ci.yml", Line: 10, Message: "bad stage"},
|
||||||
|
{Severity: linter.Warning, Rule: "GL007", Job: "old-job", File: "ci.yml", Line: 20, Message: "deprecated"},
|
||||||
|
{Severity: linter.Warning, Rule: "GL032", Job: "check", File: "ci.yml", Line: 30, Message: "var ref"},
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("no config — pass through", func(t *testing.T) {
|
||||||
|
got := applyConfig(findings, config.Config{}, nil)
|
||||||
|
if len(got) != 3 {
|
||||||
|
t.Errorf("got %d findings, want 3", len(got))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ignore GL007", func(t *testing.T) {
|
||||||
|
cfg := config.Config{Ignore: []string{"GL007"}}
|
||||||
|
got := applyConfig(findings, cfg, nil)
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("got %d findings, want 2", len(got))
|
||||||
|
}
|
||||||
|
for _, f := range got {
|
||||||
|
if f.Rule == "GL007" {
|
||||||
|
t.Error("GL007 should be suppressed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ignore case-insensitive", func(t *testing.T) {
|
||||||
|
cfg := config.Config{Ignore: []string{"gl007"}}
|
||||||
|
got := applyConfig(findings, cfg, nil)
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("got %d, want 2", len(got))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("severity demote error to warning", func(t *testing.T) {
|
||||||
|
cfg := config.Config{Severity: map[string]string{"GL004": "warning"}}
|
||||||
|
got := applyConfig(findings, cfg, nil)
|
||||||
|
if len(got) != 3 {
|
||||||
|
t.Fatalf("got %d findings, want 3", len(got))
|
||||||
|
}
|
||||||
|
if got[0].Severity != linter.Warning {
|
||||||
|
t.Errorf("GL004 severity = %s, want WARNING", got[0].Severity)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("severity promote warning to error", func(t *testing.T) {
|
||||||
|
cfg := config.Config{Severity: map[string]string{"GL007": "error"}}
|
||||||
|
got := applyConfig(findings, cfg, nil)
|
||||||
|
if got[1].Severity != linter.Error {
|
||||||
|
t.Errorf("GL007 severity = %s, want ERROR", got[1].Severity)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("severity ignore is equivalent to ignore list", func(t *testing.T) {
|
||||||
|
cfg := config.Config{Severity: map[string]string{"GL032": "ignore"}}
|
||||||
|
got := applyConfig(findings, cfg, nil)
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("got %d, want 2", len(got))
|
||||||
|
}
|
||||||
|
for _, f := range got {
|
||||||
|
if f.Rule == "GL032" {
|
||||||
|
t.Error("GL032 should be suppressed via severity=ignore")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("inline suppression by job", func(t *testing.T) {
|
||||||
|
suppressions := map[string][]string{
|
||||||
|
"old-job": {"GL007"},
|
||||||
|
}
|
||||||
|
got := applyConfig(findings, config.Config{}, suppressions)
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("got %d, want 2", len(got))
|
||||||
|
}
|
||||||
|
for _, f := range got {
|
||||||
|
if f.Job == "old-job" && f.Rule == "GL007" {
|
||||||
|
t.Error("old-job GL007 should be suppressed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("inline suppression wildcard", func(t *testing.T) {
|
||||||
|
suppressions := map[string][]string{
|
||||||
|
"old-job": {"*"},
|
||||||
|
}
|
||||||
|
got := applyConfig(findings, config.Config{}, suppressions)
|
||||||
|
// old-job had GL007; should be gone
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("got %d, want 2", len(got))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("pipeline-level findings not suppressed by job comment", func(t *testing.T) {
|
||||||
|
pipelineFindings := []linter.Finding{
|
||||||
|
{Severity: linter.Error, Rule: "GL001", Job: "", File: "ci.yml", Line: 0, Message: "no stages"},
|
||||||
|
}
|
||||||
|
suppressions := map[string][]string{
|
||||||
|
"": {"GL001"}, // empty job key should not match pipeline-level
|
||||||
|
}
|
||||||
|
got := applyConfig(pipelineFindings, config.Config{}, suppressions)
|
||||||
|
// Pipeline-level findings (f.Job == "") are never suppressed by job comments.
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Errorf("got %d, want 1 (pipeline-level finding must not be suppressed)", len(got))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+264
-21
@@ -9,6 +9,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.k3nny.fr/glint/internal/cicontext"
|
"git.k3nny.fr/glint/internal/cicontext"
|
||||||
|
"git.k3nny.fr/glint/internal/config"
|
||||||
"git.k3nny.fr/glint/internal/fetcher"
|
"git.k3nny.fr/glint/internal/fetcher"
|
||||||
"git.k3nny.fr/glint/internal/graph"
|
"git.k3nny.fr/glint/internal/graph"
|
||||||
"git.k3nny.fr/glint/internal/linter"
|
"git.k3nny.fr/glint/internal/linter"
|
||||||
@@ -16,6 +17,21 @@ import (
|
|||||||
"git.k3nny.fr/glint/internal/resolver"
|
"git.k3nny.fr/glint/internal/resolver"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// version is set at build time via -ldflags "-X main.version=vX.Y.Z".
|
||||||
|
var version = "dev"
|
||||||
|
|
||||||
|
// defaultCacheDir returns the platform-default glint cache directory:
|
||||||
|
// $XDG_CACHE_HOME/glint or ~/.cache/glint.
|
||||||
|
func defaultCacheDir() string {
|
||||||
|
if xdg := os.Getenv("XDG_CACHE_HOME"); xdg != "" {
|
||||||
|
return filepath.Join(xdg, "glint")
|
||||||
|
}
|
||||||
|
if home, err := os.UserHomeDir(); err == nil {
|
||||||
|
return filepath.Join(home, ".cache", "glint")
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
const globalUsage = `glint: Lint and visualise GitLab CI pipelines locally.
|
const globalUsage = `glint: Lint and visualise GitLab CI pipelines locally.
|
||||||
|
|
||||||
Usage: glint [OPTIONS] <COMMAND>
|
Usage: glint [OPTIONS] <COMMAND>
|
||||||
@@ -26,6 +42,7 @@ Commands:
|
|||||||
|
|
||||||
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 +58,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,12 +81,17 @@ 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")
|
||||||
|
listVars := fs.Bool("list-vars", false, "print all collected pipeline variables (from root and included files) to stderr, then continue")
|
||||||
var vars multiFlag
|
var vars multiFlag
|
||||||
fs.Var(&vars, "var", "set a CI variable as KEY=VALUE; repeatable")
|
fs.Var(&vars, "var", "set a CI variable as KEY=VALUE; repeatable")
|
||||||
fs.Usage = func() {
|
fs.Usage = func() {
|
||||||
|
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.
|
||||||
@@ -78,6 +103,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.
|
||||||
@@ -87,9 +116,21 @@ 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.
|
||||||
|
[default: main]
|
||||||
|
|
||||||
--tag <NAME>
|
--tag <NAME>
|
||||||
Simulate a tag push. Populates: CI_COMMIT_TAG, CI_COMMIT_REF_NAME,
|
Simulate a tag push. Populates: CI_COMMIT_TAG, CI_COMMIT_REF_NAME,
|
||||||
@@ -97,32 +138,90 @@ Options:
|
|||||||
|
|
||||||
--source <EVENT>
|
--source <EVENT>
|
||||||
Override CI_PIPELINE_SOURCE.
|
Override CI_PIPELINE_SOURCE.
|
||||||
[possible values: push, merge_request_event, schedule, web, api]
|
[default: push] [possible values: push, merge_request_event, schedule, web, api]
|
||||||
|
|
||||||
--var <KEY=VALUE>
|
--var <KEY=VALUE>
|
||||||
Set or override a CI variable. Takes precedence over --branch, --tag,
|
Set or override a CI variable. Takes precedence over --branch, --tag,
|
||||||
and --source. Repeatable.
|
and --source. Repeatable.
|
||||||
|
|
||||||
|
--list-vars
|
||||||
|
Print all pipeline-level variables collected from the root file and
|
||||||
|
every included file (sorted KEY=VALUE) to stderr, then continue
|
||||||
|
normally. Useful for debugging variable resolution and GL032 findings.
|
||||||
|
|
||||||
-h, --help
|
-h, --help
|
||||||
Print help
|
Print help
|
||||||
|
|
||||||
|
Note: when none of --branch, --tag, --source, or --var are given, glint
|
||||||
|
defaults to --branch main --source push so that rules:if: expressions are
|
||||||
|
always evaluated.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
glint check .gitlab-ci.yml
|
glint check .gitlab-ci.yml
|
||||||
glint check --branch main .gitlab-ci.yml
|
glint check --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 --tag v1.0.0 .gitlab-ci.yml
|
||||||
|
glint check --source merge_request_event .gitlab-ci.yml
|
||||||
|
glint check --list-vars .gitlab-ci.yml
|
||||||
GITLAB_TOKEN=glpat-xxxx glint check .gitlab-ci.yml
|
GITLAB_TOKEN=glpat-xxxx glint check .gitlab-ci.yml
|
||||||
glint check --token glpat-xxxx --gitlab-url https://gitlab.example.com .gitlab-ci.yml
|
glint check --token glpat-xxxx --gitlab-url https://gitlab.example.com .gitlab-ci.yml
|
||||||
glint check --branch main --var DEPLOY_ENV=production .gitlab-ci.yml
|
glint check --branch main --var DEPLOY_ENV=production .gitlab-ci.yml
|
||||||
|
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)
|
||||||
|
|
||||||
|
// Apply implicit defaults when no context flag is given at all.
|
||||||
|
if *branch == "" && *tag == "" && *source == "" && len(vars) == 0 {
|
||||||
|
*branch = "main"
|
||||||
|
*source = "push"
|
||||||
|
}
|
||||||
|
|
||||||
|
validFormats := map[string]bool{
|
||||||
|
"text": true, "json": true, "sarif": true, "junit": true, "github": true,
|
||||||
|
}
|
||||||
|
if !validFormats[*format] {
|
||||||
|
fmt.Fprintf(os.Stderr, "glint: unknown format %q; valid: text, json, sarif, junit, github\n", *format)
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
if fs.NArg() != 1 {
|
if fs.NArg() != 1 {
|
||||||
fs.Usage()
|
fs.Usage()
|
||||||
os.Exit(2)
|
os.Exit(2)
|
||||||
}
|
}
|
||||||
path := fs.Arg(0)
|
path := fs.Arg(0)
|
||||||
|
rootDir := filepath.Dir(filepath.Clean(path))
|
||||||
|
|
||||||
cfg := fetcher.AutoConfig().WithOverrides(*gitlabURL, *token)
|
// Load project config (.glint.yml), searching from the pipeline directory
|
||||||
|
// up to the git root.
|
||||||
|
glintCfg, cfgErr := config.Load(rootDir)
|
||||||
|
if cfgErr != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s: [warning] %s: %v\n", path, config.Filename, cfgErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CLI flags take priority over config file values, which take priority over
|
||||||
|
// environment variables (read by AutoConfig).
|
||||||
|
fetcherToken := *token
|
||||||
|
if fetcherToken == "" {
|
||||||
|
fetcherToken = glintCfg.Token
|
||||||
|
}
|
||||||
|
fetcherURL := *gitlabURL
|
||||||
|
if fetcherURL == "" {
|
||||||
|
fetcherURL = glintCfg.URL
|
||||||
|
}
|
||||||
|
resolvedCacheDir := *cacheDir
|
||||||
|
if resolvedCacheDir == "" {
|
||||||
|
resolvedCacheDir = glintCfg.CacheDir
|
||||||
|
}
|
||||||
|
if *offline && resolvedCacheDir == "" {
|
||||||
|
resolvedCacheDir = defaultCacheDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := fetcher.AutoConfig().WithOverrides(fetcherURL, fetcherToken, resolvedCacheDir, *offline)
|
||||||
|
|
||||||
p, err := model.Parse(path)
|
p, err := model.Parse(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -130,10 +229,22 @@ Examples:
|
|||||||
os.Exit(2)
|
os.Exit(2)
|
||||||
}
|
}
|
||||||
|
|
||||||
rootDir := filepath.Dir(filepath.Clean(path))
|
// Merge config-defined stages into the pipeline before linting so that
|
||||||
|
// GL004 does not fire for jobs in stages declared only in .glint.yml.
|
||||||
|
stageSet := make(map[string]bool, len(p.Stages))
|
||||||
|
for _, s := range p.Stages {
|
||||||
|
stageSet[s] = true
|
||||||
|
}
|
||||||
|
for _, s := range glintCfg.Stages {
|
||||||
|
if !stageSet[s] {
|
||||||
|
p.Stages = append(p.Stages, s)
|
||||||
|
stageSet[s] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
warnings, _ := resolver.ResolveIncludes(p, cfg, rootDir)
|
warnings, _ := resolver.ResolveIncludes(p, cfg, rootDir)
|
||||||
for _, w := range warnings {
|
for _, w := range warnings {
|
||||||
fmt.Fprintf(os.Stderr, "[WARNING] include %s\n", w)
|
fmt.Fprintf(os.Stderr, "%s: [warning] include %s\n", path, w)
|
||||||
}
|
}
|
||||||
|
|
||||||
extWarnings, err := resolver.Resolve(p)
|
extWarnings, err := resolver.Resolve(p)
|
||||||
@@ -142,36 +253,56 @@ Examples:
|
|||||||
os.Exit(2)
|
os.Exit(2)
|
||||||
}
|
}
|
||||||
for _, w := range extWarnings {
|
for _, w := range extWarnings {
|
||||||
fmt.Fprintf(os.Stderr, "[WARNING] job %q extends unknown job %q; extends chain skipped\n", w.Job, w.Base)
|
fmt.Fprintf(os.Stderr, "%s: [warning] job %q extends unknown job %q; extends chain skipped\n", path, w.Job, w.Base)
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := cicontext.New(*branch, *tag, *source, vars)
|
ctx := cicontext.New(*branch, *tag, *source, vars)
|
||||||
if !ctx.IsEmpty() {
|
if !ctx.IsEmpty() {
|
||||||
|
if !enrichContext(ctx, p) {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s: [warning] workflow:rules: pipeline would not start for this context\n", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if *listVars {
|
||||||
|
printVars(p, ctx)
|
||||||
|
}
|
||||||
|
// Context summary only makes sense in plain-text output; suppress it in
|
||||||
|
// structured formats so stdout contains only the machine-readable payload.
|
||||||
|
if !ctx.IsEmpty() && *format == "text" {
|
||||||
printContext(p, ctx)
|
printContext(p, ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
findings := linter.Lint(p)
|
findings := linter.Lint(p)
|
||||||
hasErrors := false
|
findings = applyConfig(findings, glintCfg, p.Suppressions)
|
||||||
|
errCount, _ := countSeverities(findings)
|
||||||
|
|
||||||
|
// In structured formats the summary line goes to stderr so stdout is clean.
|
||||||
|
summaryOut := os.Stdout
|
||||||
|
if *format != "text" {
|
||||||
|
summaryOut = os.Stderr
|
||||||
|
}
|
||||||
|
|
||||||
|
switch *format {
|
||||||
|
case "json":
|
||||||
|
writeJSON(os.Stdout, findings, path)
|
||||||
|
case "sarif":
|
||||||
|
writeSARIF(os.Stdout, findings, path)
|
||||||
|
case "junit":
|
||||||
|
writeJUnit(os.Stdout, findings, path)
|
||||||
|
case "github":
|
||||||
|
writeGitHub(os.Stdout, findings)
|
||||||
|
default: // "text"
|
||||||
for _, f := range findings {
|
for _, f := range findings {
|
||||||
fmt.Println(f)
|
fmt.Println(f)
|
||||||
if f.Severity == linter.Error {
|
|
||||||
hasErrors = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(findings) == 0 {
|
if len(findings) == 0 {
|
||||||
fmt.Printf("OK: %s — no issues found (%d job(s), %d stage(s))\n", path, len(p.Jobs), len(p.Stages))
|
fmt.Fprintf(summaryOut, "OK: %s — no issues found (%d job(s), %d stage(s))\n", path, len(p.Jobs), len(p.Stages))
|
||||||
} else {
|
} else {
|
||||||
errCount := 0
|
fmt.Fprintf(summaryOut, "%d finding(s): %d error(s)\n", len(findings), errCount)
|
||||||
for _, f := range findings {
|
|
||||||
if f.Severity == linter.Error {
|
|
||||||
errCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fmt.Printf("%d finding(s): %d error(s)\n", len(findings), errCount)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if hasErrors {
|
if errCount > 0 {
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -191,8 +322,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>
|
||||||
@@ -221,6 +355,7 @@ Options:
|
|||||||
evaluated state ([skipped] or [manual]; no tag means active).
|
evaluated state ([skipped] or [manual]; no tag means active).
|
||||||
Populates: CI_COMMIT_BRANCH, CI_COMMIT_REF_NAME, CI_COMMIT_REF_SLUG,
|
Populates: CI_COMMIT_BRANCH, CI_COMMIT_REF_NAME, CI_COMMIT_REF_SLUG,
|
||||||
CI_PIPELINE_SOURCE=push.
|
CI_PIPELINE_SOURCE=push.
|
||||||
|
[default: main]
|
||||||
|
|
||||||
--tag <NAME>
|
--tag <NAME>
|
||||||
Simulate a tag push. Populates: CI_COMMIT_TAG, CI_COMMIT_REF_NAME,
|
Simulate a tag push. Populates: CI_COMMIT_TAG, CI_COMMIT_REF_NAME,
|
||||||
@@ -228,18 +363,29 @@ Options:
|
|||||||
|
|
||||||
--source <EVENT>
|
--source <EVENT>
|
||||||
Override CI_PIPELINE_SOURCE.
|
Override CI_PIPELINE_SOURCE.
|
||||||
[possible values: push, merge_request_event, schedule, web, api]
|
[default: push] [possible values: push, merge_request_event, schedule, web, api]
|
||||||
|
|
||||||
--var <KEY=VALUE>
|
--var <KEY=VALUE>
|
||||||
Set or override a CI variable. Repeatable.
|
Set or override a CI variable. Repeatable.
|
||||||
|
|
||||||
|
--list-vars
|
||||||
|
Print all pipeline-level variables collected from the root file and
|
||||||
|
every included file (sorted KEY=VALUE) to stderr, then continue
|
||||||
|
normally. Useful for debugging variable resolution.
|
||||||
|
|
||||||
-h, --help
|
-h, --help
|
||||||
Print help
|
Print help
|
||||||
|
|
||||||
|
Note: when none of --branch, --tag, --source, or --var are given, glint
|
||||||
|
defaults to --branch main --source push so that rules:if: expressions are
|
||||||
|
always evaluated.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
glint graph .gitlab-ci.yml
|
glint graph .gitlab-ci.yml
|
||||||
glint graph tree .gitlab-ci.yml
|
glint graph tree .gitlab-ci.yml
|
||||||
glint graph tree --branch main .gitlab-ci.yml
|
glint graph tree --branch develop .gitlab-ci.yml
|
||||||
|
glint graph tree --tag v1.0.0 .gitlab-ci.yml
|
||||||
|
glint graph tree --list-vars .gitlab-ci.yml
|
||||||
glint graph includes .gitlab-ci.yml > includes.mmd
|
glint graph includes .gitlab-ci.yml > includes.mmd
|
||||||
glint graph pipeline .gitlab-ci.yml
|
glint graph pipeline .gitlab-ci.yml
|
||||||
glint graph pipeline --out /tmp/graphs .gitlab-ci.yml
|
glint graph pipeline --out /tmp/graphs .gitlab-ci.yml
|
||||||
@@ -249,17 +395,29 @@ Examples:
|
|||||||
branch := fs.String("branch", "", "simulate a branch push (sets CI_COMMIT_BRANCH, …)")
|
branch := fs.String("branch", "", "simulate a branch push (sets CI_COMMIT_BRANCH, …)")
|
||||||
tag := fs.String("tag", "", "simulate a tag push (sets CI_COMMIT_TAG, …)")
|
tag := fs.String("tag", "", "simulate a tag push (sets CI_COMMIT_TAG, …)")
|
||||||
source := fs.String("source", "", "set CI_PIPELINE_SOURCE")
|
source := fs.String("source", "", "set CI_PIPELINE_SOURCE")
|
||||||
|
listVars := fs.Bool("list-vars", false, "print all collected pipeline variables to stderr, then continue")
|
||||||
var vars multiFlag
|
var vars multiFlag
|
||||||
fs.Var(&vars, "var", "set a CI variable as KEY=VALUE; repeatable")
|
fs.Var(&vars, "var", "set a CI variable as KEY=VALUE; repeatable")
|
||||||
_ = fs.Parse(args)
|
_ = fs.Parse(args)
|
||||||
|
|
||||||
|
// Apply implicit defaults when no context flag is given at all.
|
||||||
|
if *branch == "" && *tag == "" && *source == "" && len(vars) == 0 {
|
||||||
|
*branch = "main"
|
||||||
|
*source = "push"
|
||||||
|
}
|
||||||
|
|
||||||
if fs.NArg() != 1 {
|
if fs.NArg() != 1 {
|
||||||
fs.Usage()
|
fs.Usage()
|
||||||
os.Exit(2)
|
os.Exit(2)
|
||||||
}
|
}
|
||||||
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 {
|
||||||
@@ -272,6 +430,12 @@ Examples:
|
|||||||
resolver.Resolve(p) //nolint:errcheck
|
resolver.Resolve(p) //nolint:errcheck
|
||||||
|
|
||||||
ctx := cicontext.New(*branch, *tag, *source, vars)
|
ctx := cicontext.New(*branch, *tag, *source, vars)
|
||||||
|
if !ctx.IsEmpty() {
|
||||||
|
enrichContext(ctx, p)
|
||||||
|
}
|
||||||
|
if *listVars {
|
||||||
|
printVars(p, ctx)
|
||||||
|
}
|
||||||
|
|
||||||
switch mode {
|
switch mode {
|
||||||
case "default":
|
case "default":
|
||||||
@@ -300,6 +464,85 @@ Examples:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// printVars prints the collected variable namespaces to stderr:
|
||||||
|
// 1. Pipeline variables — declared in variables: blocks across the root file
|
||||||
|
// and all included files (merged by ResolveIncludes).
|
||||||
|
// 2. Workflow-rule variables — union of variables: from every workflow:rules
|
||||||
|
// entry; any one of them may be injected at runtime.
|
||||||
|
// 3. Effective context variables — only when ctx is non-empty; shows the
|
||||||
|
// fully merged set visible to job rules:if: after enrichContext.
|
||||||
|
func printVars(p *model.Pipeline, ctx *cicontext.Context) {
|
||||||
|
fmt.Fprintln(os.Stderr, "Pipeline variables (YAML, root + includes):")
|
||||||
|
printVarMap(p.Variables)
|
||||||
|
|
||||||
|
if p.Workflow != nil {
|
||||||
|
union := map[string]any{}
|
||||||
|
for _, rule := range p.Workflow.Rules {
|
||||||
|
for k, v := range rule.Variables {
|
||||||
|
union[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(union) > 0 {
|
||||||
|
fmt.Fprintln(os.Stderr, "Workflow-rule variables (union across all rules):")
|
||||||
|
printVarMap(union)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ctx.IsEmpty() {
|
||||||
|
fmt.Fprintln(os.Stderr, "Effective context variables (after workflow + CLI flags):")
|
||||||
|
keys := make([]string, 0, len(ctx.Vars))
|
||||||
|
for k := range ctx.Vars {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
for _, k := range keys {
|
||||||
|
fmt.Fprintf(os.Stderr, " %s=%s\n", k, ctx.Vars[k])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func printVarMap(m map[string]any) {
|
||||||
|
keys := make([]string, 0, len(m))
|
||||||
|
for k := range m {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
if len(keys) == 0 {
|
||||||
|
fmt.Fprintln(os.Stderr, " (none)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, k := range keys {
|
||||||
|
fmt.Fprintf(os.Stderr, " %s=%s\n", k, varValueString(m[k]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func varValueString(v any) string {
|
||||||
|
if s, ok := cicontext.ScalarString(v); ok {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return "(complex)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// enrichContext injects pipeline-level variable defaults and then
|
||||||
|
// workflow-rule-generated variables into ctx before job evaluation.
|
||||||
|
// Injection respects pinned variables (--branch/--tag/--source/--var always win).
|
||||||
|
// Returns false when workflow:rules: would prevent the pipeline from starting.
|
||||||
|
func enrichContext(ctx *cicontext.Context, p *model.Pipeline) bool {
|
||||||
|
// Pipeline variables: injected as defaults (lowest priority).
|
||||||
|
for k, v := range cicontext.ExtractStringVars(p.Variables) {
|
||||||
|
ctx.Inject(k, v)
|
||||||
|
}
|
||||||
|
// Workflow rules: evaluate to find which rule matches, then inject its variables.
|
||||||
|
runs, ruleVars := cicontext.EvalWorkflow(p, ctx)
|
||||||
|
for k, v := range ruleVars {
|
||||||
|
ctx.Inject(k, v)
|
||||||
|
}
|
||||||
|
// Expand $VAR / ${VAR} references within variable values now that all
|
||||||
|
// sources (pipeline, workflow rules, CLI) have been merged.
|
||||||
|
ctx.ExpandVars()
|
||||||
|
return runs
|
||||||
|
}
|
||||||
|
|
||||||
func printContext(p *model.Pipeline, ctx *cicontext.Context) {
|
func printContext(p *model.Pipeline, ctx *cicontext.Context) {
|
||||||
fmt.Printf("Context: %s\n\n", ctx.Summary())
|
fmt.Printf("Context: %s\n\n", ctx.Summary())
|
||||||
|
|
||||||
|
|||||||
@@ -2,4 +2,14 @@ module git.k3nny.fr/glint
|
|||||||
|
|
||||||
go 1.26.4
|
go 1.26.4
|
||||||
|
|
||||||
require gopkg.in/yaml.v3 v3.0.1 // indirect
|
require (
|
||||||
|
github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c // indirect
|
||||||
|
golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 // indirect
|
||||||
|
golang.org/x/mod v0.31.0 // indirect
|
||||||
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
|
golang.org/x/tools v0.40.1-0.20260108161641-ca281cf95054 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
honnef.co/go/tools v0.7.0 // indirect
|
||||||
|
)
|
||||||
|
|
||||||
|
tool honnef.co/go/tools/cmd/staticcheck
|
||||||
|
|||||||
@@ -1,3 +1,16 @@
|
|||||||
|
github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs=
|
||||||
|
github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||||
|
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ=
|
||||||
|
golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 h1:1P7xPZEwZMoBoz0Yze5Nx2/4pxj6nw9ZqHWXqP0iRgQ=
|
||||||
|
golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
|
||||||
|
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||||
|
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
||||||
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/tools v0.40.1-0.20260108161641-ca281cf95054 h1:CHVDrNHx9ZoOrNN9kKWYIbT5Rj+WF2rlwPkhbQQ5V4U=
|
||||||
|
golang.org/x/tools v0.40.1-0.20260108161641-ca281cf95054/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU=
|
||||||
|
honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc=
|
||||||
|
|||||||
+167
-12
@@ -1,12 +1,16 @@
|
|||||||
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:).
|
||||||
// Variables are keyed by their name without the leading $.
|
// Variables are keyed by their name without the leading $.
|
||||||
type Context struct {
|
type Context struct {
|
||||||
Vars map[string]string
|
Vars map[string]string
|
||||||
|
pinned map[string]bool // vars set via --var or shortcuts; never overwritten by Inject
|
||||||
}
|
}
|
||||||
|
|
||||||
// New builds a Context from high-level shortcut values and optional KEY=VALUE
|
// New builds a Context from high-level shortcut values and optional KEY=VALUE
|
||||||
@@ -17,46 +21,55 @@ type Context struct {
|
|||||||
// preserving the existing linting behaviour when no context flags are given.
|
// preserving the existing linting behaviour when no context flags are given.
|
||||||
//
|
//
|
||||||
// Override priority (highest wins): extraVars > branch/tag/source shortcuts.
|
// Override priority (highest wins): extraVars > branch/tag/source shortcuts.
|
||||||
|
// Both shortcut-derived and extraVar variables are pinned — they will not be
|
||||||
|
// overwritten by Inject (used for pipeline-level and workflow-rule variables).
|
||||||
func New(branch, tag, source string, extraVars []string) *Context {
|
func New(branch, tag, source string, extraVars []string) *Context {
|
||||||
if branch == "" && tag == "" && source == "" && len(extraVars) == 0 {
|
if branch == "" && tag == "" && source == "" && len(extraVars) == 0 {
|
||||||
return &Context{}
|
return &Context{}
|
||||||
}
|
}
|
||||||
|
|
||||||
vars := make(map[string]string)
|
vars := make(map[string]string)
|
||||||
|
pinned := make(map[string]bool)
|
||||||
|
|
||||||
|
pin := func(k, v string) {
|
||||||
|
vars[k] = v
|
||||||
|
pinned[k] = true
|
||||||
|
}
|
||||||
|
|
||||||
if branch != "" {
|
if branch != "" {
|
||||||
vars["CI_COMMIT_BRANCH"] = branch
|
pin("CI_COMMIT_BRANCH", branch)
|
||||||
vars["CI_COMMIT_REF_NAME"] = branch
|
pin("CI_COMMIT_REF_NAME", branch)
|
||||||
vars["CI_COMMIT_REF_SLUG"] = slugify(branch)
|
pin("CI_COMMIT_REF_SLUG", slugify(branch))
|
||||||
if source == "" {
|
if source == "" {
|
||||||
source = "push"
|
source = "push"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if tag != "" {
|
if tag != "" {
|
||||||
vars["CI_COMMIT_TAG"] = tag
|
pin("CI_COMMIT_TAG", tag)
|
||||||
vars["CI_COMMIT_REF_NAME"] = tag
|
pin("CI_COMMIT_REF_NAME", tag)
|
||||||
vars["CI_COMMIT_REF_SLUG"] = slugify(tag)
|
pin("CI_COMMIT_REF_SLUG", slugify(tag))
|
||||||
delete(vars, "CI_COMMIT_BRANCH") // tag pushes have no branch variable
|
delete(vars, "CI_COMMIT_BRANCH")
|
||||||
|
delete(pinned, "CI_COMMIT_BRANCH")
|
||||||
if source == "" {
|
if source == "" {
|
||||||
source = "push"
|
source = "push"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if source != "" {
|
if source != "" {
|
||||||
vars["CI_PIPELINE_SOURCE"] = source
|
pin("CI_PIPELINE_SOURCE", source)
|
||||||
}
|
}
|
||||||
if _, ok := vars["CI_DEFAULT_BRANCH"]; !ok {
|
if _, ok := vars["CI_DEFAULT_BRANCH"]; !ok {
|
||||||
vars["CI_DEFAULT_BRANCH"] = "main"
|
vars["CI_DEFAULT_BRANCH"] = "main"
|
||||||
}
|
}
|
||||||
|
|
||||||
// KEY=VALUE overrides win over shortcuts.
|
// KEY=VALUE overrides win over shortcuts and everything else.
|
||||||
for _, kv := range extraVars {
|
for _, kv := range extraVars {
|
||||||
k, v, ok := strings.Cut(kv, "=")
|
k, v, ok := strings.Cut(kv, "=")
|
||||||
if ok {
|
if ok {
|
||||||
vars[k] = v
|
pin(k, v)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return &Context{Vars: vars}
|
return &Context{Vars: vars, pinned: pinned}
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsEmpty reports whether no variables have been set (no context flags given).
|
// IsEmpty reports whether no variables have been set (no context flags given).
|
||||||
@@ -73,6 +86,21 @@ func (c *Context) Get(key string) string {
|
|||||||
return c.Vars[key]
|
return c.Vars[key]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Inject sets key=value only if key is not already pinned (i.e. not set via
|
||||||
|
// --branch / --tag / --source / --var). Used to inject pipeline-level variable
|
||||||
|
// defaults and workflow-rule variables without overriding explicit user input.
|
||||||
|
// Calling Inject in order from lowest-priority to highest-priority source
|
||||||
|
// ensures later calls win over earlier ones.
|
||||||
|
func (c *Context) Inject(key, value string) {
|
||||||
|
if c.pinned[key] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if c.Vars == nil {
|
||||||
|
c.Vars = make(map[string]string)
|
||||||
|
}
|
||||||
|
c.Vars[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
// Summary returns a short human-readable description of the context for CLI output.
|
// Summary returns a short human-readable description of the context for CLI output.
|
||||||
func (c *Context) Summary() string {
|
func (c *Context) Summary() string {
|
||||||
if c.IsEmpty() {
|
if c.IsEmpty() {
|
||||||
@@ -90,6 +118,133 @@ func (c *Context) Summary() string {
|
|||||||
return strings.Join(parts, ", ")
|
return strings.Join(parts, ", ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExtractStringVars converts a map[string]any variable block (as used by
|
||||||
|
// Pipeline.Variables and Rule.Variables) to a flat map[string]string.
|
||||||
|
// Plain string values are used directly. Extended {value: ...} map form uses
|
||||||
|
// the "value" key. Scalar non-string values (bool, int, float64) are
|
||||||
|
// converted to their string representation, matching GitLab's own behaviour
|
||||||
|
// where all CI variable values are strings.
|
||||||
|
func ExtractStringVars(m map[string]any) map[string]string {
|
||||||
|
if len(m) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make(map[string]string, len(m))
|
||||||
|
for k, v := range m {
|
||||||
|
if s, ok := ScalarString(v); ok {
|
||||||
|
out[k] = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScalarString converts a YAML-decoded CI variable value to its string
|
||||||
|
// representation. Handles plain scalars and the extended {value: ...} map
|
||||||
|
// form. Returns (s, true) on success, ("", false) for unrecognised forms.
|
||||||
|
func ScalarString(v any) (string, bool) {
|
||||||
|
switch val := v.(type) {
|
||||||
|
case string:
|
||||||
|
return val, true
|
||||||
|
case bool:
|
||||||
|
return fmt.Sprintf("%t", val), true
|
||||||
|
case int:
|
||||||
|
return fmt.Sprintf("%d", val), true
|
||||||
|
case float64:
|
||||||
|
return fmt.Sprintf("%g", val), true
|
||||||
|
case map[string]any:
|
||||||
|
inner, ok := val["value"]
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return ScalarString(inner)
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExpandVars expands $VAR and ${VAR} references within every value in
|
||||||
|
// ctx.Vars, using the same map as the expansion source. Iteration repeats
|
||||||
|
// (up to 10 passes) so transitive chains like A=$B, B=$C resolve fully.
|
||||||
|
// Variables that form circular references are left as-is after the limit.
|
||||||
|
func (c *Context) ExpandVars() {
|
||||||
|
if c == nil || len(c.Vars) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for range 10 {
|
||||||
|
changed := false
|
||||||
|
for k, v := range c.Vars {
|
||||||
|
expanded := expandVarRefs(v, c.Vars)
|
||||||
|
if expanded != v {
|
||||||
|
c.Vars[k] = expanded
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// expandVarRefs replaces $VAR and ${VAR} occurrences in s with their values
|
||||||
|
// from vars. Unknown variables are left unchanged.
|
||||||
|
func expandVarRefs(s string, vars map[string]string) string {
|
||||||
|
if !strings.Contains(s, "$") {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
var sb strings.Builder
|
||||||
|
i := 0
|
||||||
|
for i < len(s) {
|
||||||
|
if s[i] != '$' {
|
||||||
|
sb.WriteByte(s[i])
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
i++ // consume '$'
|
||||||
|
if i >= len(s) {
|
||||||
|
sb.WriteByte('$')
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if s[i] == '{' {
|
||||||
|
i++ // consume '{'
|
||||||
|
j := i
|
||||||
|
for j < len(s) && isIdentByte(s[j]) {
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
if j < len(s) && s[j] == '}' {
|
||||||
|
name := s[i:j]
|
||||||
|
if val, ok := vars[name]; ok {
|
||||||
|
sb.WriteString(val)
|
||||||
|
} else {
|
||||||
|
sb.WriteString("${")
|
||||||
|
sb.WriteString(name)
|
||||||
|
sb.WriteByte('}')
|
||||||
|
}
|
||||||
|
i = j + 1
|
||||||
|
} else {
|
||||||
|
// Malformed ${…} — emit literally
|
||||||
|
sb.WriteString("${")
|
||||||
|
i = j
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
j := i
|
||||||
|
for j < len(s) && isIdentByte(s[j]) {
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
if j > i {
|
||||||
|
name := s[i:j]
|
||||||
|
if val, ok := vars[name]; ok {
|
||||||
|
sb.WriteString(val)
|
||||||
|
} else {
|
||||||
|
sb.WriteByte('$')
|
||||||
|
sb.WriteString(name)
|
||||||
|
}
|
||||||
|
i = j
|
||||||
|
} else {
|
||||||
|
sb.WriteByte('$')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
// slugify converts a ref name to its GitLab slug form:
|
// 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 {
|
||||||
|
|||||||
+169
-19
@@ -9,21 +9,35 @@ import (
|
|||||||
// variable resolver.
|
// variable resolver.
|
||||||
//
|
//
|
||||||
// Supported:
|
// Supported:
|
||||||
// - Variable references: $VAR_NAME
|
// - Variable references: $VAR_NAME or ${VAR_NAME}
|
||||||
// - String literals: "value" or 'value'
|
// - String literals: "value" or 'value'
|
||||||
// - Null keyword: null
|
// - Null keyword: null
|
||||||
// - Comparison: == != =~ !~
|
// - Comparison: == != =~ !~ (single = is accepted as == for user convenience)
|
||||||
// - Boolean: && || !
|
// - Boolean: && || !
|
||||||
// - Grouping: ( )
|
// - Grouping: ( )
|
||||||
|
// - Regex flags: /pattern/i (case-insensitive), /pattern/m, /pattern/s
|
||||||
|
// - Multi-line: newlines between tokens are treated as whitespace
|
||||||
|
// - Variable regex RHS: $VAR =~ $PATTERN when $PATTERN holds a /regex/ string
|
||||||
//
|
//
|
||||||
// Regex patterns use Go's regexp syntax, which covers the common RE2 subset
|
// Regex patterns use Go's regexp syntax, which covers the common RE2 subset
|
||||||
// used by GitLab CI. Unsupported or unparseable expressions fall back to true
|
// used by GitLab CI. Unsupported or unparseable expressions fall back to true
|
||||||
// (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
|
||||||
}
|
}
|
||||||
@@ -56,8 +70,13 @@ func (p *exprParser) consume(tok string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *exprParser) skipWS() {
|
func (p *exprParser) skipWS() {
|
||||||
for p.pos < len(p.s) && (p.s[p.pos] == ' ' || p.s[p.pos] == '\t') {
|
for p.pos < len(p.s) {
|
||||||
|
b := p.s[p.pos]
|
||||||
|
if b == ' ' || b == '\t' || b == '\n' || b == '\r' {
|
||||||
p.pos++
|
p.pos++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,11 +86,11 @@ func (p *exprParser) skipWS() {
|
|||||||
// and_expr → not_expr ( '&&' not_expr )*
|
// and_expr → not_expr ( '&&' not_expr )*
|
||||||
// not_expr → '!' not_expr | primary
|
// not_expr → '!' not_expr | primary
|
||||||
// primary → '(' or_expr ')' | comparison
|
// primary → '(' or_expr ')' | comparison
|
||||||
// comparison → value ( op value | regex_op regex )? | value
|
// comparison → value ( op value | regex_op regex_rhs )?
|
||||||
// value → '$' ident | '"' … '"' | "'" … "'" | 'null'
|
// value → '$' '{' ident '}' | '$' ident | '"' … '"' | "'" … "'" | 'null'
|
||||||
// op → '==' | '!='
|
// op → '==' | '!='
|
||||||
// regex_op → '=~' | '!~'
|
// regex_op → '=~' | '!~'
|
||||||
// regex → '/' … '/'
|
// regex_rhs → '/' … '/' flags? | '$' ident (where ident value is '/…/flags')
|
||||||
|
|
||||||
func (p *exprParser) parseOr() (bool, bool) {
|
func (p *exprParser) parseOr() (bool, bool) {
|
||||||
left, ok := p.parseAnd()
|
left, ok := p.parseAnd()
|
||||||
@@ -165,8 +184,11 @@ func (p *exprParser) parseComparison() (bool, bool) {
|
|||||||
|
|
||||||
case p.consume("=~"):
|
case p.consume("=~"):
|
||||||
p.skipWS()
|
p.skipWS()
|
||||||
pat, ok := p.parseRegexLiteral()
|
pat, patOk, permissive := p.parseRegexRHS()
|
||||||
if !ok {
|
if permissive {
|
||||||
|
return true, true
|
||||||
|
}
|
||||||
|
if !patOk {
|
||||||
return false, false
|
return false, false
|
||||||
}
|
}
|
||||||
re, err := regexp.Compile(pat)
|
re, err := regexp.Compile(pat)
|
||||||
@@ -177,8 +199,11 @@ func (p *exprParser) parseComparison() (bool, bool) {
|
|||||||
|
|
||||||
case p.consume("!~"):
|
case p.consume("!~"):
|
||||||
p.skipWS()
|
p.skipWS()
|
||||||
pat, ok := p.parseRegexLiteral()
|
pat, patOk, permissive := p.parseRegexRHS()
|
||||||
if !ok {
|
if permissive {
|
||||||
|
return true, true
|
||||||
|
}
|
||||||
|
if !patOk {
|
||||||
return false, false
|
return false, false
|
||||||
}
|
}
|
||||||
re, err := regexp.Compile(pat)
|
re, err := regexp.Compile(pat)
|
||||||
@@ -186,19 +211,66 @@ 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).
|
||||||
return leftStr != "", true
|
return leftStr != "", true
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseValue reads $VAR, "string", 'string', or null.
|
// parseRegexRHS parses the right-hand side of =~ / !~ operators.
|
||||||
// null and undefined variables both produce an empty string.
|
// Returns (pattern, ok, permissive):
|
||||||
|
// - /regex/flags literal → (pattern, true, false)
|
||||||
|
// - $VAR whose value is /regex/flags → (pattern, true, false)
|
||||||
|
// - $VAR whose value is empty or not a /regex/ → ("", false, true) — caller uses permissive true
|
||||||
|
// - parse error → ("", false, false)
|
||||||
|
func (p *exprParser) parseRegexRHS() (pat string, ok bool, permissive bool) {
|
||||||
|
if p.peek() == '/' {
|
||||||
|
pat, ok = p.parseRegexLiteral()
|
||||||
|
return pat, ok, false
|
||||||
|
}
|
||||||
|
if p.peek() == '$' {
|
||||||
|
varVal, varOk := p.parseValue()
|
||||||
|
if !varOk {
|
||||||
|
return "", false, false
|
||||||
|
}
|
||||||
|
pat, ok = extractRegexFromString(varVal)
|
||||||
|
if !ok {
|
||||||
|
return "", false, true // variable is not a /regex/ value → permissive
|
||||||
|
}
|
||||||
|
return pat, true, false
|
||||||
|
}
|
||||||
|
return "", false, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseValue reads $VAR, ${VAR}, "string", 'string', null, true, false, or an
|
||||||
|
// integer literal. null and undefined variables both produce an empty string.
|
||||||
|
// true/false and integers produce their string representations (GitLab CI
|
||||||
|
// compares all values as strings).
|
||||||
func (p *exprParser) parseValue() (string, bool) {
|
func (p *exprParser) parseValue() (string, bool) {
|
||||||
p.skipWS()
|
p.skipWS()
|
||||||
|
|
||||||
if p.peek() == '$' {
|
if p.peek() == '$' {
|
||||||
p.pos++ // consume '$'
|
p.pos++ // consume '$'
|
||||||
|
if p.peek() == '{' {
|
||||||
|
p.pos++ // consume '{'
|
||||||
|
name := p.parseIdent()
|
||||||
|
if name == "" || p.peek() != '}' {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
p.pos++ // consume '}'
|
||||||
|
return p.vars(name), true
|
||||||
|
}
|
||||||
name := p.parseIdent()
|
name := p.parseIdent()
|
||||||
if name == "" {
|
if name == "" {
|
||||||
return "", false
|
return "", false
|
||||||
@@ -206,12 +278,18 @@ func (p *exprParser) parseValue() (string, bool) {
|
|||||||
return p.vars(name), true
|
return p.vars(name), true
|
||||||
}
|
}
|
||||||
|
|
||||||
// null keyword — must not be a prefix of a longer identifier.
|
// Keywords and string literals must not be prefixes of longer identifiers.
|
||||||
if p.startsWith("null") {
|
for _, kw := range []struct{ tok, val string }{
|
||||||
end := p.pos + 4
|
{"null", ""},
|
||||||
|
{"true", "true"},
|
||||||
|
{"false", "false"},
|
||||||
|
} {
|
||||||
|
if p.startsWith(kw.tok) {
|
||||||
|
end := p.pos + len(kw.tok)
|
||||||
if end >= len(p.s) || !isIdentByte(p.s[end]) {
|
if end >= len(p.s) || !isIdentByte(p.s[end]) {
|
||||||
p.pos += 4
|
p.pos += len(kw.tok)
|
||||||
return "", true // null → empty string
|
return kw.val, true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,6 +297,15 @@ func (p *exprParser) parseValue() (string, bool) {
|
|||||||
return p.parseStringLiteral()
|
return p.parseStringLiteral()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Integer literal — returned as its decimal string for string comparison.
|
||||||
|
if p.peek() >= '0' && p.peek() <= '9' {
|
||||||
|
start := p.pos
|
||||||
|
for p.pos < len(p.s) && p.s[p.pos] >= '0' && p.s[p.pos] <= '9' {
|
||||||
|
p.pos++
|
||||||
|
}
|
||||||
|
return p.s[start:p.pos], true
|
||||||
|
}
|
||||||
|
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,7 +348,8 @@ func (p *exprParser) parseRegexLiteral() (string, bool) {
|
|||||||
b := p.s[p.pos]
|
b := p.s[p.pos]
|
||||||
if b == '/' {
|
if b == '/' {
|
||||||
p.pos++ // consume closing '/'
|
p.pos++ // consume closing '/'
|
||||||
return sb.String(), true
|
flags := p.parseRegexFlags()
|
||||||
|
return applyRegexFlags(flags, sb.String()), true
|
||||||
}
|
}
|
||||||
if b == '\\' && p.pos+1 < len(p.s) {
|
if b == '\\' && p.pos+1 < len(p.s) {
|
||||||
p.pos++
|
p.pos++
|
||||||
@@ -275,6 +363,68 @@ func (p *exprParser) parseRegexLiteral() (string, bool) {
|
|||||||
return "", false // unterminated regex
|
return "", false // unterminated regex
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseRegexFlags reads zero or more regex flag letters (i, m, s) after the
|
||||||
|
// closing '/'. Unknown letters are consumed but ignored.
|
||||||
|
func (p *exprParser) parseRegexFlags() string {
|
||||||
|
start := p.pos
|
||||||
|
for p.pos < len(p.s) && isIdentByte(p.s[p.pos]) {
|
||||||
|
p.pos++
|
||||||
|
}
|
||||||
|
return p.s[start:p.pos]
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyRegexFlags prepends Go regexp flag groups to pattern (e.g. (?i) for 'i').
|
||||||
|
// Unknown flags are silently ignored.
|
||||||
|
func applyRegexFlags(flags, pattern string) string {
|
||||||
|
if flags == "" {
|
||||||
|
return pattern
|
||||||
|
}
|
||||||
|
var prefix strings.Builder
|
||||||
|
for _, f := range flags {
|
||||||
|
switch f {
|
||||||
|
case 'i':
|
||||||
|
prefix.WriteString("(?i)")
|
||||||
|
case 'm':
|
||||||
|
prefix.WriteString("(?m)")
|
||||||
|
case 's':
|
||||||
|
prefix.WriteString("(?s)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return prefix.String() + pattern
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractRegexFromString parses a /pattern/flags string (typically from a CI
|
||||||
|
// variable) and returns a Go regexp pattern with flags applied.
|
||||||
|
func extractRegexFromString(s string) (string, bool) {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if len(s) == 0 || s[0] != '/' {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
var sb strings.Builder
|
||||||
|
i := 1
|
||||||
|
for i < len(s) {
|
||||||
|
b := s[i]
|
||||||
|
if b == '/' {
|
||||||
|
i++ // past closing '/'
|
||||||
|
var flags strings.Builder
|
||||||
|
for i < len(s) && isIdentByte(s[i]) {
|
||||||
|
flags.WriteByte(s[i])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return applyRegexFlags(flags.String(), sb.String()), true
|
||||||
|
}
|
||||||
|
if b == '\\' && i+1 < len(s) {
|
||||||
|
i++
|
||||||
|
sb.WriteByte('\\')
|
||||||
|
sb.WriteByte(s[i])
|
||||||
|
} else {
|
||||||
|
sb.WriteByte(b)
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return "", false // unterminated
|
||||||
|
}
|
||||||
|
|
||||||
func isIdentByte(b byte) bool {
|
func isIdentByte(b byte) bool {
|
||||||
return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') || b == '_'
|
return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') || b == '_'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ func TestEvalIf(t *testing.T) {
|
|||||||
"CI_COMMIT_TAG": "",
|
"CI_COMMIT_TAG": "",
|
||||||
"CI_PIPELINE_SOURCE": "push",
|
"CI_PIPELINE_SOURCE": "push",
|
||||||
"DEPLOY_ENV": "staging",
|
"DEPLOY_ENV": "staging",
|
||||||
|
"BRANCH_PATTERN": "/^dev/",
|
||||||
|
"BRANCH_PATTERN_CI": "/^DEV/i",
|
||||||
|
"EMPTY_PATTERN": "",
|
||||||
|
"PLAIN_PATTERN": "develop",
|
||||||
}
|
}
|
||||||
return m[key]
|
return m[key]
|
||||||
}
|
}
|
||||||
@@ -69,9 +73,59 @@ func TestEvalIf(t *testing.T) {
|
|||||||
{"extra spaces", ` $CI_COMMIT_BRANCH == "develop" `, true},
|
{"extra spaces", ` $CI_COMMIT_BRANCH == "develop" `, true},
|
||||||
{"tabs", "$CI_COMMIT_BRANCH\t==\t\"develop\"", true},
|
{"tabs", "$CI_COMMIT_BRANCH\t==\t\"develop\"", true},
|
||||||
|
|
||||||
|
// ── Multi-line expressions (newlines between tokens) ──────────────────
|
||||||
|
{"multiline or true", "$CI_COMMIT_BRANCH == \"develop\" ||\n$CI_COMMIT_TAG != null", true},
|
||||||
|
{"multiline or false", "$CI_COMMIT_BRANCH == \"main\" ||\n$CI_COMMIT_TAG != null", false},
|
||||||
|
{"multiline and true", "$CI_COMMIT_BRANCH == \"develop\" &&\n$CI_PIPELINE_SOURCE == \"push\"", true},
|
||||||
|
{"multiline and false", "$CI_COMMIT_BRANCH == \"main\" &&\n$CI_PIPELINE_SOURCE == \"push\"", false},
|
||||||
|
{"multiline with crlf", "$CI_COMMIT_BRANCH == \"develop\" ||\r\n$CI_COMMIT_TAG != null", true},
|
||||||
|
|
||||||
|
// ── ${VAR} curly-brace syntax ─────────────────────────────────────────
|
||||||
|
{"curly var eq match", `${CI_COMMIT_BRANCH} == "develop"`, true},
|
||||||
|
{"curly var eq no match", `${CI_COMMIT_BRANCH} == "main"`, false},
|
||||||
|
{"curly var truthiness", `${CI_COMMIT_BRANCH}`, true},
|
||||||
|
{"curly var falsy", `${CI_COMMIT_TAG}`, false},
|
||||||
|
{"curly var neq null", `${CI_COMMIT_BRANCH} != null`, true},
|
||||||
|
{"curly mixed", `${CI_COMMIT_BRANCH} == "develop" && $CI_PIPELINE_SOURCE == "push"`, true},
|
||||||
|
|
||||||
|
// ── Regex flags (/pattern/i etc.) ─────────────────────────────────────
|
||||||
|
{"regex flag i match", `$CI_COMMIT_BRANCH =~ /^DEV/i`, true},
|
||||||
|
{"regex flag i no match", `$CI_COMMIT_BRANCH =~ /^MAIN/i`, false},
|
||||||
|
{"regex flag i not match", `$CI_COMMIT_BRANCH !~ /^MAIN/i`, true},
|
||||||
|
{"regex no flag case sensitive", `$CI_COMMIT_BRANCH =~ /^DEV/`, false},
|
||||||
|
{"regex flag i version tag", `$CI_PIPELINE_SOURCE =~ /^PUSH$/i`, true},
|
||||||
|
|
||||||
|
// ── Variable on right side of =~ ──────────────────────────────────────
|
||||||
|
{"var regex rhs match", `$CI_COMMIT_BRANCH =~ $BRANCH_PATTERN`, true},
|
||||||
|
{"var regex rhs no match", `$CI_PIPELINE_SOURCE =~ $BRANCH_PATTERN`, false},
|
||||||
|
{"var regex rhs ci flag match", `$CI_COMMIT_BRANCH =~ $BRANCH_PATTERN_CI`, true},
|
||||||
|
{"var regex rhs empty permissive", `$CI_COMMIT_BRANCH =~ $EMPTY_PATTERN`, true},
|
||||||
|
{"var regex rhs plain permissive", `$CI_COMMIT_BRANCH =~ $PLAIN_PATTERN`, true},
|
||||||
|
{"var regex rhs not match", `$CI_COMMIT_BRANCH !~ $BRANCH_PATTERN`, false},
|
||||||
|
|
||||||
|
// ── Bare true/false keywords ─────────────────────────────────────────
|
||||||
|
// GitLab CI treats true/false as the string values "true"/"false".
|
||||||
|
{"bare true match", `$CI_PIPELINE_SOURCE == true`, false}, // "push" != "true"
|
||||||
|
{"bare false match", `$CI_COMMIT_TAG == false`, false}, // "" != "false"
|
||||||
|
{"bare true var set to true", `$DEPLOY_ENV == true`, false}, // "staging" != "true"
|
||||||
|
{"bare false neq", `$CI_COMMIT_BRANCH != false`, true}, // "develop" != "false"
|
||||||
|
{"bare true in compound", `$CI_COMMIT_BRANCH != null && $CI_COMMIT_TAG == false`, false},
|
||||||
|
|
||||||
|
// ── Integer literals ──────────────────────────────────────────────────
|
||||||
|
// Compared as decimal strings (GitLab CI converts integers to strings).
|
||||||
|
{"int eq match", `$CI_PIPELINE_SOURCE != 0`, true}, // "push" != "0"
|
||||||
|
{"int eq no match", `$CI_COMMIT_TAG == 0`, false}, // "" != "0"
|
||||||
|
{"int in compound", `$CI_COMMIT_BRANCH != null && $CI_COMMIT_BRANCH != 0`, true},
|
||||||
|
|
||||||
// ── Permissive fallback ───────────────────────────────────────────────
|
// ── Permissive fallback ───────────────────────────────────────────────
|
||||||
{"unparseable returns true", `this is not valid syntax %%%`, true},
|
{"unparseable returns true", `this is not valid syntax %%%`, true},
|
||||||
{"empty expr returns true", ``, true},
|
{"empty expr returns true", ``, true},
|
||||||
|
|
||||||
|
// ── 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 {
|
||||||
@@ -83,3 +137,48 @@ func TestEvalIf(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEvalIfStrict(t *testing.T) {
|
||||||
|
vars := func(key string) string {
|
||||||
|
m := map[string]string{
|
||||||
|
"CI_COMMIT_BRANCH": "develop",
|
||||||
|
"CI_PIPELINE_SOURCE": "push",
|
||||||
|
"WORKFLOW": "",
|
||||||
|
}
|
||||||
|
return m[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
expr string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
// Parseable expressions behave identically to EvalIf.
|
||||||
|
{"parseable match", `$CI_COMMIT_BRANCH == "develop"`, true},
|
||||||
|
{"parseable no match", `$CI_COMMIT_BRANCH == "main"`, false},
|
||||||
|
{"single eq match", `$CI_COMMIT_BRANCH = "develop"`, true},
|
||||||
|
// Empty expression: ruleIfMatchesStrict handles the empty→true case
|
||||||
|
// before calling EvalIfStrict, so empty falls through to false here.
|
||||||
|
{"empty expr", ``, false},
|
||||||
|
|
||||||
|
// Unparseable expressions return false (strict) instead of true (permissive).
|
||||||
|
{"unparseable returns false", `this is not valid syntax %%%`, false},
|
||||||
|
|
||||||
|
// The key workflow-rule scenario: a complex condition with an
|
||||||
|
// unevaluable sub-expression should not match (strict=false) so that
|
||||||
|
// later workflow rules can be evaluated.
|
||||||
|
{"workflow rule complex no match", `$WORKFLOW = "gitflow" && $CI_PIPELINE_SOURCE == /(push|web)/`, false},
|
||||||
|
|
||||||
|
// Compound with a bad second operand: strict returns false.
|
||||||
|
{"and with bad rhs strict false", `$CI_COMMIT_BRANCH == "develop" && !(((`, false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := EvalIfStrict(tc.expr, vars)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("EvalIfStrict(%q) = %v, want %v", tc.expr, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,26 +28,34 @@ func (s JobState) String() string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// EvalWorkflow returns false when the pipeline's workflow:rules block would
|
// EvalWorkflow evaluates the pipeline's workflow:rules block against ctx.
|
||||||
// prevent any pipeline from starting in the given context.
|
// Returns (runs, ruleVars):
|
||||||
// Returns true when ctx is empty, when there is no workflow block, or when no
|
// - runs=false means the pipeline would not start for this context.
|
||||||
// rule is configured.
|
// - ruleVars holds any variables: defined on the matching rule; inject these
|
||||||
func EvalWorkflow(p *model.Pipeline, ctx *Context) bool {
|
// into the context so job rules can reference them.
|
||||||
|
//
|
||||||
|
// Returns (true, nil) when ctx is empty, when there is no workflow block, or
|
||||||
|
// when no rules are configured.
|
||||||
|
func EvalWorkflow(p *model.Pipeline, ctx *Context) (bool, map[string]string) {
|
||||||
if ctx.IsEmpty() || p.Workflow == nil || len(p.Workflow.Rules) == 0 {
|
if ctx.IsEmpty() || p.Workflow == nil || len(p.Workflow.Rules) == 0 {
|
||||||
return true
|
return true, nil
|
||||||
}
|
}
|
||||||
vars := ctx.Get
|
vars := ctx.Get
|
||||||
for _, rule := range p.Workflow.Rules {
|
for _, rule := range p.Workflow.Rules {
|
||||||
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
|
||||||
if when == "" {
|
if when == "" {
|
||||||
when = "always"
|
when = "always"
|
||||||
}
|
}
|
||||||
return when != "never"
|
return when != "never", ExtractStringVars(rule.Variables)
|
||||||
}
|
}
|
||||||
return false // no rule matched → pipeline does not run
|
return false, nil // no rule matched → pipeline does not run
|
||||||
}
|
}
|
||||||
|
|
||||||
// EvalJob returns the effective JobState for job in the given context.
|
// EvalJob returns the effective JobState for job in the given context.
|
||||||
@@ -90,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":
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
package cicontext
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.k3nny.fr/glint/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEvalWorkflow(t *testing.T) {
|
||||||
|
makePipeline := func(rules []model.Rule) *model.Pipeline {
|
||||||
|
return &model.Pipeline{Workflow: &model.Workflow{Rules: rules}}
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
rules []model.Rule
|
||||||
|
branch string
|
||||||
|
wantRuns bool
|
||||||
|
wantVars map[string]string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no workflow block",
|
||||||
|
rules: nil,
|
||||||
|
branch: "main",
|
||||||
|
wantRuns: true,
|
||||||
|
wantVars: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "matching rule runs always",
|
||||||
|
rules: []model.Rule{
|
||||||
|
{If: `$CI_COMMIT_BRANCH == "main"`, When: "always"},
|
||||||
|
},
|
||||||
|
branch: "main",
|
||||||
|
wantRuns: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "matching rule when never",
|
||||||
|
rules: []model.Rule{
|
||||||
|
{If: `$CI_COMMIT_BRANCH == "main"`, When: "never"},
|
||||||
|
},
|
||||||
|
branch: "main",
|
||||||
|
wantRuns: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no rule matched",
|
||||||
|
rules: []model.Rule{
|
||||||
|
{If: `$CI_COMMIT_BRANCH == "main"`},
|
||||||
|
},
|
||||||
|
branch: "develop",
|
||||||
|
wantRuns: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "matching rule with variables",
|
||||||
|
rules: []model.Rule{
|
||||||
|
{
|
||||||
|
If: `$CI_COMMIT_BRANCH == "main"`,
|
||||||
|
When: "always",
|
||||||
|
Variables: map[string]any{
|
||||||
|
"DEPLOY_TARGET": "production",
|
||||||
|
"ENVIRONMENT": "prod",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{When: "always"},
|
||||||
|
},
|
||||||
|
branch: "main",
|
||||||
|
wantRuns: true,
|
||||||
|
wantVars: map[string]string{
|
||||||
|
"DEPLOY_TARGET": "production",
|
||||||
|
"ENVIRONMENT": "prod",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fallback rule with different variables",
|
||||||
|
rules: []model.Rule{
|
||||||
|
{If: `$CI_COMMIT_BRANCH == "main"`, Variables: map[string]any{"DEPLOY_TARGET": "production"}},
|
||||||
|
{When: "always", Variables: map[string]any{"DEPLOY_TARGET": "staging"}},
|
||||||
|
},
|
||||||
|
branch: "develop",
|
||||||
|
wantRuns: true,
|
||||||
|
wantVars: map[string]string{"DEPLOY_TARGET": "staging"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty context always runs",
|
||||||
|
rules: []model.Rule{
|
||||||
|
{If: `$CI_COMMIT_BRANCH == "main"`, When: "never"},
|
||||||
|
},
|
||||||
|
branch: "", // no context
|
||||||
|
wantRuns: true,
|
||||||
|
wantVars: nil,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
var p *model.Pipeline
|
||||||
|
if tc.rules == nil {
|
||||||
|
p = &model.Pipeline{}
|
||||||
|
} else {
|
||||||
|
p = makePipeline(tc.rules)
|
||||||
|
}
|
||||||
|
ctx := New(tc.branch, "", "", nil)
|
||||||
|
runs, vars := EvalWorkflow(p, ctx)
|
||||||
|
if runs != tc.wantRuns {
|
||||||
|
t.Errorf("EvalWorkflow runs = %v, want %v", runs, tc.wantRuns)
|
||||||
|
}
|
||||||
|
for k, want := range tc.wantVars {
|
||||||
|
if got := vars[k]; got != want {
|
||||||
|
t.Errorf("EvalWorkflow vars[%q] = %q, want %q", k, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(vars) != len(tc.wantVars) {
|
||||||
|
t.Errorf("EvalWorkflow returned %d vars, want %d; got %v", len(vars), len(tc.wantVars), vars)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContextInject(t *testing.T) {
|
||||||
|
t.Run("inject does not overwrite pinned var", func(t *testing.T) {
|
||||||
|
ctx := New("main", "", "", []string{"DEPLOY_TARGET=override"})
|
||||||
|
ctx.Inject("DEPLOY_TARGET", "workflow-value")
|
||||||
|
if got := ctx.Get("DEPLOY_TARGET"); got != "override" {
|
||||||
|
t.Errorf("Inject overwrote pinned var: got %q, want %q", got, "override")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("inject does not overwrite shortcut var", func(t *testing.T) {
|
||||||
|
ctx := New("main", "", "", nil)
|
||||||
|
ctx.Inject("CI_COMMIT_BRANCH", "other")
|
||||||
|
if got := ctx.Get("CI_COMMIT_BRANCH"); got != "main" {
|
||||||
|
t.Errorf("Inject overwrote shortcut var: got %q, want %q", got, "main")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("inject sets new variable", func(t *testing.T) {
|
||||||
|
ctx := New("main", "", "", nil)
|
||||||
|
ctx.Inject("DEPLOY_TARGET", "production")
|
||||||
|
if got := ctx.Get("DEPLOY_TARGET"); got != "production" {
|
||||||
|
t.Errorf("Inject did not set variable: got %q", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("inject later call overrides earlier call", func(t *testing.T) {
|
||||||
|
ctx := New("main", "", "", nil)
|
||||||
|
ctx.Inject("DEPLOY_TARGET", "pipeline-default")
|
||||||
|
ctx.Inject("DEPLOY_TARGET", "workflow-override")
|
||||||
|
if got := ctx.Get("DEPLOY_TARGET"); got != "workflow-override" {
|
||||||
|
t.Errorf("second Inject did not win: got %q, want %q", got, "workflow-override")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractStringVars(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
in map[string]any
|
||||||
|
want map[string]string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "plain strings",
|
||||||
|
in: map[string]any{"A": "hello", "B": "world"},
|
||||||
|
want: map[string]string{"A": "hello", "B": "world"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "extended value form",
|
||||||
|
in: map[string]any{
|
||||||
|
"KEY": map[string]any{"value": "extended", "description": "some desc"},
|
||||||
|
},
|
||||||
|
want: map[string]string{"KEY": "extended"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mixed forms",
|
||||||
|
in: map[string]any{
|
||||||
|
"PLAIN": "str",
|
||||||
|
"COMPLEX": map[string]any{"value": "val"},
|
||||||
|
},
|
||||||
|
want: map[string]string{"PLAIN": "str", "COMPLEX": "val"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nil map",
|
||||||
|
in: nil,
|
||||||
|
want: nil,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := ExtractStringVars(tc.in)
|
||||||
|
for k, want := range tc.want {
|
||||||
|
if got[k] != want {
|
||||||
|
t.Errorf("ExtractStringVars[%q] = %q, want %q", k, got[k], want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(got) != len(tc.want) {
|
||||||
|
t.Errorf("ExtractStringVars returned %d entries, want %d", len(got), len(tc.want))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWorkflowVarsJobEval verifies the end-to-end flow: workflow rule injects
|
||||||
|
// DEPLOY_TARGET, which is then used in a job's rules:if: expression.
|
||||||
|
func TestWorkflowVarsJobEval(t *testing.T) {
|
||||||
|
rules := []model.Rule{
|
||||||
|
{If: `$CI_COMMIT_BRANCH == "main"`, Variables: map[string]any{"DEPLOY_TARGET": "production"}},
|
||||||
|
{When: "always", Variables: map[string]any{"DEPLOY_TARGET": "staging"}},
|
||||||
|
}
|
||||||
|
p := &model.Pipeline{
|
||||||
|
Workflow: &model.Workflow{Rules: rules},
|
||||||
|
Jobs: map[string]model.Job{
|
||||||
|
"deploy-prod": {
|
||||||
|
Rules: []model.Rule{
|
||||||
|
{If: `$DEPLOY_TARGET == "production"`, When: "on_success"},
|
||||||
|
{When: "never"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"deploy-staging": {
|
||||||
|
Rules: []model.Rule{
|
||||||
|
{If: `$DEPLOY_TARGET == "staging"`, When: "on_success"},
|
||||||
|
{When: "never"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
branch string
|
||||||
|
wantProd JobState
|
||||||
|
wantStaging JobState
|
||||||
|
}{
|
||||||
|
{"main", JobActive, JobSkipped},
|
||||||
|
{"develop", JobSkipped, JobActive},
|
||||||
|
} {
|
||||||
|
ctx := New(tc.branch, "", "", nil)
|
||||||
|
_, ruleVars := EvalWorkflow(p, ctx)
|
||||||
|
for k, v := range ruleVars {
|
||||||
|
ctx.Inject(k, v)
|
||||||
|
}
|
||||||
|
if got := EvalJob(p.Jobs["deploy-prod"], ctx); got != tc.wantProd {
|
||||||
|
t.Errorf("branch=%q deploy-prod = %v, want %v", tc.branch, got, tc.wantProd)
|
||||||
|
}
|
||||||
|
if got := EvalJob(p.Jobs["deploy-staging"], ctx); got != tc.wantStaging {
|
||||||
|
t.Errorf("branch=%q deploy-staging = %v, want %v", tc.branch, got, tc.wantStaging)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Filename is the name of the project-level glint configuration file.
|
||||||
|
const Filename = ".glint.yml"
|
||||||
|
|
||||||
|
// Config holds project-level glint configuration loaded from .glint.yml.
|
||||||
|
type Config struct {
|
||||||
|
// Ignore lists rule IDs to suppress entirely (e.g. ["GL007", "GL032"]).
|
||||||
|
Ignore []string `yaml:"ignore"`
|
||||||
|
|
||||||
|
// Severity maps rule IDs to overridden severity levels.
|
||||||
|
// Valid values: "error", "warning", "ignore".
|
||||||
|
// "ignore" is equivalent to listing the rule in Ignore.
|
||||||
|
Severity map[string]string `yaml:"severity"`
|
||||||
|
|
||||||
|
// Stages lists additional stage names that are considered valid for this
|
||||||
|
// project, beyond what is declared in the pipeline's own stages: block.
|
||||||
|
// Jobs in these stages are not flagged by GL004.
|
||||||
|
Stages []string `yaml:"stages"`
|
||||||
|
|
||||||
|
// Token is a default GitLab personal access token used when neither the
|
||||||
|
// --token flag nor GITLAB_TOKEN (/ CI_JOB_TOKEN / GITLAB_PRIVATE_TOKEN)
|
||||||
|
// environment variables are set.
|
||||||
|
Token string `yaml:"token"`
|
||||||
|
|
||||||
|
// URL is the default GitLab instance URL. Overridden by --gitlab-url and
|
||||||
|
// the CI_SERVER_URL / GITLAB_URL environment variables.
|
||||||
|
URL string `yaml:"url"`
|
||||||
|
|
||||||
|
// CacheDir is the default directory for caching fetched remote includes.
|
||||||
|
// Overridden by the --cache-dir flag.
|
||||||
|
CacheDir string `yaml:"cache_dir"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load searches for a .glint.yml file starting from dir and walking up toward
|
||||||
|
// the filesystem root. The walk stops at the first .git directory found (the
|
||||||
|
// repository root) or at the filesystem root. Returns an empty Config (and no
|
||||||
|
// error) when no config file is found.
|
||||||
|
func Load(dir string) (Config, error) {
|
||||||
|
dir = filepath.Clean(dir)
|
||||||
|
for {
|
||||||
|
candidate := filepath.Join(dir, Filename)
|
||||||
|
data, err := os.ReadFile(candidate)
|
||||||
|
if err == nil {
|
||||||
|
var cfg Config
|
||||||
|
if yerr := yaml.Unmarshal(data, &cfg); yerr != nil {
|
||||||
|
return Config{}, fmt.Errorf("parsing %s: %w", candidate, yerr)
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
if !os.IsNotExist(err) {
|
||||||
|
return Config{}, fmt.Errorf("reading %s: %w", candidate, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop when we reach a git root so we don't wander into parent repos.
|
||||||
|
if _, serr := os.Stat(filepath.Join(dir, ".git")); serr == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
parent := filepath.Dir(dir)
|
||||||
|
if parent == dir {
|
||||||
|
break // filesystem root
|
||||||
|
}
|
||||||
|
dir = parent
|
||||||
|
}
|
||||||
|
return Config{}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoad_NotFound(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
cfg, err := Load(tmp)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(cfg.Ignore) != 0 || cfg.Token != "" || cfg.URL != "" {
|
||||||
|
t.Errorf("expected empty config, got %+v", cfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoad_Found(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
content := `
|
||||||
|
ignore:
|
||||||
|
- GL007
|
||||||
|
- GL032
|
||||||
|
severity:
|
||||||
|
GL004: warning
|
||||||
|
stages:
|
||||||
|
- quality
|
||||||
|
token: glpat-test
|
||||||
|
url: https://gitlab.example.com
|
||||||
|
cache_dir: /tmp/glint-cache
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(filepath.Join(tmp, Filename), []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg, err := Load(tmp)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if len(cfg.Ignore) != 2 || cfg.Ignore[0] != "GL007" {
|
||||||
|
t.Errorf("ignore = %v", cfg.Ignore)
|
||||||
|
}
|
||||||
|
if cfg.Severity["GL004"] != "warning" {
|
||||||
|
t.Errorf("severity = %v", cfg.Severity)
|
||||||
|
}
|
||||||
|
if len(cfg.Stages) != 1 || cfg.Stages[0] != "quality" {
|
||||||
|
t.Errorf("stages = %v", cfg.Stages)
|
||||||
|
}
|
||||||
|
if cfg.Token != "glpat-test" {
|
||||||
|
t.Errorf("token = %q", cfg.Token)
|
||||||
|
}
|
||||||
|
if cfg.URL != "https://gitlab.example.com" {
|
||||||
|
t.Errorf("url = %q", cfg.URL)
|
||||||
|
}
|
||||||
|
if cfg.CacheDir != "/tmp/glint-cache" {
|
||||||
|
t.Errorf("cache_dir = %q", cfg.CacheDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoad_WalksUp(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
sub := filepath.Join(tmp, "subdir", "pipeline")
|
||||||
|
if err := os.MkdirAll(sub, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Config at the top-level (no .git, so walk continues to tmp).
|
||||||
|
if err := os.WriteFile(filepath.Join(tmp, Filename), []byte("token: walked-up\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg, err := Load(sub)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Token != "walked-up" {
|
||||||
|
t.Errorf("token = %q, want walked-up", cfg.Token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoad_StopsAtGitRoot(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
sub := filepath.Join(tmp, "repo", "src")
|
||||||
|
if err := os.MkdirAll(sub, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// .git marks the repo root — walk must stop here.
|
||||||
|
if err := os.Mkdir(filepath.Join(tmp, "repo", ".git"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Config placed ABOVE the .git root should not be found.
|
||||||
|
if err := os.WriteFile(filepath.Join(tmp, Filename), []byte("token: should-not-load\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg, err := Load(sub)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Token != "" {
|
||||||
|
t.Errorf("token = %q, should not have crossed .git boundary", cfg.Token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoad_InvalidYAML(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(tmp, Filename), []byte("ignore: [unclosed\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err := Load(tmp)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for invalid YAML")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -25,6 +25,8 @@ type GitLabConfig struct {
|
|||||||
BaseURL string
|
BaseURL string
|
||||||
Token string
|
Token string
|
||||||
Source TokenSource
|
Source TokenSource
|
||||||
|
CacheDir string // local cache directory; empty = caching disabled
|
||||||
|
Offline bool // when true, return an error instead of making network calls
|
||||||
}
|
}
|
||||||
|
|
||||||
// AutoConfig builds a GitLabConfig from environment variables.
|
// AutoConfig builds a GitLabConfig from environment variables.
|
||||||
@@ -54,8 +56,11 @@ func AutoConfig() GitLabConfig {
|
|||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithOverrides returns a copy of cfg with non-empty overrides applied.
|
// WithOverrides returns a copy of cfg with the provided overrides applied.
|
||||||
func (cfg GitLabConfig) WithOverrides(baseURL, token string) GitLabConfig {
|
// Non-empty strings overwrite the corresponding field; booleans are always
|
||||||
|
// applied (so offline: false explicitly clears offline mode).
|
||||||
|
// cacheDir: empty string disables caching.
|
||||||
|
func (cfg GitLabConfig) WithOverrides(baseURL, token, cacheDir string, offline bool) GitLabConfig {
|
||||||
if baseURL != "" {
|
if baseURL != "" {
|
||||||
cfg.BaseURL = strings.TrimRight(baseURL, "/")
|
cfg.BaseURL = strings.TrimRight(baseURL, "/")
|
||||||
}
|
}
|
||||||
@@ -63,6 +68,8 @@ func (cfg GitLabConfig) WithOverrides(baseURL, token string) GitLabConfig {
|
|||||||
cfg.Token = token
|
cfg.Token = token
|
||||||
cfg.Source = TokenPrivate
|
cfg.Source = TokenPrivate
|
||||||
}
|
}
|
||||||
|
cfg.CacheDir = cacheDir
|
||||||
|
cfg.Offline = offline
|
||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,16 +91,24 @@ func (cfg GitLabConfig) HasToken() bool { return cfg.Token != "" }
|
|||||||
// - filePath — repository file path, e.g. "/templates/ci.yml"
|
// - filePath — repository file path, e.g. "/templates/ci.yml"
|
||||||
// - ref — branch, tag, or commit SHA; empty string defaults to HEAD
|
// - ref — branch, tag, or commit SHA; empty string defaults to HEAD
|
||||||
func (cfg GitLabConfig) FetchFile(project, filePath, ref string) ([]byte, error) {
|
func (cfg GitLabConfig) FetchFile(project, filePath, ref string) ([]byte, error) {
|
||||||
|
if ref == "" {
|
||||||
|
ref = "HEAD"
|
||||||
|
}
|
||||||
|
|
||||||
|
cKey := cfg.BaseURL + "|" + project + "|" + filePath + "|" + ref
|
||||||
|
if data, ok := cacheRead(cfg.CacheDir, cKey); ok {
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
if cfg.Offline {
|
||||||
|
return nil, fmt.Errorf("offline mode: %s:%s@%s is not in the local cache (run without --offline first to populate the cache)", project, filePath, ref)
|
||||||
|
}
|
||||||
|
|
||||||
encodedProject := url.PathEscape(project)
|
encodedProject := url.PathEscape(project)
|
||||||
encodedFile := url.PathEscape(strings.TrimPrefix(filePath, "/"))
|
encodedFile := url.PathEscape(strings.TrimPrefix(filePath, "/"))
|
||||||
|
|
||||||
apiURL := fmt.Sprintf("%s/api/v4/projects/%s/repository/files/%s/raw",
|
apiURL := fmt.Sprintf("%s/api/v4/projects/%s/repository/files/%s/raw",
|
||||||
cfg.BaseURL, encodedProject, encodedFile)
|
cfg.BaseURL, encodedProject, encodedFile)
|
||||||
|
|
||||||
if ref == "" {
|
|
||||||
ref = "HEAD"
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequest(http.MethodGet, apiURL, nil)
|
req, err := http.NewRequest(http.MethodGet, apiURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("building request: %w", err)
|
return nil, fmt.Errorf("building request: %w", err)
|
||||||
@@ -139,6 +154,34 @@ func (cfg GitLabConfig) FetchFile(project, filePath, ref string) ([]byte, error)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cacheWrite(cfg.CacheDir, cKey, body)
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchURL downloads the content at a plain HTTPS URL without authentication.
|
||||||
|
// Used for include: remote: entries which are public by definition.
|
||||||
|
// 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
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("GET %s: %w", rawURL, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading body: %w", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("GET %s: status %d", rawURL, resp.StatusCode)
|
||||||
|
}
|
||||||
|
cacheWrite(cfg.CacheDir, rawURL, body)
|
||||||
return body, nil
|
return body, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.k3nny.fr/glint/internal/fetcher"
|
"git.k3nny.fr/glint/internal/fetcher"
|
||||||
@@ -11,6 +12,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Includes returns a Mermaid flowchart of the full include dependency tree.
|
// Includes returns a Mermaid flowchart of the full include dependency tree.
|
||||||
|
// Each node shows the jobs defined directly in that file, connected with
|
||||||
|
// dashed arrows (solid arrows represent the include hierarchy itself).
|
||||||
// It recurses into project:, component:, and local: includes to expose
|
// It recurses into project:, component:, and local: includes to expose
|
||||||
// transitive dependencies. Includes that cannot be fetched are shown but not expanded.
|
// transitive dependencies. Includes that cannot be fetched are shown but not expanded.
|
||||||
func Includes(sourcePath string, rawIncludes []any, cfg fetcher.GitLabConfig) string {
|
func Includes(sourcePath string, rawIncludes []any, cfg fetcher.GitLabConfig) string {
|
||||||
@@ -19,7 +22,12 @@ func Includes(sourcePath string, rawIncludes []any, cfg fetcher.GitLabConfig) st
|
|||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
baseDir: filepath.Dir(sourcePath),
|
baseDir: filepath.Dir(sourcePath),
|
||||||
}
|
}
|
||||||
root := &treeNode{id: "root", label: mermaidLabel(sourcePath), class: "main"}
|
root := &treeNode{
|
||||||
|
id: "root",
|
||||||
|
label: mermaidLabel(sourcePath),
|
||||||
|
class: "main",
|
||||||
|
jobs: directJobs(sourcePath),
|
||||||
|
}
|
||||||
b.buildChildren(root, rawIncludes)
|
b.buildChildren(root, rawIncludes)
|
||||||
return renderTree(root)
|
return renderTree(root)
|
||||||
}
|
}
|
||||||
@@ -29,6 +37,7 @@ type treeNode struct {
|
|||||||
label string
|
label string
|
||||||
class string
|
class string
|
||||||
children []*treeNode
|
children []*treeNode
|
||||||
|
jobs []string // job names defined directly in this file
|
||||||
}
|
}
|
||||||
|
|
||||||
// treeBuilder accumulates state while recursively traversing include entries.
|
// treeBuilder accumulates state while recursively traversing include entries.
|
||||||
@@ -46,9 +55,7 @@ func (b *treeBuilder) nextID() string {
|
|||||||
|
|
||||||
func (b *treeBuilder) buildChildren(parent *treeNode, rawIncludes []any) {
|
func (b *treeBuilder) buildChildren(parent *treeNode, rawIncludes []any) {
|
||||||
for _, entry := range rawIncludes {
|
for _, entry := range rawIncludes {
|
||||||
for _, child := range b.parseEntry(entry) {
|
parent.children = append(parent.children, b.parseEntry(entry)...)
|
||||||
parent.children = append(parent.children, child)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,7 +112,9 @@ func (b *treeBuilder) parseMap(m map[string]any) []*treeNode {
|
|||||||
return []*treeNode{node}
|
return []*treeNode{node}
|
||||||
}
|
}
|
||||||
if remote, ok := m["remote"].(string); ok {
|
if remote, ok := m["remote"].(string); ok {
|
||||||
return []*treeNode{{id: b.nextID(), label: "remote:<br>" + mermaidLabel(remote), class: "remote"}}
|
node := &treeNode{id: b.nextID(), label: "remote:<br>" + mermaidLabel(remote), class: "remote"}
|
||||||
|
b.recurseRemote(node, remote)
|
||||||
|
return []*treeNode{node}
|
||||||
}
|
}
|
||||||
if tmpl, ok := m["template"].(string); ok {
|
if tmpl, ok := m["template"].(string); ok {
|
||||||
return []*treeNode{{id: b.nextID(), label: "template:<br>" + mermaidLabel(tmpl), class: "template"}}
|
return []*treeNode{{id: b.nextID(), label: "template:<br>" + mermaidLabel(tmpl), class: "template"}}
|
||||||
@@ -126,7 +135,11 @@ func (b *treeBuilder) recurseLocal(node *treeNode, path string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
p, err := model.ParseBytes(data)
|
p, err := model.ParseBytes(data)
|
||||||
if err != nil || len(p.Include) == 0 {
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
node.jobs = jobNames(p)
|
||||||
|
if len(p.Include) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
orig := b.baseDir
|
orig := b.baseDir
|
||||||
@@ -147,7 +160,33 @@ func (b *treeBuilder) recurseProject(node *treeNode, project, filePath, ref stri
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
p, err := model.ParseBytes(data)
|
p, err := model.ParseBytes(data)
|
||||||
if err != nil || len(p.Include) == 0 {
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
node.jobs = jobNames(p)
|
||||||
|
if len(p.Include) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.buildChildren(node, p.Include)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *treeBuilder) recurseRemote(node *treeNode, rawURL string) {
|
||||||
|
key := "remote:" + rawURL
|
||||||
|
if b.visited[key] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.visited[key] = true
|
||||||
|
|
||||||
|
data, err := b.cfg.FetchURL(rawURL)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p, err := model.ParseBytes(data)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
node.jobs = jobNames(p)
|
||||||
|
if len(p.Include) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
b.buildChildren(node, p.Include)
|
b.buildChildren(node, p.Include)
|
||||||
@@ -172,7 +211,11 @@ func (b *treeBuilder) recurseComponent(node *treeNode, ref string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
p, err := model.ParseBytes(data)
|
p, err := model.ParseBytes(data)
|
||||||
if err != nil || len(p.Include) == 0 {
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
node.jobs = jobNames(p)
|
||||||
|
if len(p.Include) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
b.buildChildren(node, p.Include)
|
b.buildChildren(node, p.Include)
|
||||||
@@ -193,11 +236,19 @@ func renderTree(root *treeNode) string {
|
|||||||
w(" classDef local fill:#428fdc,stroke:#1068bf,color:#fff")
|
w(" classDef local fill:#428fdc,stroke:#1068bf,color:#fff")
|
||||||
w(" classDef remote fill:#868686,stroke:#686868,color:#fff")
|
w(" classDef remote fill:#868686,stroke:#686868,color:#fff")
|
||||||
w(" classDef template fill:#fc6d26,stroke:#e56b1f,color:#fff")
|
w(" classDef template fill:#fc6d26,stroke:#e56b1f,color:#fff")
|
||||||
|
w(" classDef job fill:#f5f5ff,stroke:#7175a0,color:#333")
|
||||||
w("")
|
w("")
|
||||||
|
|
||||||
|
jobCounter := 0
|
||||||
var emit func(n *treeNode)
|
var emit func(n *treeNode)
|
||||||
emit = func(n *treeNode) {
|
emit = func(n *treeNode) {
|
||||||
wf(" %s[\"%s\"]:::%s", n.id, n.label, n.class)
|
wf(" %s[\"%s\"]:::%s", n.id, n.label, n.class)
|
||||||
|
for _, jobName := range n.jobs {
|
||||||
|
jobCounter++
|
||||||
|
jobID := fmt.Sprintf("job%d", jobCounter)
|
||||||
|
wf(" %s(\"%s\"):::job", jobID, mermaidLabel(jobName))
|
||||||
|
wf(" %s -.-> %s", n.id, jobID)
|
||||||
|
}
|
||||||
for _, child := range n.children {
|
for _, child := range n.children {
|
||||||
emit(child)
|
emit(child)
|
||||||
wf(" %s --> %s", n.id, child.id)
|
wf(" %s --> %s", n.id, child.id)
|
||||||
@@ -208,6 +259,29 @@ func renderTree(root *treeNode) string {
|
|||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// directJobs parses a single pipeline file (without include resolution) and
|
||||||
|
// returns the sorted list of job names defined directly in it.
|
||||||
|
func directJobs(path string) []string {
|
||||||
|
p, err := model.Parse(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return jobNames(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// jobNames returns a sorted slice of all job names defined in p.
|
||||||
|
func jobNames(p *model.Pipeline) []string {
|
||||||
|
if len(p.Jobs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
names := make([]string, 0, len(p.Jobs))
|
||||||
|
for name := range p.Jobs {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
// parseComponentRef parses a CI/CD component reference of the form
|
// parseComponentRef parses a CI/CD component reference of the form
|
||||||
// <host>/<project-path>/<component-name>@<version>.
|
// <host>/<project-path>/<component-name>@<version>.
|
||||||
func parseComponentRef(ref string) (host, project, component, version string, err error) {
|
func parseComponentRef(ref string) (host, project, component, version string, err error) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,7 +23,10 @@ func checkDependencies(p *model.Pipeline) []Finding {
|
|||||||
if !exists {
|
if !exists {
|
||||||
findings = append(findings, Finding{
|
findings = append(findings, Finding{
|
||||||
Severity: Error,
|
Severity: Error,
|
||||||
|
Rule: RuleUnknownDependency,
|
||||||
Job: name,
|
Job: name,
|
||||||
|
File: job.File,
|
||||||
|
Line: job.Line,
|
||||||
Message: fmt.Sprintf("'dependencies' references unknown job %q", dep),
|
Message: fmt.Sprintf("'dependencies' references unknown job %q", dep),
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
@@ -33,7 +36,10 @@ func checkDependencies(p *model.Pipeline) []Finding {
|
|||||||
if depHasStage && depIdx >= jobStageIdx {
|
if depHasStage && depIdx >= jobStageIdx {
|
||||||
findings = append(findings, Finding{
|
findings = append(findings, Finding{
|
||||||
Severity: Error,
|
Severity: Error,
|
||||||
|
Rule: RuleDependencyStage,
|
||||||
Job: name,
|
Job: name,
|
||||||
|
File: job.File,
|
||||||
|
Line: job.Line,
|
||||||
Message: fmt.Sprintf("'dependencies' job %q must be in an earlier stage (in %q, current job is in %q)", dep, depJob.Stage, job.Stage),
|
Message: fmt.Sprintf("'dependencies' job %q must be in an earlier stage (in %q, current job is in %q)", dep, depJob.Stage, job.Stage),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+359
-5
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,6 +122,7 @@ func checkWhen(name string, job model.Job) []Finding {
|
|||||||
if job.When != "" && !validJobWhen[job.When] {
|
if job.When != "" && !validJobWhen[job.When] {
|
||||||
findings = append(findings, Finding{
|
findings = append(findings, Finding{
|
||||||
Severity: Error,
|
Severity: Error,
|
||||||
|
Rule: RuleInvalidWhen,
|
||||||
Job: name,
|
Job: name,
|
||||||
Message: fmt.Sprintf("'when' has invalid value %q; valid: on_success, on_failure, always, manual, delayed, never", job.When),
|
Message: fmt.Sprintf("'when' has invalid value %q; valid: on_success, on_failure, always, manual, delayed, never", job.When),
|
||||||
})
|
})
|
||||||
@@ -112,6 +130,7 @@ func checkWhen(name string, job model.Job) []Finding {
|
|||||||
if job.When == "delayed" && job.StartIn == "" {
|
if job.When == "delayed" && job.StartIn == "" {
|
||||||
findings = append(findings, Finding{
|
findings = append(findings, Finding{
|
||||||
Severity: Error,
|
Severity: Error,
|
||||||
|
Rule: RuleDelayedNoStartIn,
|
||||||
Job: name,
|
Job: name,
|
||||||
Message: "'when: delayed' requires 'start_in' (e.g. 'start_in: 30 minutes')",
|
Message: "'when: delayed' requires 'start_in' (e.g. 'start_in: 30 minutes')",
|
||||||
})
|
})
|
||||||
@@ -119,6 +138,7 @@ func checkWhen(name string, job model.Job) []Finding {
|
|||||||
if job.When != "delayed" && job.StartIn != "" {
|
if job.When != "delayed" && job.StartIn != "" {
|
||||||
findings = append(findings, Finding{
|
findings = append(findings, Finding{
|
||||||
Severity: Error,
|
Severity: Error,
|
||||||
|
Rule: RuleStartInNoDelayed,
|
||||||
Job: name,
|
Job: name,
|
||||||
Message: "'start_in' is only valid when 'when: delayed'",
|
Message: "'start_in' is only valid when 'when: delayed'",
|
||||||
})
|
})
|
||||||
@@ -135,6 +155,7 @@ func checkParallel(name string, job model.Job) []Finding {
|
|||||||
if v < 2 || v > 200 {
|
if v < 2 || v > 200 {
|
||||||
return []Finding{{
|
return []Finding{{
|
||||||
Severity: Error,
|
Severity: Error,
|
||||||
|
Rule: RuleInvalidParallel,
|
||||||
Job: name,
|
Job: name,
|
||||||
Message: fmt.Sprintf("'parallel' must be between 2 and 200, got %d", v),
|
Message: fmt.Sprintf("'parallel' must be between 2 and 200, got %d", v),
|
||||||
}}
|
}}
|
||||||
@@ -143,6 +164,7 @@ func checkParallel(name string, job model.Job) []Finding {
|
|||||||
if _, ok := v["matrix"]; !ok {
|
if _, ok := v["matrix"]; !ok {
|
||||||
return []Finding{{
|
return []Finding{{
|
||||||
Severity: Error,
|
Severity: Error,
|
||||||
|
Rule: RuleInvalidParallel,
|
||||||
Job: name,
|
Job: name,
|
||||||
Message: "'parallel' map form must have a 'matrix' key",
|
Message: "'parallel' map form must have a 'matrix' key",
|
||||||
}}
|
}}
|
||||||
@@ -150,6 +172,7 @@ func checkParallel(name string, job model.Job) []Finding {
|
|||||||
default:
|
default:
|
||||||
return []Finding{{
|
return []Finding{{
|
||||||
Severity: Error,
|
Severity: Error,
|
||||||
|
Rule: RuleInvalidParallel,
|
||||||
Job: name,
|
Job: name,
|
||||||
Message: "'parallel' must be an integer (2–200) or a map with 'matrix'",
|
Message: "'parallel' must be an integer (2–200) or a map with 'matrix'",
|
||||||
}}
|
}}
|
||||||
@@ -166,6 +189,7 @@ func checkRetry(name string, job model.Job) []Finding {
|
|||||||
if v < 0 || v > 2 {
|
if v < 0 || v > 2 {
|
||||||
return []Finding{{
|
return []Finding{{
|
||||||
Severity: Error,
|
Severity: Error,
|
||||||
|
Rule: RuleInvalidRetry,
|
||||||
Job: name,
|
Job: name,
|
||||||
Message: fmt.Sprintf("'retry' must be 0, 1, or 2; got %d", v),
|
Message: fmt.Sprintf("'retry' must be 0, 1, or 2; got %d", v),
|
||||||
}}
|
}}
|
||||||
@@ -176,6 +200,7 @@ func checkRetry(name string, job model.Job) []Finding {
|
|||||||
if n, ok := maxVal.(int); ok && (n < 0 || n > 2) {
|
if n, ok := maxVal.(int); ok && (n < 0 || n > 2) {
|
||||||
findings = append(findings, Finding{
|
findings = append(findings, Finding{
|
||||||
Severity: Error,
|
Severity: Error,
|
||||||
|
Rule: RuleInvalidRetry,
|
||||||
Job: name,
|
Job: name,
|
||||||
Message: fmt.Sprintf("'retry.max' must be 0, 1, or 2; got %d", n),
|
Message: fmt.Sprintf("'retry.max' must be 0, 1, or 2; got %d", n),
|
||||||
})
|
})
|
||||||
@@ -188,6 +213,7 @@ func checkRetry(name string, job model.Job) []Finding {
|
|||||||
default:
|
default:
|
||||||
return []Finding{{
|
return []Finding{{
|
||||||
Severity: Error,
|
Severity: Error,
|
||||||
|
Rule: RuleInvalidRetry,
|
||||||
Job: name,
|
Job: name,
|
||||||
Message: "'retry' must be an integer (0–2) or a map with 'max'/'when'",
|
Message: "'retry' must be an integer (0–2) or a map with 'max'/'when'",
|
||||||
}}
|
}}
|
||||||
@@ -201,6 +227,7 @@ func validateRetryWhen(name string, val any) []Finding {
|
|||||||
if !validRetryWhen[s] {
|
if !validRetryWhen[s] {
|
||||||
findings = append(findings, Finding{
|
findings = append(findings, Finding{
|
||||||
Severity: Error,
|
Severity: Error,
|
||||||
|
Rule: RuleInvalidRetryWhen,
|
||||||
Job: name,
|
Job: name,
|
||||||
Message: fmt.Sprintf("'retry.when' has invalid value %q", s),
|
Message: fmt.Sprintf("'retry.when' has invalid value %q", s),
|
||||||
})
|
})
|
||||||
@@ -230,6 +257,7 @@ func checkAllowFailure(name string, job model.Job) []Finding {
|
|||||||
if _, ok := v["exit_codes"]; !ok {
|
if _, ok := v["exit_codes"]; !ok {
|
||||||
return []Finding{{
|
return []Finding{{
|
||||||
Severity: Error,
|
Severity: Error,
|
||||||
|
Rule: RuleInvalidAllowFailure,
|
||||||
Job: name,
|
Job: name,
|
||||||
Message: "'allow_failure' map form must contain 'exit_codes'",
|
Message: "'allow_failure' map form must contain 'exit_codes'",
|
||||||
}}
|
}}
|
||||||
@@ -238,6 +266,7 @@ func checkAllowFailure(name string, job model.Job) []Finding {
|
|||||||
_ = v
|
_ = v
|
||||||
return []Finding{{
|
return []Finding{{
|
||||||
Severity: Error,
|
Severity: Error,
|
||||||
|
Rule: RuleInvalidAllowFailure,
|
||||||
Job: name,
|
Job: name,
|
||||||
Message: "'allow_failure' must be a boolean or a map with 'exit_codes'",
|
Message: "'allow_failure' must be a boolean or a map with 'exit_codes'",
|
||||||
}}
|
}}
|
||||||
@@ -252,6 +281,7 @@ func checkInterruptible(name string, job model.Job) []Finding {
|
|||||||
if _, ok := job.Interruptible.(bool); !ok {
|
if _, ok := job.Interruptible.(bool); !ok {
|
||||||
return []Finding{{
|
return []Finding{{
|
||||||
Severity: Error,
|
Severity: Error,
|
||||||
|
Rule: RuleInvalidInterruptible,
|
||||||
Job: name,
|
Job: name,
|
||||||
Message: "'interruptible' must be a boolean",
|
Message: "'interruptible' must be a boolean",
|
||||||
}}
|
}}
|
||||||
@@ -267,6 +297,7 @@ func checkTrigger(name string, job model.Job) []Finding {
|
|||||||
if scriptNonEmpty(job.Script) {
|
if scriptNonEmpty(job.Script) {
|
||||||
findings = append(findings, Finding{
|
findings = append(findings, Finding{
|
||||||
Severity: Error,
|
Severity: Error,
|
||||||
|
Rule: RuleTriggerWithScript,
|
||||||
Job: name,
|
Job: name,
|
||||||
Message: "jobs with 'trigger' cannot use 'script'",
|
Message: "jobs with 'trigger' cannot use 'script'",
|
||||||
})
|
})
|
||||||
@@ -277,6 +308,7 @@ func checkTrigger(name string, job model.Job) []Finding {
|
|||||||
if !hasProject && !hasInclude {
|
if !hasProject && !hasInclude {
|
||||||
findings = append(findings, Finding{
|
findings = append(findings, Finding{
|
||||||
Severity: Error,
|
Severity: Error,
|
||||||
|
Rule: RuleInvalidTrigger,
|
||||||
Job: name,
|
Job: name,
|
||||||
Message: "'trigger' map must specify 'project' or 'include'",
|
Message: "'trigger' map must specify 'project' or 'include'",
|
||||||
})
|
})
|
||||||
@@ -294,6 +326,7 @@ func checkCoverage(name string, job model.Job) []Finding {
|
|||||||
if !coveragePattern.MatchString(job.Coverage) {
|
if !coveragePattern.MatchString(job.Coverage) {
|
||||||
return []Finding{{
|
return []Finding{{
|
||||||
Severity: Error,
|
Severity: Error,
|
||||||
|
Rule: RuleInvalidCoverage,
|
||||||
Job: name,
|
Job: name,
|
||||||
Message: fmt.Sprintf("'coverage' must be a regex pattern wrapped in '/' (e.g. '/\\d+\\.?\\d*%%/'), got %q", job.Coverage),
|
Message: fmt.Sprintf("'coverage' must be a regex pattern wrapped in '/' (e.g. '/\\d+\\.?\\d*%%/'), got %q", job.Coverage),
|
||||||
}}
|
}}
|
||||||
@@ -309,6 +342,7 @@ func checkRelease(name string, job model.Job) []Finding {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return []Finding{{
|
return []Finding{{
|
||||||
Severity: Error,
|
Severity: Error,
|
||||||
|
Rule: RuleInvalidRelease,
|
||||||
Job: name,
|
Job: name,
|
||||||
Message: "'release' must be a map",
|
Message: "'release' must be a map",
|
||||||
}}
|
}}
|
||||||
@@ -317,6 +351,7 @@ func checkRelease(name string, job model.Job) []Finding {
|
|||||||
if !exists || tagName == "" || tagName == nil {
|
if !exists || tagName == "" || tagName == nil {
|
||||||
return []Finding{{
|
return []Finding{{
|
||||||
Severity: Error,
|
Severity: Error,
|
||||||
|
Rule: RuleInvalidRelease,
|
||||||
Job: name,
|
Job: name,
|
||||||
Message: "'release' requires 'tag_name'",
|
Message: "'release' requires 'tag_name'",
|
||||||
}}
|
}}
|
||||||
@@ -334,11 +369,12 @@ func checkEnvironment(name string, job model.Job) []Finding {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var findings []Finding
|
var findings []Finding
|
||||||
envName, _ := m["name"]
|
envName := m["name"]
|
||||||
_, hasURL := m["url"]
|
_, hasURL := m["url"]
|
||||||
if (envName == nil || envName == "") && hasURL {
|
if (envName == nil || envName == "") && hasURL {
|
||||||
findings = append(findings, Finding{
|
findings = append(findings, Finding{
|
||||||
Severity: Error,
|
Severity: Error,
|
||||||
|
Rule: RuleInvalidEnvironment,
|
||||||
Job: name,
|
Job: name,
|
||||||
Message: "'environment.url' requires 'environment.name' to be set",
|
Message: "'environment.url' requires 'environment.name' to be set",
|
||||||
})
|
})
|
||||||
@@ -346,6 +382,7 @@ func checkEnvironment(name string, job model.Job) []Finding {
|
|||||||
if action, ok := m["action"].(string); ok && !validEnvironmentAction[action] {
|
if action, ok := m["action"].(string); ok && !validEnvironmentAction[action] {
|
||||||
findings = append(findings, Finding{
|
findings = append(findings, Finding{
|
||||||
Severity: Error,
|
Severity: Error,
|
||||||
|
Rule: RuleInvalidEnvironment,
|
||||||
Job: name,
|
Job: name,
|
||||||
Message: fmt.Sprintf("'environment.action' has invalid value %q; valid: start, stop, prepare, verify, access", action),
|
Message: fmt.Sprintf("'environment.action' has invalid value %q; valid: start, stop, prepare, verify, access", action),
|
||||||
})
|
})
|
||||||
@@ -365,22 +402,25 @@ func checkArtifacts(name string, job model.Job) []Finding {
|
|||||||
if w, ok := m["when"].(string); ok && !validArtifactsWhen[w] {
|
if w, ok := m["when"].(string); ok && !validArtifactsWhen[w] {
|
||||||
findings = append(findings, Finding{
|
findings = append(findings, Finding{
|
||||||
Severity: Error,
|
Severity: Error,
|
||||||
|
Rule: RuleInvalidArtifacts,
|
||||||
Job: name,
|
Job: name,
|
||||||
Message: fmt.Sprintf("'artifacts.when' has invalid value %q; valid: on_success, on_failure, always", w),
|
Message: fmt.Sprintf("'artifacts.when' has invalid value %q; valid: on_success, on_failure, always", w),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if _, hasExposeAs := m["expose_as"]; hasExposeAs {
|
if _, hasExposeAs := m["expose_as"]; hasExposeAs {
|
||||||
paths, _ := m["paths"]
|
paths := m["paths"]
|
||||||
if paths == nil {
|
if paths == nil {
|
||||||
findings = append(findings, Finding{
|
findings = append(findings, Finding{
|
||||||
Severity: Error,
|
Severity: Error,
|
||||||
|
Rule: RuleInvalidArtifacts,
|
||||||
Job: name,
|
Job: name,
|
||||||
Message: "'artifacts.expose_as' requires 'artifacts.paths'",
|
Message: "'artifacts.expose_as' requires 'artifacts.paths'",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 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 {
|
||||||
@@ -392,6 +432,7 @@ func checkArtifacts(name string, job model.Job) []Finding {
|
|||||||
if !found {
|
if !found {
|
||||||
findings = append(findings, Finding{
|
findings = append(findings, Finding{
|
||||||
Severity: Warning,
|
Severity: Warning,
|
||||||
|
Rule: RulePagesPublic,
|
||||||
Job: name,
|
Job: name,
|
||||||
Message: "the 'pages' job should include 'public' in 'artifacts.paths' for GitLab Pages to deploy",
|
Message: "the 'pages' job should include 'public' in 'artifacts.paths' for GitLab Pages to deploy",
|
||||||
})
|
})
|
||||||
@@ -421,6 +462,7 @@ func checkCache(name string, job model.Job) []Finding {
|
|||||||
if w, ok := m["when"].(string); ok && !validCacheWhen[w] {
|
if w, ok := m["when"].(string); ok && !validCacheWhen[w] {
|
||||||
findings = append(findings, Finding{
|
findings = append(findings, Finding{
|
||||||
Severity: Error,
|
Severity: Error,
|
||||||
|
Rule: RuleInvalidCache,
|
||||||
Job: name,
|
Job: name,
|
||||||
Message: fmt.Sprintf("'cache.when' has invalid value %q; valid: on_success, on_failure, always", w),
|
Message: fmt.Sprintf("'cache.when' has invalid value %q; valid: on_success, on_failure, always", w),
|
||||||
})
|
})
|
||||||
@@ -428,6 +470,7 @@ func checkCache(name string, job model.Job) []Finding {
|
|||||||
if p, ok := m["policy"].(string); ok && !validCachePolicy[p] {
|
if p, ok := m["policy"].(string); ok && !validCachePolicy[p] {
|
||||||
findings = append(findings, Finding{
|
findings = append(findings, Finding{
|
||||||
Severity: Error,
|
Severity: Error,
|
||||||
|
Rule: RuleInvalidCache,
|
||||||
Job: name,
|
Job: name,
|
||||||
Message: fmt.Sprintf("'cache.policy' has invalid value %q; valid: pull, push, pull-push", p),
|
Message: fmt.Sprintf("'cache.policy' has invalid value %q; valid: pull, push, pull-push", p),
|
||||||
})
|
})
|
||||||
@@ -442,6 +485,7 @@ func checkRules(name string, job model.Job) []Finding {
|
|||||||
if rule.When != "" && !validRuleWhen[rule.When] {
|
if rule.When != "" && !validRuleWhen[rule.When] {
|
||||||
findings = append(findings, Finding{
|
findings = append(findings, Finding{
|
||||||
Severity: Error,
|
Severity: Error,
|
||||||
|
Rule: RuleInvalidRulesWhen,
|
||||||
Job: name,
|
Job: name,
|
||||||
Message: fmt.Sprintf("rules[%d].when has invalid value %q; valid: on_success, on_failure, always, manual, delayed, never", i, rule.When),
|
Message: fmt.Sprintf("rules[%d].when has invalid value %q; valid: on_success, on_failure, always, manual, delayed, never", i, rule.When),
|
||||||
})
|
})
|
||||||
@@ -450,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
|
||||||
@@ -458,10 +524,11 @@ func checkImage(name string, job model.Job) []Finding {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return nil // String form is valid.
|
return nil // String form is valid.
|
||||||
}
|
}
|
||||||
imgName, _ := m["name"]
|
imgName := m["name"]
|
||||||
if imgName == nil || imgName == "" {
|
if imgName == nil || imgName == "" {
|
||||||
return []Finding{{
|
return []Finding{{
|
||||||
Severity: Error,
|
Severity: Error,
|
||||||
|
Rule: RuleInvalidImage,
|
||||||
Job: name,
|
Job: name,
|
||||||
Message: "'image' map form requires a 'name' key",
|
Message: "'image' map form requires a 'name' key",
|
||||||
}}
|
}}
|
||||||
@@ -489,6 +556,7 @@ func checkInherit(name string, job model.Job) []Finding {
|
|||||||
default:
|
default:
|
||||||
findings = append(findings, Finding{
|
findings = append(findings, Finding{
|
||||||
Severity: Error,
|
Severity: Error,
|
||||||
|
Rule: RuleInvalidInherit,
|
||||||
Job: name,
|
Job: name,
|
||||||
Message: fmt.Sprintf("'inherit.%s' must be a boolean or a list of names", key),
|
Message: fmt.Sprintf("'inherit.%s' must be a boolean or a list of names", key),
|
||||||
})
|
})
|
||||||
@@ -496,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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -16,25 +18,59 @@ const (
|
|||||||
|
|
||||||
type Finding struct {
|
type Finding struct {
|
||||||
Severity Severity
|
Severity Severity
|
||||||
|
Rule string // stable rule ID, e.g. "GL003" — see rules.go
|
||||||
Job string // empty for pipeline-level findings
|
Job string // empty for pipeline-level findings
|
||||||
|
File string // source file where the finding originates
|
||||||
|
Line int // line number in File (0 = unknown)
|
||||||
Message string
|
Message string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f Finding) String() string {
|
func (f Finding) String() string {
|
||||||
if f.Job != "" {
|
var loc string
|
||||||
return fmt.Sprintf("[%s] job %q: %s", f.Severity, f.Job, f.Message)
|
if f.File != "" {
|
||||||
|
if f.Line > 0 {
|
||||||
|
loc = fmt.Sprintf("%s:%d: ", f.File, f.Line)
|
||||||
|
} else {
|
||||||
|
loc = fmt.Sprintf("%s: ", f.File)
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("[%s] %s", f.Severity, f.Message)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lint runs all rules against p and returns findings sorted by job name.
|
rule := ""
|
||||||
|
if f.Rule != "" {
|
||||||
|
rule = f.Rule + " "
|
||||||
|
}
|
||||||
|
|
||||||
|
sev := "[" + strings.ToLower(string(f.Severity)) + "]"
|
||||||
|
|
||||||
|
msg := f.Message
|
||||||
|
if f.Job != "" {
|
||||||
|
msg = fmt.Sprintf("job %q: %s", f.Job, f.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%s%s%s %s", loc, rule, sev, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lint runs all rules against p and returns findings sorted by (File, Line, Rule).
|
||||||
|
// Findings with no File (pipeline-level) sort before file-scoped ones.
|
||||||
func Lint(p *model.Pipeline) []Finding {
|
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)...)
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,12 +79,40 @@ func checkStages(p *model.Pipeline) []Finding {
|
|||||||
if len(p.Stages) == 0 {
|
if len(p.Stages) == 0 {
|
||||||
findings = append(findings, Finding{
|
findings = append(findings, Finding{
|
||||||
Severity: Warning,
|
Severity: Warning,
|
||||||
|
Rule: RuleNoStages,
|
||||||
|
File: p.SourceFile,
|
||||||
Message: "no stages defined; GitLab will use default stages (build, test, deploy)",
|
Message: "no stages defined; GitLab will use default stages (build, test, deploy)",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
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
|
||||||
@@ -58,6 +122,8 @@ func checkWorkflow(p *model.Pipeline) []Finding {
|
|||||||
if rule.When != "" && !validWorkflowRuleWhen[rule.When] {
|
if rule.When != "" && !validWorkflowRuleWhen[rule.When] {
|
||||||
findings = append(findings, Finding{
|
findings = append(findings, Finding{
|
||||||
Severity: Error,
|
Severity: Error,
|
||||||
|
Rule: RuleWorkflowWhen,
|
||||||
|
File: p.SourceFile,
|
||||||
Message: fmt.Sprintf("workflow.rules[%d].when has invalid value %q; valid: always, never", i, rule.When),
|
Message: fmt.Sprintf("workflow.rules[%d].when has invalid value %q; valid: always, never", i, rule.When),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -88,10 +154,17 @@ func checkJob(name string, job model.Job, stageSet map[string]bool) []Finding {
|
|||||||
|
|
||||||
// After extends resolution, a job with no script/run is an error.
|
// After extends resolution, a job with no script/run is an error.
|
||||||
// Exceptions: trigger jobs, pages jobs (use pages: keyword), and template jobs.
|
// Exceptions: trigger jobs, pages jobs (use pages: keyword), and template jobs.
|
||||||
|
// When the job has extends:, the script may come from a base that couldn't be
|
||||||
|
// fetched (e.g. a remote include without a token), so downgrade to warning.
|
||||||
hasScript := scriptNonEmpty(job.Script) || job.Run != nil
|
hasScript := scriptNonEmpty(job.Script) || job.Run != nil
|
||||||
if !isTemplate && !isTrigger && job.Pages == nil && !hasScript {
|
if !isTemplate && !isTrigger && job.Pages == nil && !hasScript {
|
||||||
|
sev := Error
|
||||||
|
if job.Extends != nil {
|
||||||
|
sev = Warning
|
||||||
|
}
|
||||||
findings = append(findings, Finding{
|
findings = append(findings, Finding{
|
||||||
Severity: Error,
|
Severity: sev,
|
||||||
|
Rule: RuleMissingScript,
|
||||||
Job: name,
|
Job: name,
|
||||||
Message: "missing required field 'script' (or 'run')",
|
Message: "missing required field 'script' (or 'run')",
|
||||||
})
|
})
|
||||||
@@ -103,6 +176,7 @@ func checkJob(name string, job model.Job, stageSet map[string]bool) []Finding {
|
|||||||
if job.Stage != "" && !strings.Contains(job.Stage, "$[[") && len(stageSet) > 0 && !stageSet[job.Stage] {
|
if job.Stage != "" && !strings.Contains(job.Stage, "$[[") && len(stageSet) > 0 && !stageSet[job.Stage] {
|
||||||
findings = append(findings, Finding{
|
findings = append(findings, Finding{
|
||||||
Severity: Error,
|
Severity: Error,
|
||||||
|
Rule: RuleUnknownStage,
|
||||||
Job: name,
|
Job: name,
|
||||||
Message: fmt.Sprintf("stage %q is not defined in 'stages'", job.Stage),
|
Message: fmt.Sprintf("stage %q is not defined in 'stages'", job.Stage),
|
||||||
})
|
})
|
||||||
@@ -112,6 +186,7 @@ func checkJob(name string, job model.Job, stageSet map[string]bool) []Finding {
|
|||||||
if job.Only != nil && len(job.Rules) > 0 {
|
if job.Only != nil && len(job.Rules) > 0 {
|
||||||
findings = append(findings, Finding{
|
findings = append(findings, Finding{
|
||||||
Severity: Error,
|
Severity: Error,
|
||||||
|
Rule: RuleOnlyRulesConflict,
|
||||||
Job: name,
|
Job: name,
|
||||||
Message: "'only' and 'rules' cannot be used together",
|
Message: "'only' and 'rules' cannot be used together",
|
||||||
})
|
})
|
||||||
@@ -121,6 +196,7 @@ func checkJob(name string, job model.Job, stageSet map[string]bool) []Finding {
|
|||||||
if job.Except != nil && len(job.Rules) > 0 {
|
if job.Except != nil && len(job.Rules) > 0 {
|
||||||
findings = append(findings, Finding{
|
findings = append(findings, Finding{
|
||||||
Severity: Error,
|
Severity: Error,
|
||||||
|
Rule: RuleExceptRulesConflict,
|
||||||
Job: name,
|
Job: name,
|
||||||
Message: "'except' and 'rules' cannot be used together",
|
Message: "'except' and 'rules' cannot be used together",
|
||||||
})
|
})
|
||||||
@@ -130,6 +206,7 @@ func checkJob(name string, job model.Job, stageSet map[string]bool) []Finding {
|
|||||||
if job.Only != nil || job.Except != nil {
|
if job.Only != nil || job.Except != nil {
|
||||||
findings = append(findings, Finding{
|
findings = append(findings, Finding{
|
||||||
Severity: Warning,
|
Severity: Warning,
|
||||||
|
Rule: RuleDeprecatedOnly,
|
||||||
Job: name,
|
Job: name,
|
||||||
Message: "'only'/'except' are deprecated; prefer 'rules'",
|
Message: "'only'/'except' are deprecated; prefer 'rules'",
|
||||||
})
|
})
|
||||||
@@ -137,6 +214,13 @@ func checkJob(name string, job model.Job, stageSet map[string]bool) []Finding {
|
|||||||
|
|
||||||
findings = append(findings, checkJobKeywords(name, job)...)
|
findings = append(findings, checkJobKeywords(name, job)...)
|
||||||
|
|
||||||
|
// Attach source location to every job-scoped finding collected above.
|
||||||
|
for i := range findings {
|
||||||
|
if findings[i].Job != "" && findings[i].File == "" {
|
||||||
|
findings[i].File = job.File
|
||||||
|
findings[i].Line = job.Line
|
||||||
|
}
|
||||||
|
}
|
||||||
return findings
|
return findings
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+45
-19
@@ -6,6 +6,12 @@ import (
|
|||||||
"git.k3nny.fr/glint/internal/model"
|
"git.k3nny.fr/glint/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// needEntry is a parsed element from a job's needs: list.
|
||||||
|
type needEntry struct {
|
||||||
|
job string
|
||||||
|
optional bool // true when the needs entry carries optional: true
|
||||||
|
}
|
||||||
|
|
||||||
func checkNeeds(p *model.Pipeline) []Finding {
|
func checkNeeds(p *model.Pipeline) []Finding {
|
||||||
var findings []Finding
|
var findings []Finding
|
||||||
|
|
||||||
@@ -15,7 +21,7 @@ func checkNeeds(p *model.Pipeline) []Finding {
|
|||||||
stageIndex[s] = i
|
stageIndex[s] = i
|
||||||
}
|
}
|
||||||
|
|
||||||
// needsGraph maps each job to the list of jobs it depends on.
|
// needsGraph maps each job to the jobs it depends on (existing jobs only).
|
||||||
// Used for cycle detection after individual checks.
|
// Used for cycle detection after individual checks.
|
||||||
needsGraph := make(map[string][]string)
|
needsGraph := make(map[string][]string)
|
||||||
|
|
||||||
@@ -24,32 +30,47 @@ func checkNeeds(p *model.Pipeline) []Finding {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
neededNames := parseNeedJobNames(job.Needs)
|
entries := parseNeedEntries(job.Needs)
|
||||||
needsGraph[name] = neededNames
|
|
||||||
|
|
||||||
jobStageIdx, jobHasStage := stageIndex[job.Stage]
|
jobStageIdx, jobHasStage := stageIndex[job.Stage]
|
||||||
|
|
||||||
for _, needed := range neededNames {
|
for _, entry := range entries {
|
||||||
neededJob, exists := p.Jobs[needed]
|
neededJob, exists := p.Jobs[entry.job]
|
||||||
if !exists {
|
if !exists {
|
||||||
|
// optional: true means GitLab CI will silently skip the
|
||||||
|
// dependency when the job is absent (e.g. from a conditional
|
||||||
|
// include). Downgrade to warning so users are informed without
|
||||||
|
// failing the lint.
|
||||||
|
sev := Error
|
||||||
|
if entry.optional {
|
||||||
|
sev = Warning
|
||||||
|
}
|
||||||
findings = append(findings, Finding{
|
findings = append(findings, Finding{
|
||||||
Severity: Error,
|
Severity: sev,
|
||||||
|
Rule: RuleNeedsUnknown,
|
||||||
Job: name,
|
Job: name,
|
||||||
Message: fmt.Sprintf("needs unknown job %q", needed),
|
File: job.File,
|
||||||
|
Line: job.Line,
|
||||||
|
Message: fmt.Sprintf("needs unknown job %q", entry.job),
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add to the cycle-detection graph only when the dep exists.
|
||||||
|
needsGraph[name] = append(needsGraph[name], entry.job)
|
||||||
|
|
||||||
// A job cannot need a job in a later stage.
|
// A job cannot need a job in a later stage.
|
||||||
if len(p.Stages) > 0 && jobHasStage && neededJob.Stage != "" {
|
if len(p.Stages) > 0 && jobHasStage && neededJob.Stage != "" {
|
||||||
neededStageIdx, neededHasStage := stageIndex[neededJob.Stage]
|
neededStageIdx, neededHasStage := stageIndex[neededJob.Stage]
|
||||||
if neededHasStage && neededStageIdx > jobStageIdx {
|
if neededHasStage && neededStageIdx > jobStageIdx {
|
||||||
findings = append(findings, Finding{
|
findings = append(findings, Finding{
|
||||||
Severity: Error,
|
Severity: Error,
|
||||||
|
Rule: RuleNeedsStageOrder,
|
||||||
Job: name,
|
Job: name,
|
||||||
|
File: job.File,
|
||||||
|
Line: job.Line,
|
||||||
Message: fmt.Sprintf(
|
Message: fmt.Sprintf(
|
||||||
"needs %q which is in a later stage (%q after %q)",
|
"needs %q which is in a later stage (%q after %q)",
|
||||||
needed, neededJob.Stage, job.Stage,
|
entry.job, neededJob.Stage, job.Stage,
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -57,32 +78,33 @@ func checkNeeds(p *model.Pipeline) []Finding {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
findings = append(findings, detectNeedsCycles(needsGraph)...)
|
findings = append(findings, detectNeedsCycles(needsGraph, p.Jobs)...)
|
||||||
return findings
|
return findings
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseNeedJobNames extracts job names from a needs: list.
|
// parseNeedEntries extracts needs entries from a needs: list, preserving the
|
||||||
// Each element is either a plain string or a map with a "job" key.
|
// optional flag. Each element is a plain string (job name) or a map with a
|
||||||
// Cross-pipeline needs (maps with a "pipeline" key) are skipped.
|
// "job" key. Cross-pipeline needs (maps with a "pipeline" key) are skipped.
|
||||||
func parseNeedJobNames(needs []any) []string {
|
func parseNeedEntries(needs []any) []needEntry {
|
||||||
var names []string
|
var entries []needEntry
|
||||||
for _, n := range needs {
|
for _, n := range needs {
|
||||||
switch v := n.(type) {
|
switch v := n.(type) {
|
||||||
case string:
|
case string:
|
||||||
names = append(names, v)
|
entries = append(entries, needEntry{job: v})
|
||||||
case map[string]any:
|
case map[string]any:
|
||||||
if _, crossPipeline := v["pipeline"]; crossPipeline {
|
if _, crossPipeline := v["pipeline"]; crossPipeline {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if job, ok := v["job"].(string); ok {
|
if job, ok := v["job"].(string); ok {
|
||||||
names = append(names, job)
|
optional, _ := v["optional"].(bool)
|
||||||
|
entries = append(entries, needEntry{job: job, optional: optional})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return names
|
return entries
|
||||||
}
|
}
|
||||||
|
|
||||||
func detectNeedsCycles(graph map[string][]string) []Finding {
|
func detectNeedsCycles(graph map[string][]string, jobs map[string]model.Job) []Finding {
|
||||||
const (
|
const (
|
||||||
unvisited = 0
|
unvisited = 0
|
||||||
visiting = 1
|
visiting = 1
|
||||||
@@ -101,9 +123,13 @@ func detectNeedsCycles(graph map[string][]string) []Finding {
|
|||||||
case visiting:
|
case visiting:
|
||||||
if !reported[name] {
|
if !reported[name] {
|
||||||
reported[name] = true
|
reported[name] = true
|
||||||
|
j := jobs[name]
|
||||||
findings = append(findings, Finding{
|
findings = append(findings, Finding{
|
||||||
Severity: Error,
|
Severity: Error,
|
||||||
|
Rule: RuleNeedsCycle,
|
||||||
Job: name,
|
Job: name,
|
||||||
|
File: j.File,
|
||||||
|
Line: j.Line,
|
||||||
Message: fmt.Sprintf("circular dependency in needs: %v → %s", path, name),
|
Message: fmt.Sprintf("circular dependency in needs: %v → %s", path, name),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package linter
|
||||||
|
|
||||||
|
// Rule ID constants. Each ID is stable across versions and uniquely identifies
|
||||||
|
// one lint check. Use these when filtering output, writing suppression rules,
|
||||||
|
// or referencing a check in documentation.
|
||||||
|
//
|
||||||
|
// Prefix GL = Glint / GitLab CI lint.
|
||||||
|
// Numbering is sequential by category; gaps may appear as rules are added.
|
||||||
|
const (
|
||||||
|
// ── Pipeline-level ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// GL001: no stages: block defined; GitLab falls back to default stages.
|
||||||
|
RuleNoStages = "GL001"
|
||||||
|
|
||||||
|
// GL002: workflow.rules[n].when has an invalid value (only always/never allowed).
|
||||||
|
RuleWorkflowWhen = "GL002"
|
||||||
|
|
||||||
|
// ── Job structure ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// GL003: job is missing a required script: (or run:) field.
|
||||||
|
RuleMissingScript = "GL003"
|
||||||
|
|
||||||
|
// GL004: job references a stage not declared in stages:.
|
||||||
|
RuleUnknownStage = "GL004"
|
||||||
|
|
||||||
|
// GL005: only: and rules: used together on the same job.
|
||||||
|
RuleOnlyRulesConflict = "GL005"
|
||||||
|
|
||||||
|
// GL006: except: and rules: used together on the same job.
|
||||||
|
RuleExceptRulesConflict = "GL006"
|
||||||
|
|
||||||
|
// GL007: only:/except: used (deprecated; prefer rules:).
|
||||||
|
RuleDeprecatedOnly = "GL007"
|
||||||
|
|
||||||
|
// ── Keyword constraints ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// GL008: when: has an invalid value.
|
||||||
|
RuleInvalidWhen = "GL008"
|
||||||
|
|
||||||
|
// GL009: when: delayed without start_in:.
|
||||||
|
RuleDelayedNoStartIn = "GL009"
|
||||||
|
|
||||||
|
// GL010: start_in: set when when: is not delayed.
|
||||||
|
RuleStartInNoDelayed = "GL010"
|
||||||
|
|
||||||
|
// GL011: parallel: value is invalid (integer out of range or map missing matrix:).
|
||||||
|
RuleInvalidParallel = "GL011"
|
||||||
|
|
||||||
|
// GL012: retry: integer is out of range 0–2, or retry: is neither int nor map.
|
||||||
|
RuleInvalidRetry = "GL012"
|
||||||
|
|
||||||
|
// GL013: retry.when: contains an unrecognised failure type.
|
||||||
|
RuleInvalidRetryWhen = "GL013"
|
||||||
|
|
||||||
|
// GL014: allow_failure: is not a boolean or a map with exit_codes:.
|
||||||
|
RuleInvalidAllowFailure = "GL014"
|
||||||
|
|
||||||
|
// GL015: interruptible: is not a boolean.
|
||||||
|
RuleInvalidInterruptible = "GL015"
|
||||||
|
|
||||||
|
// GL016: trigger: job also defines script: (mutually exclusive).
|
||||||
|
RuleTriggerWithScript = "GL016"
|
||||||
|
|
||||||
|
// GL017: trigger: map does not specify project: or include:.
|
||||||
|
RuleInvalidTrigger = "GL017"
|
||||||
|
|
||||||
|
// GL018: coverage: is not a regex pattern wrapped in /.
|
||||||
|
RuleInvalidCoverage = "GL018"
|
||||||
|
|
||||||
|
// GL019: release: is missing required tag_name:, or is not a map.
|
||||||
|
RuleInvalidRelease = "GL019"
|
||||||
|
|
||||||
|
// GL020: environment: has an invalid url/action configuration.
|
||||||
|
RuleInvalidEnvironment = "GL020"
|
||||||
|
|
||||||
|
// GL021: artifacts: has an invalid when/expose_as configuration.
|
||||||
|
RuleInvalidArtifacts = "GL021"
|
||||||
|
|
||||||
|
// GL022: pages job artifacts.paths does not include public/.
|
||||||
|
RulePagesPublic = "GL022"
|
||||||
|
|
||||||
|
// GL023: cache: has an invalid when/policy value.
|
||||||
|
RuleInvalidCache = "GL023"
|
||||||
|
|
||||||
|
// GL024: rules[n].when has an invalid value.
|
||||||
|
RuleInvalidRulesWhen = "GL024"
|
||||||
|
|
||||||
|
// GL025: image: map form is missing a name: key.
|
||||||
|
RuleInvalidImage = "GL025"
|
||||||
|
|
||||||
|
// GL026: inherit.default or inherit.variables is not a boolean or list.
|
||||||
|
RuleInvalidInherit = "GL026"
|
||||||
|
|
||||||
|
// ── Cross-job graph ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// GL027: needs: references a job that does not exist in the pipeline.
|
||||||
|
RuleNeedsUnknown = "GL027"
|
||||||
|
|
||||||
|
// GL028: needs: references a job in a later stage than the current job.
|
||||||
|
RuleNeedsStageOrder = "GL028"
|
||||||
|
|
||||||
|
// GL029: circular dependency detected in the needs: graph.
|
||||||
|
RuleNeedsCycle = "GL029"
|
||||||
|
|
||||||
|
// GL030: dependencies: references a job that does not exist.
|
||||||
|
RuleUnknownDependency = "GL030"
|
||||||
|
|
||||||
|
// GL031: dependencies: references a job in the same or a later stage.
|
||||||
|
RuleDependencyStage = "GL031"
|
||||||
|
|
||||||
|
// ── Expression validation ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// GL032: rules:if: references a variable not declared in pipeline variables:,
|
||||||
|
// the job's own variables:, or any workflow:rules:variables: block.
|
||||||
|
// May be a false positive for variables set in GitLab CI/CD project settings.
|
||||||
|
RuleUndeclaredVariable = "GL032"
|
||||||
|
|
||||||
|
// GL033: every rule in a job's rules: block has when: never, so the job
|
||||||
|
// can never be included in any pipeline run.
|
||||||
|
RuleDeadRules = "GL033"
|
||||||
|
|
||||||
|
// 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"
|
||||||
|
)
|
||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
// pipeline that is valid on GitLab) produces no Error findings.
|
// pipeline that is valid on GitLab) produces no Error findings.
|
||||||
// These files exercise local include resolution and multi-level extends chains.
|
// These files exercise local include resolution and multi-level extends chains.
|
||||||
func TestSambaCI(t *testing.T) {
|
func TestSambaCI(t *testing.T) {
|
||||||
entryPoint := "../../samba-testdata/.gitlab-ci.yml"
|
entryPoint := "../../testdata/samba/.gitlab-ci.yml"
|
||||||
|
|
||||||
p, err := model.Parse(entryPoint)
|
p, err := model.Parse(entryPoint)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -51,9 +51,9 @@ func TestSambaCIEntryFiles(t *testing.T) {
|
|||||||
name string
|
name string
|
||||||
path string
|
path string
|
||||||
}{
|
}{
|
||||||
{"default", "../../samba-testdata/.gitlab-ci.yml"},
|
{"default", "../../testdata/samba/.gitlab-ci.yml"},
|
||||||
{"coverage", "../../samba-testdata/.gitlab-ci-coverage.yml"},
|
{"coverage", "../../testdata/samba/.gitlab-ci-coverage.yml"},
|
||||||
{"private", "../../samba-testdata/.gitlab-ci-private.yml"},
|
{"private", "../../testdata/samba/.gitlab-ci-private.yml"},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range entryPoints {
|
for _, tc := range entryPoints {
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
package linter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.k3nny.fr/glint/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// predefinedVarPrefixes lists GitLab-maintained variable namespaces that are
|
||||||
|
// always available without an explicit declaration in variables: blocks.
|
||||||
|
var predefinedVarPrefixes = []string{
|
||||||
|
"CI_", // most predefined CI variables (CI_COMMIT_BRANCH, CI_JOB_ID, …)
|
||||||
|
"GITLAB_", // user/project metadata (GITLAB_USER_ID, GITLAB_FEATURES, …)
|
||||||
|
"FF_", // GitLab feature flags
|
||||||
|
"RUNNER_", // runner-level variables
|
||||||
|
"TRIGGER_", // trigger token variables passed from upstream pipelines
|
||||||
|
"CHAT_", // ChatOps variables
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPredefinedVar(name string) bool {
|
||||||
|
for _, prefix := range predefinedVarPrefixes {
|
||||||
|
if strings.HasPrefix(name, prefix) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractIfVars returns every variable name referenced in a rules:if: expression.
|
||||||
|
// String literals are skipped so that dollar signs inside quoted values are
|
||||||
|
// not mistaken for variable references.
|
||||||
|
func extractIfVars(expr string) []string {
|
||||||
|
var names []string
|
||||||
|
i := 0
|
||||||
|
for i < len(expr) {
|
||||||
|
switch expr[i] {
|
||||||
|
case '"', '\'':
|
||||||
|
quote := expr[i]
|
||||||
|
i++
|
||||||
|
for i < len(expr) && expr[i] != quote {
|
||||||
|
if expr[i] == '\\' {
|
||||||
|
i++ // skip escaped character
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
if i < len(expr) {
|
||||||
|
i++ // consume closing quote
|
||||||
|
}
|
||||||
|
case '$':
|
||||||
|
i++ // consume '$'
|
||||||
|
if i < len(expr) && expr[i] == '{' {
|
||||||
|
i++ // consume '{'
|
||||||
|
start := i
|
||||||
|
for i < len(expr) && isVarNameByte(expr[i]) {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
if i < len(expr) && expr[i] == '}' && i > start {
|
||||||
|
names = append(names, expr[start:i])
|
||||||
|
i++ // consume '}'
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
start := i
|
||||||
|
for i < len(expr) && isVarNameByte(expr[i]) {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
if i > start {
|
||||||
|
names = append(names, expr[start:i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
func isVarNameByte(b byte) bool {
|
||||||
|
return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') || b == '_'
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkVariableRefs warns when a rules:if: expression references a variable that
|
||||||
|
// is not declared in pipeline variables:, the job's own variables:, or any
|
||||||
|
// workflow:rules:variables: block. Predefined GitLab CI variables (CI_*, GITLAB_*,
|
||||||
|
// …) are always exempt. Variables set in GitLab CI/CD project settings are
|
||||||
|
// invisible to glint, so the finding is a WARNING rather than an error.
|
||||||
|
func checkVariableRefs(p *model.Pipeline) []Finding {
|
||||||
|
pipelineVars := make(map[string]bool, len(p.Variables))
|
||||||
|
for k := range p.Variables {
|
||||||
|
pipelineVars[k] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Union of all variables any workflow rule might inject into the context.
|
||||||
|
workflowRuleVars := make(map[string]bool)
|
||||||
|
if p.Workflow != nil {
|
||||||
|
for _, rule := range p.Workflow.Rules {
|
||||||
|
for k := range rule.Variables {
|
||||||
|
workflowRuleVars[k] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var findings []Finding
|
||||||
|
|
||||||
|
// Check workflow rules:if: expressions.
|
||||||
|
if p.Workflow != nil {
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
for i, rule := range p.Workflow.Rules {
|
||||||
|
if rule.If == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, varName := range extractIfVars(rule.If) {
|
||||||
|
if isPredefinedVar(varName) || pipelineVars[varName] || seen[varName] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[varName] = true
|
||||||
|
findings = append(findings, Finding{
|
||||||
|
Severity: Warning,
|
||||||
|
Rule: RuleUndeclaredVariable,
|
||||||
|
File: p.SourceFile,
|
||||||
|
Message: fmt.Sprintf("workflow.rules[%d].if: $%s is not declared in pipeline variables:", i, varName),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check each job's rules:if: expressions.
|
||||||
|
for name, job := range p.Jobs {
|
||||||
|
jobVars := make(map[string]bool, len(job.Variables))
|
||||||
|
for k := range job.Variables {
|
||||||
|
jobVars[k] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduplicate per (job, varName): report each undeclared variable once per job.
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
for i, rule := range job.Rules {
|
||||||
|
if rule.If == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, varName := range extractIfVars(rule.If) {
|
||||||
|
if isPredefinedVar(varName) || pipelineVars[varName] || jobVars[varName] || workflowRuleVars[varName] || seen[varName] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[varName] = true
|
||||||
|
findings = append(findings, Finding{
|
||||||
|
Severity: Warning,
|
||||||
|
Rule: RuleUndeclaredVariable,
|
||||||
|
Job: name,
|
||||||
|
File: job.File,
|
||||||
|
Line: job.Line,
|
||||||
|
Message: fmt.Sprintf("rules[%d].if: $%s is not declared in pipeline or job variables:", i, varName),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return findings
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
package linter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.k3nny.fr/glint/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExtractIfVars(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
expr string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "simple variable",
|
||||||
|
expr: `$MY_VAR == "value"`,
|
||||||
|
want: []string{"MY_VAR"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "curly brace syntax",
|
||||||
|
expr: `${MY_VAR} != null`,
|
||||||
|
want: []string{"MY_VAR"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple variables",
|
||||||
|
expr: `$BRANCH == "main" && $DEPLOY_ENV == "prod"`,
|
||||||
|
want: []string{"BRANCH", "DEPLOY_ENV"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "dollar sign inside string literal is skipped",
|
||||||
|
expr: `$REAL_VAR == "$not_a_var"`,
|
||||||
|
want: []string{"REAL_VAR"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no variables",
|
||||||
|
expr: `"main" == "main"`,
|
||||||
|
want: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "regex rhs variable",
|
||||||
|
expr: `$BRANCH =~ $PATTERN`,
|
||||||
|
want: []string{"BRANCH", "PATTERN"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiline expression",
|
||||||
|
expr: "$BRANCH == \"main\" ||\n$BRANCH == \"develop\"",
|
||||||
|
want: []string{"BRANCH", "BRANCH"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := extractIfVars(tc.expr)
|
||||||
|
if len(got) != len(tc.want) {
|
||||||
|
t.Fatalf("extractIfVars(%q) = %v, want %v", tc.expr, got, tc.want)
|
||||||
|
}
|
||||||
|
for i, v := range got {
|
||||||
|
if v != tc.want[i] {
|
||||||
|
t.Errorf("[%d] got %q, want %q", i, v, tc.want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsPredefinedVar(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
input string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"CI_COMMIT_BRANCH", true},
|
||||||
|
{"CI_JOB_TOKEN", true},
|
||||||
|
{"GITLAB_USER_ID", true},
|
||||||
|
{"GITLAB_FEATURES", true},
|
||||||
|
{"FF_SOME_FLAG", true},
|
||||||
|
{"RUNNER_ID", true},
|
||||||
|
{"TRIGGER_PAYLOAD", true},
|
||||||
|
{"CHAT_INPUT", true},
|
||||||
|
{"MY_CUSTOM_VAR", false},
|
||||||
|
{"DEPLOY_ENV", false},
|
||||||
|
{"FEATURE_ENABLED", false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.input, func(t *testing.T) {
|
||||||
|
if got := isPredefinedVar(tc.input); got != tc.want {
|
||||||
|
t.Errorf("isPredefinedVar(%q) = %v, want %v", tc.input, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckVariableRefs(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
pipeline *model.Pipeline
|
||||||
|
wantWarnings int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "declared pipeline variable — no warning",
|
||||||
|
pipeline: &model.Pipeline{
|
||||||
|
Variables: map[string]any{"MY_VAR": "value"},
|
||||||
|
Jobs: map[string]model.Job{
|
||||||
|
"job-a": {Rules: []model.Rule{{If: `$MY_VAR == "value"`}}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantWarnings: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "predefined CI variable — no warning",
|
||||||
|
pipeline: &model.Pipeline{
|
||||||
|
Jobs: map[string]model.Job{
|
||||||
|
"job-a": {Rules: []model.Rule{{If: `$CI_COMMIT_BRANCH == "main"`}}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantWarnings: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "undeclared variable — one warning",
|
||||||
|
pipeline: &model.Pipeline{
|
||||||
|
Jobs: map[string]model.Job{
|
||||||
|
"job-a": {Rules: []model.Rule{{If: `$UNDEFINED_VAR == "yes"`}}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantWarnings: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "same undeclared var in multiple rules — one warning per job",
|
||||||
|
pipeline: &model.Pipeline{
|
||||||
|
Jobs: map[string]model.Job{
|
||||||
|
"job-a": {
|
||||||
|
Rules: []model.Rule{
|
||||||
|
{If: `$UNDEFINED_VAR == "yes"`},
|
||||||
|
{If: `$UNDEFINED_VAR == "no"`},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantWarnings: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "job-level variable — no warning",
|
||||||
|
pipeline: &model.Pipeline{
|
||||||
|
Jobs: map[string]model.Job{
|
||||||
|
"job-a": {
|
||||||
|
Variables: map[string]any{"LOCAL_VAR": "value"},
|
||||||
|
Rules: []model.Rule{{If: `$LOCAL_VAR == "value"`}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantWarnings: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "workflow rule variable available to job rules — no warning",
|
||||||
|
pipeline: &model.Pipeline{
|
||||||
|
Workflow: &model.Workflow{
|
||||||
|
Rules: []model.Rule{
|
||||||
|
{
|
||||||
|
If: `$CI_COMMIT_BRANCH == "main"`,
|
||||||
|
Variables: map[string]any{"DEPLOY_ENV": "production"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Jobs: map[string]model.Job{
|
||||||
|
"job-a": {Rules: []model.Rule{{If: `$DEPLOY_ENV == "production"`}}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantWarnings: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "undeclared variable in workflow rules:if",
|
||||||
|
pipeline: &model.Pipeline{
|
||||||
|
Workflow: &model.Workflow{
|
||||||
|
Rules: []model.Rule{{If: `$UNDECLARED == "main"`}},
|
||||||
|
},
|
||||||
|
Jobs: map[string]model.Job{},
|
||||||
|
},
|
||||||
|
wantWarnings: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "two jobs each with a different undeclared variable — two warnings",
|
||||||
|
pipeline: &model.Pipeline{
|
||||||
|
Jobs: map[string]model.Job{
|
||||||
|
"job-a": {Rules: []model.Rule{{If: `$UNDEF_A == "x"`}}},
|
||||||
|
"job-b": {Rules: []model.Rule{{If: `$UNDEF_B == "y"`}}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantWarnings: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no rules — no warnings",
|
||||||
|
pipeline: &model.Pipeline{
|
||||||
|
Jobs: map[string]model.Job{
|
||||||
|
"job-a": {Script: "echo ok"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantWarnings: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
findings := checkVariableRefs(tc.pipeline)
|
||||||
|
count := 0
|
||||||
|
for _, f := range findings {
|
||||||
|
if f.Severity == Warning && f.Rule == RuleUndeclaredVariable {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if count != tc.wantWarnings {
|
||||||
|
t.Errorf("got %d GL032 warnings, want %d; findings: %v", count, tc.wantWarnings, findings)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+134
-7
@@ -3,6 +3,7 @@ package model
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
@@ -13,14 +14,24 @@ func Parse(path string) (*Pipeline, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("reading file: %w", err)
|
return nil, fmt.Errorf("reading file: %w", err)
|
||||||
}
|
}
|
||||||
return ParseBytes(data)
|
p, err := ParseBytes(data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p.SourceFile = path
|
||||||
|
p.SetJobOrigin(path)
|
||||||
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseBytes parses YAML from an in-memory byte slice.
|
// ParseBytes parses YAML from an in-memory byte slice.
|
||||||
func ParseBytes(data []byte) (*Pipeline, error) {
|
func ParseBytes(data []byte) (*Pipeline, error) {
|
||||||
// First pass: decode into a raw map to extract job keys.
|
data = sanitizeYAMLEscapes(data)
|
||||||
var raw map[string]yaml.Node
|
|
||||||
if err := yaml.Unmarshal(data, &raw); err != nil {
|
// First pass: parse into a yaml.Node document to extract job keys with
|
||||||
|
// their exact source line numbers (key nodes carry the line, value nodes
|
||||||
|
// carry the body we decode into Job / map[string]any).
|
||||||
|
var doc yaml.Node
|
||||||
|
if err := yaml.Unmarshal(data, &doc); err != nil {
|
||||||
return nil, fmt.Errorf("parsing YAML: %w", err)
|
return nil, fmt.Errorf("parsing YAML: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,23 +43,139 @@ func ParseBytes(data []byte) (*Pipeline, error) {
|
|||||||
|
|
||||||
p.Jobs = make(map[string]Job)
|
p.Jobs = make(map[string]Job)
|
||||||
p.RawJobs = make(map[string]map[string]any)
|
p.RawJobs = make(map[string]map[string]any)
|
||||||
for key, node := range raw {
|
|
||||||
|
if doc.Kind != yaml.DocumentNode || len(doc.Content) == 0 {
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
root := doc.Content[0]
|
||||||
|
if root.Kind != yaml.MappingNode {
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk root mapping in key/value pairs.
|
||||||
|
for i := 0; i+1 < len(root.Content); i += 2 {
|
||||||
|
keyNode := root.Content[i]
|
||||||
|
valNode := root.Content[i+1]
|
||||||
|
key := keyNode.Value
|
||||||
if ReservedKeys[key] {
|
if ReservedKeys[key] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract any inline suppression directive from the job's head or line comment.
|
||||||
|
if rules := parseSuppressComment(keyNode.HeadComment, keyNode.LineComment); len(rules) > 0 {
|
||||||
|
if p.Suppressions == nil {
|
||||||
|
p.Suppressions = map[string][]string{}
|
||||||
|
}
|
||||||
|
p.Suppressions[key] = rules
|
||||||
|
}
|
||||||
|
|
||||||
var rawMap map[string]any
|
var rawMap map[string]any
|
||||||
if err := node.Decode(&rawMap); err != nil {
|
if err := valNode.Decode(&rawMap); err != nil {
|
||||||
return nil, fmt.Errorf("parsing raw job %q: %w", key, err)
|
return nil, fmt.Errorf("parsing raw job %q: %w", key, err)
|
||||||
}
|
}
|
||||||
p.RawJobs[key] = rawMap
|
p.RawJobs[key] = rawMap
|
||||||
|
|
||||||
var j Job
|
var j Job
|
||||||
if err := node.Decode(&j); err != nil {
|
if err := valNode.Decode(&j); err != nil {
|
||||||
return nil, fmt.Errorf("parsing job %q: %w", key, err)
|
return nil, fmt.Errorf("parsing job %q: %w", key, err)
|
||||||
}
|
}
|
||||||
j.Name = key
|
j.Name = key
|
||||||
|
j.Line = keyNode.Line // exact line of the job name key
|
||||||
p.Jobs[key] = j
|
p.Jobs[key] = j
|
||||||
}
|
}
|
||||||
|
|
||||||
return p, nil
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseSuppressComment scans head/line comments from a YAML key node for a
|
||||||
|
// "# glint: ignore RULE [RULE ...]" directive. Returns the list of rule IDs
|
||||||
|
// to suppress (uppercased), or []string{"*"} for "# glint: ignore all".
|
||||||
|
// Returns nil when no directive is found.
|
||||||
|
func parseSuppressComment(headComment, lineComment string) []string {
|
||||||
|
for _, raw := range []string{headComment, lineComment} {
|
||||||
|
for _, line := range strings.Split(raw, "\n") {
|
||||||
|
line = strings.TrimLeft(line, "# \t")
|
||||||
|
if !strings.HasPrefix(line, "glint:") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rest := strings.TrimSpace(strings.TrimPrefix(line, "glint:"))
|
||||||
|
if !strings.HasPrefix(rest, "ignore") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rest = strings.TrimSpace(strings.TrimPrefix(rest, "ignore"))
|
||||||
|
if rest == "" || strings.EqualFold(rest, "all") {
|
||||||
|
return []string{"*"}
|
||||||
|
}
|
||||||
|
var rules []string
|
||||||
|
for _, part := range strings.FieldsFunc(rest, func(r rune) bool {
|
||||||
|
return r == ',' || r == ' ' || r == '\t'
|
||||||
|
}) {
|
||||||
|
if p := strings.TrimSpace(part); p != "" {
|
||||||
|
rules = append(rules, strings.ToUpper(p))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rules
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sanitizeYAMLEscapes rewrites double-quoted YAML strings, replacing the \/
|
||||||
|
// escape sequence (unrecognised by gopkg.in/yaml.v3) with \\/ so that the
|
||||||
|
// parser produces a literal backslash+slash — preserving regex patterns like
|
||||||
|
// /^us\// that appear in GitLab CI if: expressions.
|
||||||
|
func sanitizeYAMLEscapes(data []byte) []byte {
|
||||||
|
type state int
|
||||||
|
const (
|
||||||
|
stOutside state = iota
|
||||||
|
stSingleQ
|
||||||
|
stDoubleQ
|
||||||
|
stEscape
|
||||||
|
)
|
||||||
|
|
||||||
|
out := make([]byte, 0, len(data))
|
||||||
|
s := stOutside
|
||||||
|
|
||||||
|
for i := 0; i < len(data); i++ {
|
||||||
|
b := data[i]
|
||||||
|
switch s {
|
||||||
|
case stOutside:
|
||||||
|
out = append(out, b)
|
||||||
|
switch b {
|
||||||
|
case '"':
|
||||||
|
s = stDoubleQ
|
||||||
|
case '\'':
|
||||||
|
s = stSingleQ
|
||||||
|
}
|
||||||
|
case stSingleQ:
|
||||||
|
out = append(out, b)
|
||||||
|
if b == '\'' {
|
||||||
|
if i+1 < len(data) && data[i+1] == '\'' {
|
||||||
|
// '' inside a single-quoted string is an escaped single-quote
|
||||||
|
out = append(out, data[i+1])
|
||||||
|
i++
|
||||||
|
} else {
|
||||||
|
s = stOutside
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case stDoubleQ:
|
||||||
|
out = append(out, b)
|
||||||
|
switch b {
|
||||||
|
case '\\':
|
||||||
|
s = stEscape
|
||||||
|
case '"':
|
||||||
|
s = stOutside
|
||||||
|
}
|
||||||
|
case stEscape:
|
||||||
|
if b == '/' {
|
||||||
|
// \/ is not recognised by yaml.v3; rewrite as \\/ which
|
||||||
|
// the parser resolves to a literal backslash + slash.
|
||||||
|
out = append(out, '\\', '/')
|
||||||
|
} else {
|
||||||
|
out = append(out, b)
|
||||||
|
}
|
||||||
|
s = stDoubleQ
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParseSuppressComment(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
head string
|
||||||
|
line string
|
||||||
|
wantRules []string
|
||||||
|
wantNil bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no comment",
|
||||||
|
head: "",
|
||||||
|
line: "",
|
||||||
|
wantNil: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single rule head comment",
|
||||||
|
head: "# glint: ignore GL007",
|
||||||
|
wantRules: []string{"GL007"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple rules comma-separated",
|
||||||
|
head: "# glint: ignore GL007, GL032",
|
||||||
|
wantRules: []string{"GL007", "GL032"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple rules space-separated",
|
||||||
|
head: "# glint: ignore GL007 GL032",
|
||||||
|
wantRules: []string{"GL007", "GL032"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ignore all",
|
||||||
|
head: "# glint: ignore all",
|
||||||
|
wantRules: []string{"*"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ignore all bare",
|
||||||
|
head: "# glint: ignore",
|
||||||
|
wantRules: []string{"*"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "lowercase rule ID is uppercased",
|
||||||
|
head: "# glint: ignore gl007",
|
||||||
|
wantRules: []string{"GL007"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "line comment",
|
||||||
|
head: "",
|
||||||
|
line: "# glint: ignore GL004",
|
||||||
|
wantRules: []string{"GL004"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unrelated comment",
|
||||||
|
head: "# This job deploys to production",
|
||||||
|
wantNil: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "partial match - not a glint directive",
|
||||||
|
head: "# hint: ignore this",
|
||||||
|
wantNil: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multi-line head comment with directive on second line",
|
||||||
|
head: "# Some description\n# glint: ignore GL007",
|
||||||
|
wantRules: []string{"GL007"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := parseSuppressComment(tc.head, tc.line)
|
||||||
|
if tc.wantNil {
|
||||||
|
if got != nil {
|
||||||
|
t.Errorf("expected nil, got %v", got)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(got) != len(tc.wantRules) {
|
||||||
|
t.Fatalf("got %v, want %v", got, tc.wantRules)
|
||||||
|
}
|
||||||
|
for i, r := range tc.wantRules {
|
||||||
|
if got[i] != r {
|
||||||
|
t.Errorf("[%d] got %q, want %q", i, got[i], r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseBytes_Suppressions(t *testing.T) {
|
||||||
|
yaml := `
|
||||||
|
stages:
|
||||||
|
- build
|
||||||
|
|
||||||
|
# glint: ignore GL007
|
||||||
|
deprecated-job:
|
||||||
|
stage: build
|
||||||
|
only:
|
||||||
|
- main
|
||||||
|
|
||||||
|
normal-job:
|
||||||
|
stage: build
|
||||||
|
script: echo ok
|
||||||
|
`
|
||||||
|
p, err := ParseBytes([]byte(yaml))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseBytes: %v", err)
|
||||||
|
}
|
||||||
|
if p.Suppressions == nil {
|
||||||
|
t.Fatal("Suppressions is nil")
|
||||||
|
}
|
||||||
|
rules, ok := p.Suppressions["deprecated-job"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("no suppression for deprecated-job")
|
||||||
|
}
|
||||||
|
if len(rules) != 1 || rules[0] != "GL007" {
|
||||||
|
t.Errorf("rules = %v, want [GL007]", rules)
|
||||||
|
}
|
||||||
|
if _, ok := p.Suppressions["normal-job"]; ok {
|
||||||
|
t.Error("normal-job should have no suppressions")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,20 +3,36 @@ package model
|
|||||||
// Pipeline represents the top-level structure of a .gitlab-ci.yml file.
|
// Pipeline represents the top-level structure of a .gitlab-ci.yml file.
|
||||||
// Unknown top-level keys are collected into Jobs.
|
// Unknown top-level keys are collected into Jobs.
|
||||||
type Pipeline struct {
|
type Pipeline struct {
|
||||||
|
SourceFile string // path of the root pipeline file; set by Parse
|
||||||
Stages []string `yaml:"stages"`
|
Stages []string `yaml:"stages"`
|
||||||
Variables map[string]string `yaml:"variables"`
|
Variables map[string]any `yaml:"variables"` // string or {value,description,options} map
|
||||||
Default *DefaultConfig `yaml:"default"`
|
Default *DefaultConfig `yaml:"default"`
|
||||||
Include []any `yaml:"include"`
|
Include []any `yaml:"include"`
|
||||||
Workflow *Workflow `yaml:"workflow"`
|
Workflow *Workflow `yaml:"workflow"`
|
||||||
// Jobs holds every non-reserved top-level key (i.e. job definitions).
|
// Jobs holds every non-reserved top-level key (i.e. job definitions).
|
||||||
Jobs map[string]Job `yaml:"-"`
|
Jobs map[string]Job `yaml:"-"`
|
||||||
RawJobs map[string]map[string]any `yaml:"-"` // pre-resolution raw maps, used by the resolver
|
RawJobs map[string]map[string]any `yaml:"-"` // pre-resolution raw maps, used by the resolver
|
||||||
|
// Suppressions maps job names to lists of suppressed rule IDs parsed from
|
||||||
|
// "# glint: ignore RULE" comments in the pipeline YAML. Only populated for
|
||||||
|
// the root pipeline file (not for included templates).
|
||||||
|
Suppressions map[string][]string `yaml:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetJobOrigin sets the File field on all jobs that don't already have one.
|
||||||
|
// Called after ParseBytes to record which file each job came from.
|
||||||
|
func (p *Pipeline) SetJobOrigin(file string) {
|
||||||
|
for name, j := range p.Jobs {
|
||||||
|
if j.File == "" {
|
||||||
|
j.File = file
|
||||||
|
}
|
||||||
|
p.Jobs[name] = j
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type DefaultConfig struct {
|
type DefaultConfig struct {
|
||||||
Image string `yaml:"image"`
|
Image any `yaml:"image"` // string or {name,pull_policy,...} map
|
||||||
BeforeScript []string `yaml:"before_script"`
|
BeforeScript any `yaml:"before_script"` // []string or string (block scalar)
|
||||||
AfterScript []string `yaml:"after_script"`
|
AfterScript any `yaml:"after_script"` // []string or string
|
||||||
Cache any `yaml:"cache"`
|
Cache any `yaml:"cache"`
|
||||||
Artifacts any `yaml:"artifacts"`
|
Artifacts any `yaml:"artifacts"`
|
||||||
Retry any `yaml:"retry"`
|
Retry any `yaml:"retry"`
|
||||||
@@ -30,6 +46,8 @@ type Workflow struct {
|
|||||||
|
|
||||||
type Job struct {
|
type Job struct {
|
||||||
Name string // set by parser, not from YAML
|
Name string // set by parser, not from YAML
|
||||||
|
File string // source file; set by Parse / resolver
|
||||||
|
Line int // line of the job key in its source file; set by parser
|
||||||
Stage string `yaml:"stage"`
|
Stage string `yaml:"stage"`
|
||||||
Script any `yaml:"script"` // []string or string (block scalar)
|
Script any `yaml:"script"` // []string or string (block scalar)
|
||||||
Run any `yaml:"run"` // alternative to script (CI steps)
|
Run any `yaml:"run"` // alternative to script (CI steps)
|
||||||
@@ -37,7 +55,7 @@ type Job struct {
|
|||||||
AfterScript any `yaml:"after_script"` // []string or string
|
AfterScript any `yaml:"after_script"` // []string or string
|
||||||
Image any `yaml:"image"`
|
Image any `yaml:"image"`
|
||||||
Services []any `yaml:"services"`
|
Services []any `yaml:"services"`
|
||||||
Variables map[string]string `yaml:"variables"`
|
Variables map[string]any `yaml:"variables"` // string or {value,description,options} map
|
||||||
Rules []Rule `yaml:"rules"`
|
Rules []Rule `yaml:"rules"`
|
||||||
Only any `yaml:"only"`
|
Only any `yaml:"only"`
|
||||||
Except any `yaml:"except"`
|
Except any `yaml:"except"`
|
||||||
@@ -68,8 +86,9 @@ type Job struct {
|
|||||||
type Rule struct {
|
type Rule struct {
|
||||||
If string `yaml:"if"`
|
If string `yaml:"if"`
|
||||||
When string `yaml:"when"`
|
When string `yaml:"when"`
|
||||||
Changes []string `yaml:"changes"`
|
Changes any `yaml:"changes"` // []string or {paths,compare_to} map
|
||||||
Exists []string `yaml:"exists"`
|
Exists any `yaml:"exists"` // []string or map form
|
||||||
|
Variables map[string]any `yaml:"variables"` // set/override variables when rule matches (GitLab CI 15.0+)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReservedKeys are top-level GitLab CI keys that are NOT job definitions.
|
// ReservedKeys are top-level GitLab CI keys that are NOT job definitions.
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+150
-15
@@ -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,13 +107,20 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
// remote, template — resolved by GitLab at runtime, skip silently.
|
if remote, _ := entry["remote"].(string); remote != "" {
|
||||||
|
w, ew := resolveRemoteInclude(p, remote, cfg, rootDir, visited, depth)
|
||||||
|
warnings = append(warnings, w...)
|
||||||
|
extWarnings = append(extWarnings, ew...)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// template — resolved by GitLab at runtime, skip silently.
|
||||||
}
|
}
|
||||||
return warnings, extWarnings
|
return warnings, extWarnings
|
||||||
}
|
}
|
||||||
@@ -98,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
|
||||||
@@ -117,13 +147,47 @@ func resolveLocalInclude(p *model.Pipeline, rawPath string, cfg fetcher.GitLabCo
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return []IncludeWarning{{Label: label, Err: fmt.Errorf("parsing YAML: %w", err)}}, nil
|
return []IncludeWarning{{Label: label, Err: fmt.Errorf("parsing YAML: %w", err)}}, nil
|
||||||
}
|
}
|
||||||
|
included.SetJobOrigin(absPath)
|
||||||
|
|
||||||
// Recursively resolve the included file's own includes first, merging
|
// Recursively resolve the included file's own includes first, merging
|
||||||
// everything into `included` before we merge it into the parent `p`.
|
// everything into `included` before we merge it into the parent `p`.
|
||||||
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...)
|
||||||
|
extWarnings = append(extWarnings, ew...)
|
||||||
|
}
|
||||||
|
|
||||||
|
mergeIncluded(p, included)
|
||||||
|
return warnings, extWarnings
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveRemoteInclude fetches a plain HTTPS URL, parses it as CI YAML, and
|
||||||
|
// merges it into p. Sub-includes of the fetched file are resolved recursively.
|
||||||
|
func resolveRemoteInclude(p *model.Pipeline, rawURL string, cfg fetcher.GitLabConfig, rootDir string, visited map[string]bool, depth int) ([]IncludeWarning, []ExtendWarning) {
|
||||||
|
label := "remote " + rawURL
|
||||||
|
|
||||||
|
if visited[rawURL] {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
visited[rawURL] = true
|
||||||
|
|
||||||
|
data, err := cfg.FetchURL(rawURL)
|
||||||
|
if err != nil {
|
||||||
|
return []IncludeWarning{{Label: label, Err: err}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
included, err := model.ParseBytes(data)
|
||||||
|
if err != nil {
|
||||||
|
return []IncludeWarning{{Label: label, Err: fmt.Errorf("parsing YAML: %w", err)}}, nil
|
||||||
|
}
|
||||||
|
included.SetJobOrigin(rawURL)
|
||||||
|
|
||||||
|
var warnings []IncludeWarning
|
||||||
|
var extWarnings []ExtendWarning
|
||||||
|
if len(included.Include) > 0 {
|
||||||
|
w, ew := resolveIncludes(included, included.Include, cfg, rootDir, visited, depth+1)
|
||||||
warnings = append(warnings, w...)
|
warnings = append(warnings, w...)
|
||||||
extWarnings = append(extWarnings, ew...)
|
extWarnings = append(extWarnings, ew...)
|
||||||
}
|
}
|
||||||
@@ -134,7 +198,7 @@ func resolveLocalInclude(p *model.Pipeline, rawPath 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
|
||||||
@@ -145,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
|
||||||
@@ -161,9 +231,10 @@ func resolveProjectInclude(p *model.Pipeline, entry map[string]any, project stri
|
|||||||
warnings = append(warnings, IncludeWarning{Label: label, Err: fmt.Errorf("parsing YAML: %w", err)})
|
warnings = append(warnings, IncludeWarning{Label: label, Err: fmt.Errorf("parsing YAML: %w", err)})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
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...)
|
||||||
}
|
}
|
||||||
@@ -173,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, '$') {
|
||||||
@@ -185,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
|
||||||
@@ -196,14 +275,18 @@ 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
|
||||||
}
|
}
|
||||||
|
included.SetJobOrigin(label)
|
||||||
|
|
||||||
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...)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,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>
|
||||||
@@ -283,8 +405,9 @@ func includeFiles(entry map[string]any) []string {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// mergeIncluded copies jobs and stages from src into dst.
|
// mergeIncluded copies jobs, stages, and variables from src into dst.
|
||||||
// dst (the main pipeline) always wins when a key already exists.
|
// dst (the main pipeline) always wins when a key already exists — this matches
|
||||||
|
// GitLab's precedence rule where root-pipeline values override included templates.
|
||||||
func mergeIncluded(dst, src *model.Pipeline) {
|
func mergeIncluded(dst, src *model.Pipeline) {
|
||||||
stageSet := make(map[string]bool, len(dst.Stages))
|
stageSet := make(map[string]bool, len(dst.Stages))
|
||||||
for _, s := range dst.Stages {
|
for _, s := range dst.Stages {
|
||||||
@@ -305,4 +428,16 @@ func mergeIncluded(dst, src *model.Pipeline) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Merge pipeline-level variables: dst wins on conflict (root overrides includes).
|
||||||
|
if len(src.Variables) > 0 {
|
||||||
|
if dst.Variables == nil {
|
||||||
|
dst.Variables = make(map[string]any, len(src.Variables))
|
||||||
|
}
|
||||||
|
for k, v := range src.Variables {
|
||||||
|
if _, exists := dst.Variables[k]; !exists {
|
||||||
|
dst.Variables[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package resolver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSubstituteInputs(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
data string
|
||||||
|
inputs map[string]any
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "simple substitution",
|
||||||
|
data: `stage: $[[ inputs.STAGE ]]`,
|
||||||
|
inputs: map[string]any{"STAGE": "deploy"},
|
||||||
|
want: `stage: deploy`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "default string used when key absent",
|
||||||
|
data: `image: $[[ inputs.IMAGE | default('ubuntu:22.04') ]]`,
|
||||||
|
inputs: map[string]any{},
|
||||||
|
want: `image: ubuntu:22.04`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "input overrides default",
|
||||||
|
data: `image: $[[ inputs.IMAGE | default('ubuntu:22.04') ]]`,
|
||||||
|
inputs: map[string]any{"IMAGE": "alpine:3.18"},
|
||||||
|
want: `image: alpine:3.18`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "integer input",
|
||||||
|
data: `variables:\n RETRIES: $[[ inputs.RETRY_COUNT ]]`,
|
||||||
|
inputs: map[string]any{"RETRY_COUNT": 3},
|
||||||
|
want: `variables:\n RETRIES: 3`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "boolean default",
|
||||||
|
data: `variables:\n ENABLED: $[[ inputs.ENABLE | default(true) ]]`,
|
||||||
|
inputs: map[string]any{},
|
||||||
|
want: `variables:\n ENABLED: true`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "numeric default",
|
||||||
|
data: `variables:\n COUNT: $[[ inputs.COUNT | default(5) ]]`,
|
||||||
|
inputs: map[string]any{},
|
||||||
|
want: `variables:\n COUNT: 5`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing key no default becomes empty",
|
||||||
|
data: `script: $[[ inputs.CMD ]]`,
|
||||||
|
inputs: map[string]any{},
|
||||||
|
want: `script: `,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no placeholders unchanged",
|
||||||
|
data: `stage: build`,
|
||||||
|
inputs: nil,
|
||||||
|
want: `stage: build`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple placeholders in one document",
|
||||||
|
data: "stage: $[[ inputs.STAGE ]]\nimage: $[[ inputs.IMAGE | default('alpine') ]]",
|
||||||
|
inputs: map[string]any{"STAGE": "test"},
|
||||||
|
want: "stage: test\nimage: alpine",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "double-quoted default",
|
||||||
|
data: `image: $[[ inputs.IMAGE | default("debian:12") ]]`,
|
||||||
|
inputs: map[string]any{},
|
||||||
|
want: `image: debian:12`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "whitespace inside brackets",
|
||||||
|
data: `stage: $[[ inputs.STAGE ]]`,
|
||||||
|
inputs: map[string]any{"STAGE": "build"},
|
||||||
|
want: `stage: build`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := string(substituteInputs([]byte(tc.data), tc.inputs))
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("substituteInputs:\n got %q\n want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
stages:
|
||||||
|
- build
|
||||||
|
|
||||||
|
# GL007 (only/except deprecated) would normally fire here, but the .glint.yml
|
||||||
|
# in this directory ignores GL007.
|
||||||
|
deprecated-job:
|
||||||
|
stage: build
|
||||||
|
only:
|
||||||
|
- main
|
||||||
|
script: echo ok
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
ignore:
|
||||||
|
- GL007
|
||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
stages:
|
||||||
|
- build
|
||||||
|
|
||||||
|
# GL004 (undefined stage) is demoted to warning by .glint.yml, so the exit
|
||||||
|
# code must be 0 even though there is a finding.
|
||||||
|
bad-stage-job:
|
||||||
|
stage: nonexistent
|
||||||
|
script: echo hi
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
severity:
|
||||||
|
GL004: warning
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
stages:
|
||||||
|
- build
|
||||||
|
|
||||||
|
# glint: ignore GL007
|
||||||
|
suppressed-job:
|
||||||
|
stage: build
|
||||||
|
only:
|
||||||
|
- main
|
||||||
|
script: echo ok
|
||||||
|
|
||||||
|
still-flagged-job:
|
||||||
|
stage: build
|
||||||
|
only:
|
||||||
|
- main
|
||||||
|
script: echo ok
|
||||||
Vendored
+59
@@ -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
|
||||||
Vendored
+14
@@ -0,0 +1,14 @@
|
|||||||
|
---
|
||||||
|
# Exercises include: remote: URL fetching and sub-include recursion.
|
||||||
|
# The remote file is a real public GitLab CI template; it may contain its own
|
||||||
|
# includes which should also be resolved. Exit code is 0 (warnings allowed).
|
||||||
|
stages:
|
||||||
|
- test
|
||||||
|
|
||||||
|
include:
|
||||||
|
- remote: https://gitlab.com/gitlab-org/gitlab/-/raw/master/lib/gitlab/ci/templates/Bash.gitlab-ci.yml
|
||||||
|
|
||||||
|
local-job:
|
||||||
|
stage: test
|
||||||
|
script:
|
||||||
|
- echo "local job alongside remote include"
|
||||||
Vendored
+9
@@ -38,3 +38,12 @@ missing-needs-job:
|
|||||||
- echo "bad"
|
- echo "bad"
|
||||||
needs:
|
needs:
|
||||||
- nonexistent-job # ERROR: job doesn't exist
|
- nonexistent-job # ERROR: job doesn't exist
|
||||||
|
|
||||||
|
# optional: true — missing dep should be WARNING not ERROR
|
||||||
|
optional-needs-job:
|
||||||
|
stage: build
|
||||||
|
script:
|
||||||
|
- echo "I depend on something that may not exist"
|
||||||
|
needs:
|
||||||
|
- job: nonexistent-optional-job
|
||||||
|
optional: true
|
||||||
|
|||||||
Vendored
+81
@@ -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"
|
||||||
Vendored
+116
@@ -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
|
||||||
Vendored
+51
@@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
# rules_if_expr.yml
|
||||||
|
# Exercises the rules:if: expression evaluator for:
|
||||||
|
# - Multi-line block-scalar expressions (|| on next line)
|
||||||
|
# - ${VAR} curly-brace variable syntax
|
||||||
|
# - Regex flags (/pattern/i case-insensitive)
|
||||||
|
# - Parenthesised compound expressions
|
||||||
|
# Expected: exits 0 (lints clean with no context).
|
||||||
|
|
||||||
|
stages:
|
||||||
|
- build
|
||||||
|
- deploy
|
||||||
|
|
||||||
|
variables:
|
||||||
|
DEPLOY_ENVIRONMENTS:
|
||||||
|
value: "staging"
|
||||||
|
description: "Target deployment environment"
|
||||||
|
|
||||||
|
build:
|
||||||
|
stage: build
|
||||||
|
script: make build
|
||||||
|
rules:
|
||||||
|
# Multi-line expression: || on next line
|
||||||
|
- if: |
|
||||||
|
$CI_COMMIT_BRANCH == "main" ||
|
||||||
|
$CI_COMMIT_BRANCH == "develop"
|
||||||
|
when: on_success
|
||||||
|
- when: never
|
||||||
|
|
||||||
|
deploy-feature:
|
||||||
|
stage: deploy
|
||||||
|
script: make deploy
|
||||||
|
rules:
|
||||||
|
# ${VAR} curly-brace syntax
|
||||||
|
- if: '${CI_COMMIT_BRANCH} != null && ${CI_COMMIT_TAG} == null'
|
||||||
|
when: manual
|
||||||
|
- when: never
|
||||||
|
|
||||||
|
release:
|
||||||
|
stage: deploy
|
||||||
|
script: make release
|
||||||
|
rules:
|
||||||
|
# Case-insensitive regex flag
|
||||||
|
- if: '$CI_COMMIT_BRANCH =~ /^(main|master)$/i'
|
||||||
|
when: on_success
|
||||||
|
# Parenthesised compound with multi-line
|
||||||
|
- if: >-
|
||||||
|
($CI_COMMIT_TAG != null) &&
|
||||||
|
($CI_PIPELINE_SOURCE == "push" || $CI_PIPELINE_SOURCE == "web")
|
||||||
|
when: on_success
|
||||||
|
- when: never
|
||||||
Vendored
Vendored
Vendored
+87
@@ -0,0 +1,87 @@
|
|||||||
|
---
|
||||||
|
# Exercises multi-line script patterns and extended variable declarations.
|
||||||
|
# Ref: https://docs.gitlab.com/ci/yaml/script/#split-long-commands
|
||||||
|
# Ref: https://docs.gitlab.com/ee/ci/yaml/#variablesdescription
|
||||||
|
# All patterns here must parse cleanly (exit 0).
|
||||||
|
|
||||||
|
stages:
|
||||||
|
- build
|
||||||
|
- test
|
||||||
|
- deploy
|
||||||
|
|
||||||
|
# Pipeline-level variables: plain strings and extended {value, description} map form.
|
||||||
|
variables:
|
||||||
|
PLAIN_VAR: "hello"
|
||||||
|
DEPLOY_ENV:
|
||||||
|
value: "staging"
|
||||||
|
description: "The deployment target. Set to staging or production."
|
||||||
|
RETRIES:
|
||||||
|
value: "3"
|
||||||
|
description: "Number of retry attempts."
|
||||||
|
options:
|
||||||
|
- "1"
|
||||||
|
- "3"
|
||||||
|
- "5"
|
||||||
|
|
||||||
|
default:
|
||||||
|
# image in map form (name + pull_policy)
|
||||||
|
image:
|
||||||
|
name: alpine:latest
|
||||||
|
pull_policy: if-not-present
|
||||||
|
# before_script as a block scalar (not a list)
|
||||||
|
before_script:
|
||||||
|
- apk add --no-cache curl git
|
||||||
|
|
||||||
|
build-literal-block:
|
||||||
|
stage: build
|
||||||
|
# script items using literal block scalar (|)
|
||||||
|
script:
|
||||||
|
- |
|
||||||
|
if [[ "$DEPLOY_ENV" == "production" ]]; then
|
||||||
|
echo "Production build"
|
||||||
|
else
|
||||||
|
echo "Non-production build"
|
||||||
|
fi
|
||||||
|
- echo "Build step done"
|
||||||
|
|
||||||
|
build-folded-block:
|
||||||
|
stage: build
|
||||||
|
# script items using folded block scalar (>)
|
||||||
|
script:
|
||||||
|
- >
|
||||||
|
apt-get update -qq &&
|
||||||
|
apt-get install -y curl wget
|
||||||
|
- echo "Packages installed"
|
||||||
|
before_script:
|
||||||
|
- |
|
||||||
|
echo "Job-level before_script"
|
||||||
|
echo "Using literal block scalar"
|
||||||
|
|
||||||
|
test-job:
|
||||||
|
stage: test
|
||||||
|
script:
|
||||||
|
- echo "Running tests"
|
||||||
|
- |
|
||||||
|
set -e
|
||||||
|
go test ./...
|
||||||
|
echo "Tests passed"
|
||||||
|
# Job-level variable with extended form
|
||||||
|
variables:
|
||||||
|
TEST_FLAG:
|
||||||
|
value: "true"
|
||||||
|
description: "Enable verbose test output"
|
||||||
|
# rules.changes in map form (GitLab 15.3+)
|
||||||
|
rules:
|
||||||
|
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||||
|
changes:
|
||||||
|
paths:
|
||||||
|
- "**/*.go"
|
||||||
|
compare_to: "main"
|
||||||
|
when: on_success
|
||||||
|
- when: on_success
|
||||||
|
|
||||||
|
deploy-job:
|
||||||
|
stage: deploy
|
||||||
|
script:
|
||||||
|
- echo "Deploying to $DEPLOY_ENV"
|
||||||
|
when: manual
|
||||||
Vendored
+51
@@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
# variable_refs.yml
|
||||||
|
# Verifies that GL032 does not fire for variables that are declared or predefined.
|
||||||
|
# Covers: pipeline variables:, job variables:, workflow:rules:variables:, and CI_* prefixes.
|
||||||
|
# Expected: exits 0 (no errors; no GL032 warnings).
|
||||||
|
|
||||||
|
stages:
|
||||||
|
- build
|
||||||
|
- deploy
|
||||||
|
|
||||||
|
variables:
|
||||||
|
DEPLOY_TARGET: "staging"
|
||||||
|
FEATURE_FLAG: "false"
|
||||||
|
|
||||||
|
workflow:
|
||||||
|
rules:
|
||||||
|
- if: '$CI_COMMIT_BRANCH == "main"'
|
||||||
|
variables:
|
||||||
|
DEPLOY_TARGET: "production"
|
||||||
|
- when: always
|
||||||
|
|
||||||
|
build:
|
||||||
|
stage: build
|
||||||
|
script: make build
|
||||||
|
rules:
|
||||||
|
# pipeline-level variable — GL032 must not fire
|
||||||
|
- if: '$DEPLOY_TARGET == "staging"'
|
||||||
|
when: on_success
|
||||||
|
# predefined CI variable — GL032 must not fire
|
||||||
|
- if: '$CI_COMMIT_BRANCH =~ /^feat\/.+/'
|
||||||
|
when: manual
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
stage: deploy
|
||||||
|
script: make deploy
|
||||||
|
variables:
|
||||||
|
DEPLOY_REGION: "us-east-1"
|
||||||
|
rules:
|
||||||
|
# pipeline-level variable — no warning
|
||||||
|
- if: '$FEATURE_FLAG == "true"'
|
||||||
|
when: never
|
||||||
|
# workflow-rule-injected variable — no warning
|
||||||
|
- if: '$DEPLOY_TARGET == "production"'
|
||||||
|
when: on_success
|
||||||
|
# job-level variable — no warning
|
||||||
|
- if: '$DEPLOY_REGION == "us-east-1"'
|
||||||
|
when: on_success
|
||||||
|
# predefined GITLAB_ variable — no warning
|
||||||
|
- if: '$GITLAB_USER_LOGIN == "bot"'
|
||||||
|
when: never
|
||||||
|
- when: manual
|
||||||
Vendored
+25
@@ -0,0 +1,25 @@
|
|||||||
|
---
|
||||||
|
# variable_refs_included.yml
|
||||||
|
# GL032 must NOT fire for variables declared in an included file.
|
||||||
|
# The included file (variable_refs_included_template.yml) declares TEMPLATE_VAR.
|
||||||
|
# Expected: exits 0 (no errors; no GL032 warnings).
|
||||||
|
|
||||||
|
stages:
|
||||||
|
- build
|
||||||
|
|
||||||
|
include:
|
||||||
|
- local: /variable_refs_included_template.yml
|
||||||
|
|
||||||
|
build:
|
||||||
|
stage: build
|
||||||
|
script: make build
|
||||||
|
rules:
|
||||||
|
# TEMPLATE_VAR comes from the included file — GL032 must not fire.
|
||||||
|
- if: '$TEMPLATE_VAR == "enabled"'
|
||||||
|
when: on_success
|
||||||
|
# ROOT_VAR is declared in this file — GL032 must not fire.
|
||||||
|
- if: '$ROOT_VAR == "yes"'
|
||||||
|
when: manual
|
||||||
|
|
||||||
|
variables:
|
||||||
|
ROOT_VAR: "yes"
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
# Included by variable_refs_included.yml — declares a pipeline-level variable
|
||||||
|
# that the parent pipeline's jobs reference in rules:if: expressions.
|
||||||
|
|
||||||
|
variables:
|
||||||
|
TEMPLATE_VAR: "enabled"
|
||||||
Vendored
+57
@@ -0,0 +1,57 @@
|
|||||||
|
---
|
||||||
|
# workflow_vars.yml
|
||||||
|
# Exercises workflow:rules:variables: injection.
|
||||||
|
# The matching workflow rule sets DEPLOY_TARGET; job rules use it.
|
||||||
|
#
|
||||||
|
# Expected behaviour per context:
|
||||||
|
# (no context) → all jobs active (no context evaluation)
|
||||||
|
# --branch main → deploy-prod active, deploy-staging skipped
|
||||||
|
# --branch develop → deploy-prod skipped, deploy-staging active
|
||||||
|
# --branch feat/x → deploy-prod skipped, deploy-staging skipped (manual)
|
||||||
|
|
||||||
|
stages:
|
||||||
|
- build
|
||||||
|
- deploy
|
||||||
|
|
||||||
|
variables:
|
||||||
|
DEPLOY_TARGET:
|
||||||
|
value: ""
|
||||||
|
description: "Deployment target — set by workflow rules"
|
||||||
|
|
||||||
|
workflow:
|
||||||
|
rules:
|
||||||
|
- if: "
|
||||||
|
$WORKFLOW = 'gitflow' &&
|
||||||
|
$CI_PIPELINE_SOURCE == /(push|web)/ &&
|
||||||
|
$CI_COMMIT_BRANCH =~ /^us\//
|
||||||
|
"
|
||||||
|
variables:
|
||||||
|
DEPLOY_TARGET: production
|
||||||
|
BUILD: true
|
||||||
|
TEST: true
|
||||||
|
- if: '$CI_COMMIT_BRANCH == "develop"'
|
||||||
|
variables:
|
||||||
|
DEPLOY_TARGET: staging
|
||||||
|
- when: always
|
||||||
|
|
||||||
|
build:
|
||||||
|
stage: build
|
||||||
|
script: make build
|
||||||
|
rules:
|
||||||
|
- when: always
|
||||||
|
|
||||||
|
deploy-prod:
|
||||||
|
stage: deploy
|
||||||
|
script: make deploy ENV=production
|
||||||
|
rules:
|
||||||
|
- if: '$DEPLOY_TARGET == "production"'
|
||||||
|
when: on_success
|
||||||
|
- when: never
|
||||||
|
|
||||||
|
deploy-staging:
|
||||||
|
stage: deploy
|
||||||
|
script: make deploy ENV=staging
|
||||||
|
rules:
|
||||||
|
- if: '$DEPLOY_TARGET == "staging"'
|
||||||
|
when: on_success
|
||||||
|
- when: never
|
||||||
Vendored
+57
@@ -0,0 +1,57 @@
|
|||||||
|
---
|
||||||
|
# workflow_vars.yml
|
||||||
|
# Exercises workflow:rules:variables: injection.
|
||||||
|
# The matching workflow rule sets DEPLOY_TARGET; job rules use it.
|
||||||
|
#
|
||||||
|
# Expected behaviour per context:
|
||||||
|
# --branch main → only build active (no workflow rule matches; WORKFLOW var not set)
|
||||||
|
# --branch develop → deploy-staging active, deploy-prod skipped
|
||||||
|
# --branch feat/x → only build active (when: always fallback, no deploy vars)
|
||||||
|
# --var WORKFLOW=gitflow --branch us/feature → deploy-prod + build active
|
||||||
|
|
||||||
|
stages:
|
||||||
|
- build
|
||||||
|
- deploy
|
||||||
|
|
||||||
|
variables:
|
||||||
|
DEPLOY_TARGET:
|
||||||
|
value: ""
|
||||||
|
description: "Deployment target — set by workflow rules"
|
||||||
|
|
||||||
|
workflow:
|
||||||
|
rules:
|
||||||
|
- if: "
|
||||||
|
$WORKFLOW = 'gitflow' &&
|
||||||
|
$CI_PIPELINE_SOURCE == /(push|web)/ &&
|
||||||
|
$CI_COMMIT_BRANCH =~ /^us\//
|
||||||
|
"
|
||||||
|
variables:
|
||||||
|
DEPLOY_TARGET: production
|
||||||
|
DEPLOY: true
|
||||||
|
BUILD: true
|
||||||
|
- if: '$CI_COMMIT_BRANCH == "develop"'
|
||||||
|
variables:
|
||||||
|
DEPLOY_TARGET: staging
|
||||||
|
- when: always
|
||||||
|
|
||||||
|
build:
|
||||||
|
stage: build
|
||||||
|
script: make build
|
||||||
|
rules:
|
||||||
|
- when: always
|
||||||
|
|
||||||
|
deploy-prod:
|
||||||
|
stage: deploy
|
||||||
|
script: make deploy ENV=production
|
||||||
|
rules:
|
||||||
|
- if: '$DEPLOY_TARGET == "production" && $DEPLOY == "true"'
|
||||||
|
when: on_success
|
||||||
|
- when: never
|
||||||
|
|
||||||
|
deploy-staging:
|
||||||
|
stage: deploy
|
||||||
|
script: make deploy ENV=staging
|
||||||
|
rules:
|
||||||
|
- if: '$DEPLOY_TARGET == "staging" '
|
||||||
|
when: on_success
|
||||||
|
- when: never
|
||||||
Reference in New Issue
Block a user