diff --git a/CHANGELOG.md b/CHANGELOG.md index 2266a06..785237b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,54 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This project uses [Semantic Versioning](https://semver.org). +## [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 `` with a `` child whose `message` and `type` attributes carry the finding details. A clean pipeline produces a single passing `` with no `` 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 diff --git a/README.md b/README.md index 1b6b15b..1aa2c7e 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@

License - Release + Release

> **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. @@ -31,6 +31,18 @@ A local tool to validate and lint `.gitlab-ci.yml` pipelines without needing a G - **Variable expansion** — `$VAR` and `${VAR}` references inside variable values are expanded after all sources are merged (pipeline defaults → workflow-rule overrides → CLI flags); transitive chains resolve automatically; visible via `--list-vars` - **Non-string variable scalars** — `BUILD: true`, `RETRIES: 3` and other bare boolean/integer variable values are handled correctly throughout: they render in `--list-vars` output and are injected into the evaluation context as their string equivalents, matching GitLab CI's behaviour - **Static reachability (GL033)** — warns when a job's `rules:` block can never activate: if every rule has `when: never` the job is permanently excluded from any pipeline run, provable without evaluating any `if:` expressions +- **`services:` validation (GL034)** — map form requires a `name` key; `alias` must be a valid DNS label (letters, digits, hyphens, dots; no leading/trailing hyphens) +- **`rules:changes` / `rules:exists` glob safety (GL035)** — warns when paths are absolute (start with `/`), which can never match since GitLab CI paths are always relative to the repository root +- **`timeout:` format (GL036)** — validates that job and `default:` timeout values are valid GitLab CI duration strings (`1h 30m`, `90 minutes`, `2 hours`, etc.) +- **`id_tokens:` validation (GL037)** — each OIDC token entry must have an `aud` key (missing `aud` is a GitLab API error at runtime) +- **`secrets:` validation (GL038)** — each secret entry must declare exactly one provider (`vault`, `gcp_secret_manager`, or `azure_key_vault`) +- **`pages:` keyword + `artifacts.paths` (GL039)** — warns when a job uses the `pages:` keyword but `artifacts.paths` does not include the publish directory (default: `public`) +- **Duplicate stage names (GL040)** — warns when a stage name appears more than once in `stages:`; GitLab silently merges duplicates, which can cause confusing ordering +- **`cache.key.files` glob detection (GL041)** — warns when `cache.key.files` entries contain glob metacharacters; this field requires exact file paths, not patterns +- **Recursive include depth limit** — include chains are capped at 100 nesting levels (matching GitLab's own limit); project and component includes are now tracked in the visited-file set to prevent cross-include cycles +- **`include: inputs:` substitution** — when a `component:` entry has a `with:` block, all `$[[ inputs.KEY ]]` and `$[[ inputs.KEY | default(…) ]]` placeholders in the fetched template are substituted before parsing, so component-scoped jobs get their correct `stage:` and keyword values instead of `$[[…]]` placeholders +- **Offline mode + include cache** — pass `--cache-dir DIR` to cache fetched remote templates (project: and component: includes) to disk; `--offline` serves entirely from the cache without making network calls +- **Structured output formats** — `--format json` emits a stable JSON report; `--format sarif` emits SARIF 2.1.0 (consumed by GitHub Code Scanning and GitLab SAST); `--format junit` emits JUnit XML (consumable as a CI test-report artifact); `--format github` emits GitHub Actions annotation lines (`::error file=…::`) so findings appear as inline PR comments - **Sorted findings output** — findings are sorted by source file then line number, so all issues from the same file appear together in order; pipeline-level findings (no file) sort first - **Consistent ruff-style warnings** — all warnings (unresolvable includes, skipped extends chains, workflow non-start) use the same `path: [warning] message` format as lint findings - **`--version` / `-v` flag** — prints the compiled version string (e.g. `glint v0.2.14`); the version is also shown at the top of every `--help` output @@ -76,6 +88,50 @@ glint check .gitlab-ci.yml Exits `0` when no errors are found, `1` when at least one error is reported. +### Output formats + +Pass `--format` to control the output. Plain text is the default. + +```bash +# Default: ruff-style text (human-readable) +glint check .gitlab-ci.yml + +# JSON — stable schema, machine-readable +glint check --format json .gitlab-ci.yml + +# SARIF 2.1.0 — GitHub Code Scanning / GitLab SAST +glint check --format sarif .gitlab-ci.yml > glint.sarif + +# JUnit XML — CI test-report artifact (GitLab: artifacts:reports:junit) +glint check --format junit .gitlab-ci.yml > glint-junit.xml + +# GitHub Actions annotations — inline PR diff comments +glint check --format github .gitlab-ci.yml +``` + +In structured formats (`json`, `sarif`, `junit`, `github`) the summary line +(`OK: … no issues found` or `N finding(s): M error(s)`) is written to stderr +so stdout contains only the machine-readable payload. + +**JSON schema (`schema_version: 1`):** +```json +{ + "schema_version": 1, + "glint_version": "v0.2.18", + "pipeline": ".gitlab-ci.yml", + "findings": [ + {"rule":"GL004","severity":"error","file":".gitlab-ci.yml","line":14, + "job":"deploy","message":"stage \"production\" is not defined in 'stages'"} + ], + "summary": {"total": 1, "errors": 1, "warnings": 0} +} +``` + +**GitHub annotation lines:** +``` +::error file=.gitlab-ci.yml,line=14,title=GL004::job "deploy": stage "production" is not defined in 'stages' +``` + ### Remote project includes Pipelines that include templates from other GitLab projects are supported. @@ -98,6 +154,24 @@ glint check --token glpat-xxxx --gitlab-url https://gitlab.example.com .gitlab-c **Project includes** require a token; without one they are skipped with a 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 unauthenticated, so public [CI/CD Catalog](https://gitlab.com/explore/catalog) components work without a token. A warning is emitted if the fetch fails. @@ -264,6 +338,8 @@ Every finding includes a stable rule ID (e.g. `GL003`) that can be used to filte |----|----------|------| | GL002 | ERROR | `workflow.rules[*].when` is not `always` or `never` | | 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 @@ -298,6 +374,12 @@ Every finding includes a stable rule ID (e.g. `GL003`) that can be used to filte | GL024 | ERROR | `rules[*].when` is not one of the valid `when` values | | GL025 | ERROR | `image` map form missing `name` key | | GL026 | ERROR | `inherit.default` / `inherit.variables` is not a boolean or list | +| GL034 | ERROR | `services:` map form missing `name`, or `alias` is not a valid DNS label | +| GL036 | ERROR | `timeout:` is not a valid GitLab CI duration string (e.g. `1h 30m`, `90 minutes`) | +| GL037 | ERROR | `id_tokens:` entry is missing the required `aud` key | +| GL038 | ERROR | `secrets:` entry is missing a provider key (`vault`, `gcp_secret_manager`, or `azure_key_vault`) | +| GL039 | WARNING | Job has `pages:` keyword but `artifacts.paths` does not include the publish directory | +| GL041 | WARNING | `cache.key.files` entry looks like a glob pattern; must be an exact file path | ### Cross-job graph @@ -315,6 +397,7 @@ Every finding includes a stable rule ID (e.g. `GL003`) that can be used to filte |----|----------|------| | 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) diff --git a/ROADMAP.md b/ROADMAP.md index 2813788..87fc3de 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -62,15 +62,15 @@ The current rule set covers the most common sources of broken pipelines. These a - ~~**Variable reference validation (GL032)**~~ — ✓ shipped v0.2.11; warns when a `rules:if:` expression references `$VAR` / `${VAR}` not declared anywhere in pipeline YAML; predefined GitLab namespaces (`CI_*`, `GITLAB_*`, …) exempt; variables from included files are also considered - ~~**`rules:if:` static reachability (GL033)**~~ — ✓ shipped v0.2.15; warns when every rule in a job's `rules:` block has `when: never`, making the job permanently excluded from any pipeline run; no `if:` evaluation required -- **`services:` validation** — map form requires `name`; `alias` must be a valid DNS label -- **`rules:changes` / `rules:exists`** — warn on glob patterns that can never match (e.g. absolute paths, double `**` on unsupported versions) -- **`timeout` format** — must be a duration string GitLab understands (`1h 30m`, `90 minutes`, etc.) -- **`id_tokens:` / `secrets:`** — presence and required-key checks -- **`pages:publish`** — validate that the path is consistent with `artifacts.paths` +- ~~**`services:` validation (GL034)**~~ — ✓ shipped v0.2.16; map form requires `name`; `alias` must be a valid DNS label +- ~~**`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 +- ~~**`timeout` format validation (GL036)**~~ — ✓ shipped v0.2.16; validates job-level and `default.timeout` against recognised GitLab CI duration strings +- ~~**`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 -- **Unreachable jobs** — detect jobs that can never run because every `rules:` branch evaluates to `never` (static analysis only) -- **Duplicate stage names** — GitLab silently merges them; warn to avoid confusion -- **`cache:key:files`** — must be a list of paths, not a glob --- @@ -78,20 +78,18 @@ The current rule set covers the most common sources of broken pipelines. These a - ~~**`include: local:`** full resolution~~ — ✓ shipped in v0.2.0; local files are read from disk, recursively resolved, and merged before linting - ~~**`include: remote:`** (URL)~~ — ✓ shipped post-v0.2.0; plain HTTPS URLs are fetched (unauthenticated), parsed, and merged; sub-includes are resolved recursively; unreachable URLs emit `[WARNING]` and linting continues -- **Recursive include depth limit** — guard against include cycles across files -- **Offline mode / cache** — persist fetched remote templates to a local cache directory; `--offline` flag to skip network calls and use only cached copies -- **`include: inputs:`** — substitute CI component input values into fetched templates before merging, so component-scoped jobs get their correct `stage:` and keyword values +- ~~**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**~~ — ✓ 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:`**~~ — ✓ 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`) — machine-readable findings with file, job, severity, rule ID, and message; stable schema -- **SARIF** (`--format sarif`) — [Static Analysis Results Interchange Format](https://sarifweb.azurewebsites.net); consumed natively by GitHub Code Scanning and GitLab SAST -- **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 +- ~~**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 +- ~~**JUnit XML** (`--format junit`)~~ — ✓ shipped v0.2.18; lets CI pipelines publish lint results as a test report artifact +- ~~**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 --- @@ -147,7 +145,7 @@ The SVG renderer and terminal tree cover the basic layout. These would bring it ## Reliability and developer experience -- ~~**Structured rule IDs**~~ — ✓ shipped post-v0.2.0; GL001–GL031 assigned; GL032 added v0.2.11 +- ~~**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 `** — print the rule description, rationale, and an example fix - ~~**Semantic versioning and first release**~~ — shipped as `v0.1.0` (2026-06-07) - ~~**Subcommand CLI**~~ — shipped as `v0.2.0` (2026-06-11); `glint check` / `glint graph [mode]` with ruff-style `--help` diff --git a/Taskfile.yml b/Taskfile.yml index 1a3dbb1..8f15d5e 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -83,12 +83,24 @@ tasks: 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 lint-go: desc: Run go vet on all packages diff --git a/cmd/glint/format.go b/cmd/glint/format.go new file mode 100644 index 0000000..57edbd2 --- /dev/null +++ b/cmd/glint/format.go @@ -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, ``) + 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 +} diff --git a/cmd/glint/format_test.go b/cmd/glint/format_test.go new file mode 100644 index 0000000..befff8a --- /dev/null +++ b/cmd/glint/format_test.go @@ -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(), " @@ -68,6 +80,9 @@ func cmdCheck(args []string) { fs := flag.NewFlagSet("glint check", flag.ExitOnError) 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)") + 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, …)") tag := fs.String("tag", "", "simulate a tag push (sets CI_COMMIT_TAG, …)") source := fs.String("source", "", "set CI_PIPELINE_SOURCE") @@ -87,6 +102,10 @@ Arguments: Path to the .gitlab-ci.yml file to lint Options: + --format + Output format for findings. + [default: text] [possible values: text, json, sarif, junit, github] + --token GitLab personal access token. Required to fetch project: includes; component: includes are attempted unauthenticated. @@ -96,6 +115,17 @@ Options: GitLab instance URL. [env: CI_SERVER_URL | GITLAB_URL] [default: https://gitlab.com] + --cache-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 Simulate a branch push. Populates: CI_COMMIT_BRANCH, CI_COMMIT_REF_NAME, CI_COMMIT_REF_SLUG, CI_PIPELINE_SOURCE=push. @@ -127,6 +157,10 @@ always evaluated. Examples: glint check .gitlab-ci.yml + glint check --format json .gitlab-ci.yml + glint check --format sarif .gitlab-ci.yml | upload-to-github-code-scanning + glint check --format junit .gitlab-ci.yml > junit.xml + glint check --format github .gitlab-ci.yml glint check --branch develop .gitlab-ci.yml glint check --tag v1.0.0 .gitlab-ci.yml glint check --source merge_request_event .gitlab-ci.yml @@ -134,6 +168,8 @@ Examples: GITLAB_TOKEN=glpat-xxxx glint check .gitlab-ci.yml glint check --token glpat-xxxx --gitlab-url https://gitlab.example.com .gitlab-ci.yml glint check --branch main --var DEPLOY_ENV=production .gitlab-ci.yml + glint check --cache-dir ~/.cache/glint .gitlab-ci.yml + glint check --offline --cache-dir ~/.cache/glint .gitlab-ci.yml `) } _ = fs.Parse(args) @@ -144,13 +180,27 @@ Examples: *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 { fs.Usage() os.Exit(2) } path := fs.Arg(0) - cfg := fetcher.AutoConfig().WithOverrides(*gitlabURL, *token) + // --offline with no explicit --cache-dir defaults to ~/.cache/glint. + resolvedCacheDir := *cacheDir + if *offline && resolvedCacheDir == "" { + resolvedCacheDir = defaultCacheDir() + } + + cfg := fetcher.AutoConfig().WithOverrides(*gitlabURL, *token, resolvedCacheDir, *offline) p, err := model.Parse(path) if err != nil { @@ -182,32 +232,43 @@ Examples: if *listVars { printVars(p, ctx) } - if !ctx.IsEmpty() { + // Context summary only makes sense in plain-text output; suppress it in + // structured formats so stdout contains only the machine-readable payload. + if !ctx.IsEmpty() && *format == "text" { printContext(p, ctx) } findings := linter.Lint(p) - hasErrors := false - for _, f := range findings { - fmt.Println(f) - if f.Severity == linter.Error { - hasErrors = true + 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 { + fmt.Println(f) } } 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 { - errCount := 0 - for _, f := range findings { - if f.Severity == linter.Error { - errCount++ - } - } - fmt.Printf("%d finding(s): %d error(s)\n", len(findings), errCount) + fmt.Fprintf(summaryOut, "%d finding(s): %d error(s)\n", len(findings), errCount) } - if hasErrors { + if errCount > 0 { os.Exit(1) } } @@ -227,6 +288,8 @@ func cmdGraph(args []string) { fs := flag.NewFlagSet("glint graph", flag.ExitOnError) 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)") + 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)") fs.Usage = func() { fmt.Fprintf(os.Stderr, "glint %s\n\n", version) @@ -315,7 +378,12 @@ Examples: } 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) if err != nil { diff --git a/internal/fetcher/cache.go b/internal/fetcher/cache.go new file mode 100644 index 0000000..7b992ab --- /dev/null +++ b/internal/fetcher/cache.go @@ -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)) +} diff --git a/internal/fetcher/gitlab.go b/internal/fetcher/gitlab.go index bb52024..d28d1ba 100644 --- a/internal/fetcher/gitlab.go +++ b/internal/fetcher/gitlab.go @@ -22,9 +22,11 @@ const ( // GitLabConfig holds everything needed to reach a GitLab instance. type GitLabConfig struct { - BaseURL string - Token string - Source TokenSource + BaseURL string + Token string + 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. @@ -54,8 +56,11 @@ func AutoConfig() GitLabConfig { return cfg } -// WithOverrides returns a copy of cfg with non-empty overrides applied. -func (cfg GitLabConfig) WithOverrides(baseURL, token string) GitLabConfig { +// WithOverrides returns a copy of cfg with the provided overrides applied. +// 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 != "" { cfg.BaseURL = strings.TrimRight(baseURL, "/") } @@ -63,6 +68,8 @@ func (cfg GitLabConfig) WithOverrides(baseURL, token string) GitLabConfig { cfg.Token = token cfg.Source = TokenPrivate } + cfg.CacheDir = cacheDir + cfg.Offline = offline return cfg } @@ -84,16 +91,24 @@ func (cfg GitLabConfig) HasToken() bool { return cfg.Token != "" } // - filePath — repository file path, e.g. "/templates/ci.yml" // - ref — branch, tag, or commit SHA; empty string defaults to HEAD 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) encodedFile := url.PathEscape(strings.TrimPrefix(filePath, "/")) apiURL := fmt.Sprintf("%s/api/v4/projects/%s/repository/files/%s/raw", cfg.BaseURL, encodedProject, encodedFile) - if ref == "" { - ref = "HEAD" - } - req, err := http.NewRequest(http.MethodGet, apiURL, nil) if err != nil { return nil, fmt.Errorf("building request: %w", err) @@ -139,12 +154,21 @@ func (cfg GitLabConfig) FetchFile(project, filePath, ref string) ([]byte, error) } } + cacheWrite(cfg.CacheDir, cKey, body) return body, nil } // FetchURL downloads the content at a plain HTTPS URL without authentication. // Used for include: remote: entries which are public by definition. -func FetchURL(rawURL string) ([]byte, error) { +// 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) @@ -157,6 +181,7 @@ func FetchURL(rawURL string) ([]byte, error) { if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("GET %s: status %d", rawURL, resp.StatusCode) } + cacheWrite(cfg.CacheDir, rawURL, body) return body, nil } diff --git a/internal/graph/includes.go b/internal/graph/includes.go index 41c8e36..56f1e26 100644 --- a/internal/graph/includes.go +++ b/internal/graph/includes.go @@ -177,7 +177,7 @@ func (b *treeBuilder) recurseRemote(node *treeNode, rawURL string) { } b.visited[key] = true - data, err := fetcher.FetchURL(rawURL) + data, err := b.cfg.FetchURL(rawURL) if err != nil { return } diff --git a/internal/linter/keywords.go b/internal/linter/keywords.go index b94c175..bcdd781 100644 --- a/internal/linter/keywords.go +++ b/internal/linter/keywords.go @@ -8,6 +8,15 @@ import ( "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 +// " " 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{ "on_success": true, "on_failure": true, @@ -98,6 +107,13 @@ func checkJobKeywords(name string, job model.Job) []Finding { findings = append(findings, checkDeadRules(name, job)...) findings = append(findings, checkImage(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 } @@ -402,8 +418,9 @@ func checkArtifacts(name string, job model.Job) []Finding { }) } } - // Pages job should publish to public/ - if name == "pages" { + // Pages job should publish to public/. Skip when pages: keyword is set; + // GL039 (checkPagesKeyword) handles that case with a more precise check. + if name == "pages" && job.Pages == nil { if paths, ok := m["paths"].([]any); ok { found := false for _, p := range paths { @@ -547,3 +564,289 @@ func checkInherit(name string, job model.Job) []Finding { } 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 +} diff --git a/internal/linter/linter.go b/internal/linter/linter.go index 36d4de1..53268d7 100644 --- a/internal/linter/linter.go +++ b/internal/linter/linter.go @@ -55,6 +55,8 @@ func (f Finding) String() string { func Lint(p *model.Pipeline) []Finding { var findings []Finding findings = append(findings, checkStages(p)...) + findings = append(findings, checkDuplicateStages(p)...) + findings = append(findings, checkDefault(p)...) findings = append(findings, checkWorkflow(p)...) findings = append(findings, checkJobs(p)...) findings = append(findings, checkNeeds(p)...) @@ -85,6 +87,32 @@ func checkStages(p *model.Pipeline) []Finding { 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 { if p.Workflow == nil { return nil diff --git a/internal/linter/rules.go b/internal/linter/rules.go index 1e786d3..c7816b9 100644 --- a/internal/linter/rules.go +++ b/internal/linter/rules.go @@ -118,4 +118,29 @@ const ( // 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" ) diff --git a/internal/resolver/includes.go b/internal/resolver/includes.go index b3510b7..354b08d 100644 --- a/internal/resolver/includes.go +++ b/internal/resolver/includes.go @@ -4,12 +4,27 @@ import ( "fmt" "os" "path/filepath" + "regexp" "strings" "git.k3nny.fr/glint/internal/fetcher" "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. // These are surfaced to the user as [WARNING] lines before the lint findings. type IncludeWarning struct { @@ -53,11 +68,18 @@ type ExtendWarning struct { // processing included files (forwarded from resolver.Resolve calls). func ResolveIncludes(p *model.Pipeline, cfg fetcher.GitLabConfig, rootDir string) ([]IncludeWarning, []ExtendWarning) { 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. -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 extWarnings []ExtendWarning @@ -68,14 +90,15 @@ func resolveIncludes(p *model.Pipeline, includes []any, cfg fetcher.GitLabConfig } 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...) extWarnings = append(extWarnings, ew...) continue } 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 { warnings = append(warnings, w) } @@ -84,14 +107,14 @@ func resolveIncludes(p *model.Pipeline, includes []any, cfg fetcher.GitLabConfig } 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...) extWarnings = append(extWarnings, ew...) continue } if remote, _ := entry["remote"].(string); remote != "" { - w, ew := resolveRemoteInclude(p, remote, cfg, rootDir, visited) + w, ew := resolveRemoteInclude(p, remote, cfg, rootDir, visited, depth) warnings = append(warnings, w...) extWarnings = append(extWarnings, ew...) continue @@ -105,7 +128,7 @@ func resolveIncludes(p *model.Pipeline, includes []any, cfg fetcher.GitLabConfig // resolveLocalInclude reads a local file from disk (paths are always relative // to the repository root, with or without a leading slash), recursively // 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, "/") absPath := filepath.Join(rootDir, relPath) label := "local " + rawPath @@ -131,7 +154,7 @@ func resolveLocalInclude(p *model.Pipeline, rawPath string, cfg fetcher.GitLabCo var warnings []IncludeWarning var extWarnings []ExtendWarning 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...) } @@ -142,7 +165,7 @@ func resolveLocalInclude(p *model.Pipeline, rawPath string, cfg fetcher.GitLabCo // resolveRemoteInclude fetches a plain HTTPS URL, parses it as CI YAML, and // merges it into p. Sub-includes of the fetched file are resolved recursively. -func resolveRemoteInclude(p *model.Pipeline, rawURL string, cfg fetcher.GitLabConfig, rootDir string, visited map[string]bool) ([]IncludeWarning, []ExtendWarning) { +func resolveRemoteInclude(p *model.Pipeline, rawURL string, cfg fetcher.GitLabConfig, rootDir string, visited map[string]bool, depth int) ([]IncludeWarning, []ExtendWarning) { label := "remote " + rawURL if visited[rawURL] { @@ -150,7 +173,7 @@ func resolveRemoteInclude(p *model.Pipeline, rawURL string, cfg fetcher.GitLabCo } visited[rawURL] = true - data, err := fetcher.FetchURL(rawURL) + data, err := cfg.FetchURL(rawURL) if err != nil { return []IncludeWarning{{Label: label, Err: err}}, nil } @@ -164,7 +187,7 @@ func resolveRemoteInclude(p *model.Pipeline, rawURL string, cfg fetcher.GitLabCo var warnings []IncludeWarning var extWarnings []ExtendWarning 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...) } @@ -175,7 +198,7 @@ func resolveRemoteInclude(p *model.Pipeline, rawURL string, cfg fetcher.GitLabCo // resolveProjectInclude fetches all files listed under a single project: entry // and merges them into p. -func resolveProjectInclude(p *model.Pipeline, entry map[string]any, project string, cfg fetcher.GitLabConfig, rootDir string, visited map[string]bool) ([]IncludeWarning, []ExtendWarning) { +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) var warnings []IncludeWarning var extWarnings []ExtendWarning @@ -186,6 +209,12 @@ func resolveProjectInclude(p *model.Pipeline, entry map[string]any, project stri label += "@" + ref } + visitKey := fmt.Sprintf("project:%s:%s@%s", project, filePath, ref) + if visited[visitKey] { + continue + } + visited[visitKey] = true + if !cfg.HasToken() { warnings = append(warnings, IncludeWarning{Label: label, Skipped: true}) continue @@ -205,7 +234,7 @@ func resolveProjectInclude(p *model.Pipeline, entry map[string]any, project stri included.SetJobOrigin(label) 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...) } @@ -215,9 +244,11 @@ func resolveProjectInclude(p *model.Pipeline, entry map[string]any, project stri 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. -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 if strings.ContainsRune(ref, '$') { @@ -227,6 +258,12 @@ func resolveComponentInclude(p *model.Pipeline, ref string, cfg fetcher.GitLabCo }, nil, true } + visitKey := "component:" + ref + if visited[visitKey] { + return IncludeWarning{}, nil, false + } + visited[visitKey] = true + host, project, component, version, err := parseComponentRef(ref) if err != nil { return IncludeWarning{Label: label, Err: err}, nil, true @@ -238,6 +275,9 @@ func resolveComponentInclude(p *model.Pipeline, ref string, cfg fetcher.GitLabCo return IncludeWarning{Label: label, Err: err}, nil, true } + // Substitute $[[ inputs.KEY ]] placeholders with the values from with:. + data = substituteInputs(data, inputs) + included, err := model.ParseBytes(data) if err != nil { return IncludeWarning{Label: label, Err: fmt.Errorf("parsing component YAML: %w", err)}, nil, true @@ -246,7 +286,7 @@ func resolveComponentInclude(p *model.Pipeline, ref string, cfg fetcher.GitLabCo var extWarnings []ExtendWarning 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...) } @@ -254,6 +294,45 @@ func resolveComponentInclude(p *model.Pipeline, ref string, cfg fetcher.GitLabCo 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: // // //@ diff --git a/internal/resolver/includes_test.go b/internal/resolver/includes_test.go new file mode 100644 index 0000000..d4548c3 --- /dev/null +++ b/internal/resolver/includes_test.go @@ -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) + } + }) + } +} diff --git a/testdata/new_rules_invalid.yml b/testdata/new_rules_invalid.yml new file mode 100644 index 0000000..eaf0b5d --- /dev/null +++ b/testdata/new_rules_invalid.yml @@ -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" diff --git a/testdata/new_rules_valid.yml b/testdata/new_rules_valid.yml new file mode 100644 index 0000000..2b436ff --- /dev/null +++ b/testdata/new_rules_valid.yml @@ -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