34 Commits

Author SHA1 Message Date
k3nny b21ef5c0bb feat(cicontext): rules:changes: path-glob evaluation; 100% test coverage
release / Build and publish release (push) Successful in 1m12s
ci / vet, staticcheck, test, build (push) Failing after 1m54s
- Add --changes PATH and --changes-from REF flags to glint check and glint graph
  for rules:changes: evaluation. --changes marks files explicitly; --changes-from
  runs git diff --name-only <REF> automatically. Both flags can be combined.
- Implement doublestar glob matching (*, ** across path segments) in EvalJob and
  EvalWorkflow; extended {paths, compare_to} map form supported.
- Without --changes/--changes-from the condition stays permissive (existing behaviour).
- Context summary line now shows changed-file count when file data is provided.
- Achieve 100% statement coverage: comprehensive tests added across all packages;
  removed provably dead code; added testability seams (exit, userHomeDirFn,
  execCommandOutput variables) to cover previously unreachable paths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 22:47:32 +02:00
k3nny 04f17f8616 test(coverage): add unit tests across all packages; remove dead code
ci / vet, staticcheck, test, build (push) Successful in 2m25s
- Added comprehensive table-driven test suites for all packages:
  cmd/glint, cicontext, fetcher, graph, linter, model, resolver.
  Coverage reaches 98%+ statement coverage across the codebase.
- Replaced os.Exit calls in cmd/glint with an `exit` variable so tests
  can capture exit codes without terminating the test process.
- Removed unreachable code found during coverage analysis:
  dead guard in cicontext.parseRegexLiteral; dead len(jobs)==0 branch
  in graph.Pipeline; skipWin struct field and dead continue in
  graph.convertToPNG; pipelineSVG return type simplified to string.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 22:03:46 +02:00
k3nny 7f7e2bf77b docs(docs): release v0.2.20
ci / vet, staticcheck, test, build (push) Successful in 1m52s
release / Build and publish release (push) Successful in 1m17s
Add FEATURES.md and USAGE.md entries to the [0.2.20] changelog section.
No code changes — documentation reorganisation only.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 12:32:47 +02:00
k3nny 1339ab4149 fix(linter): 🐛 yaml parser with escape in regex
ci / vet, staticcheck, test, build (push) Successful in 1m54s
release / Build and publish release (push) Successful in 1m10s
2026-06-12 01:04:35 +02:00
k3nny fef1536e1b feat(cli): ruff-style output, implicit context defaults, --list-vars
ci / vet, staticcheck, test, build (push) Successful in 2m16s
release / Build and publish release (push) Successful in 1m6s
- Finding format now follows file:line: RULEID [severity] message,
  matching ruff and other modern linters (GL003 [error] job "x": ...)
- glint check and glint graph default to --branch main --source push
  when no context flag is given; rules:if: is always evaluated
- --list-vars flag on both commands prints sorted KEY=VALUE of all
  collected variables (YAML, workflow-rule union, effective context)
- CHANGELOG [Unreleased] promoted to [0.2.11]; README badge updated;
  ROADMAP marks newly shipped items

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 00:36:32 +02:00
k3nny 46a1cf3c08 feat: add go lint
ci / vet, staticcheck, test, build (push) Successful in 2m3s
release / Build and publish release (push) Successful in 1m9s
2026-06-11 23:56:09 +02:00
k3nny 18c8fc82c9 feat(linter): add GL032 variable reference validation in rules:if:
release / Build and publish release (push) Successful in 1m11s
Warn when a $VAR or ${VAR} reference in a rules:if: expression is not
declared in pipeline variables:, the job's own variables:, or any
workflow:rules:variables: block. Predefined GitLab CI namespaces (CI_*,
GITLAB_*, FF_*, RUNNER_*, TRIGGER_*, CHAT_*) are always exempt.

Each undeclared variable is reported at most once per job. The finding
is a WARNING (not an error) because variables may also be set in GitLab
CI/CD project settings, which are invisible to glint at lint time.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 23:13:25 +02:00
k3nny f48bf02152 feat(linter): add structured rule IDs (GL001–GL031)
Every Finding now carries a stable Rule string field with a GL### code.
The ID appears in output between the source location and the message:

  [ERROR] job "deploy" (ci.yml:14) GL003: missing required field 'script'
  [WARNING] (ci.yml) GL001: no stages defined

Rules:
  GL001 no-stages          GL002 workflow-when       GL003 missing-script
  GL004 unknown-stage      GL005 only-rules-conflict GL006 except-rules-conflict
  GL007 deprecated-only    GL008 invalid-when        GL009 delayed-no-start-in
  GL010 start-in-no-delayed GL011 invalid-parallel   GL012 invalid-retry
  GL013 invalid-retry-when GL014 invalid-allow-failure GL015 invalid-interruptible
  GL016 trigger-with-script GL017 invalid-trigger    GL018 invalid-coverage
  GL019 invalid-release    GL020 invalid-environment GL021 invalid-artifacts
  GL022 pages-public       GL023 invalid-cache       GL024 invalid-rules-when
  GL025 invalid-image      GL026 invalid-inherit     GL027 needs-unknown
  GL028 needs-stage-order  GL029 needs-cycle         GL030 unknown-dependency
  GL031 dependency-stage

Changes:
- internal/linter/rules.go: new file with all 31 constants + doc comments
- linter.Finding: add Rule string field; String() inserts it before the
  message colon when non-empty; format unchanged when Rule == ""
- All Finding{} literals in linter.go, keywords.go, needs.go,
  dependencies.go updated with the correct Rule: constant
- README.md lint rules table: new ID column added to all four sections
- CHANGELOG.md: entry in [Unreleased]

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 22:56:24 +02:00
k3nny 6d0aefca5b docs(docs): update ROADMAP with post-v0.2.0 shipped items
Mark as done:
- include: remote: URL fetching
- workflow:rules:variables: propagation
- Expression evaluator: multi-line, \${VAR}, regex flags, variable regex
  RHS, bare true/false/integer literals
- File and line numbers on findings
- needs: optional: true downgraded to warning
- extends: missing script downgraded to warning
- glint graph includes jobs-per-file

Add new remaining items:
- rules:if: static reachability analysis (future)
- Findings quality section broken out from lint coverage

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 22:38:45 +02:00
k3nny de6a526560 feat(linter): propagate workflow:rules:variables: into job rule evaluation
workflow:rules: can define variables: on matching rules (GitLab CI 15.0+).
These variables are now injected into the evaluation context before job
rules:if: expressions are evaluated, making patterns like:

  workflow:
    rules:
      - if: '$CI_COMMIT_BRANCH == "main"'
        variables:
          DEPLOY_TARGET: production
  deploy:
    rules:
      - if: '$DEPLOY_TARGET == "production"'

work correctly with glint check --branch main.

Changes:
- model.Rule: add Variables map[string]any field (yaml:"variables")
- cicontext.Context: add pinned map tracking which vars must not be
  overwritten; New() pins all shortcut and --var variables; add
  Inject(key, value) which writes only when key is not pinned
- cicontext.ExtractStringVars: shared helper that converts map[string]any
  variable blocks (plain string or {value:...} form) to map[string]string
- cicontext.EvalWorkflow: returns (bool, map[string]string) — the vars of
  the matching workflow rule alongside the runs/no-runs result
- cmd/glint/main.go: enrichContext() injects pipeline-level variable
  defaults then workflow-rule variables before printContext; applied in
  both cmdCheck and cmdGraph

Injection priority (highest wins):
  --var CLI overrides > --branch/--tag/--source shortcuts
  > workflow-rule variables > pipeline variables: defaults

Adds 15 unit tests (TestEvalWorkflow, TestContextInject,
TestExtractStringVars, TestWorkflowVarsJobEval) and a testdata fixture
(workflow_vars.yml) validated across four branch contexts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 22:35:55 +02:00
k3nny a0e2582cf1 fix(linter): support bare true/false and integer literals in rules:if:
release / Build and publish release (push) Successful in 1m15s
GitLab CI expressions allow unquoted true, false, and integers as
comparison operands (all treated as their string representations):

  $GATEWAY_ENABLED == true    (equivalent to == "true")
  $FEATURE_FLAG == false      (equivalent to == "false")
  $PARALLEL == 4              (equivalent to == "4")
  $ENABLED == 1 / == 0

Previously these fell through to permissive true because parseValue
only recognised $VAR, "${VAR}", quoted strings, and null. Added:
  - true/false keyword branch → returns "true"/"false"
  - integer literal branch (digits only) → returns decimal string

All three new forms are correctly excluded from longer identifier
prefixes (identByte boundary check). Adds 8 new unit tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 22:20:59 +02:00
k3nny e931b9d1c9 fix(linter): improve rules:if: expression evaluator
Four correctness fixes to the GitLab CI expression parser in
internal/cicontext/eval.go:

- Multi-line: \n and \r are now treated as whitespace in skipWS so
  block-scalar or folded-scalar if: values with || / && on continuation
  lines evaluate correctly instead of falling back to permissive true.
- ${VAR} curly-brace variable syntax now supported in parseValue.
- Regex flags (/pattern/i, /pattern/m, /pattern/s) are now consumed and
  translated to Go (?i)/(?m)/(?s) prefixes via applyRegexFlags.
- Variable on RHS of =~ / !~: when the right operand is $VAR, the
  variable's value is interpreted as a /regex/[flags] string via
  extractRegexFromString; non-regex values fall back to permissive true.

Adds 16 new unit tests covering all four cases and a testdata fixture
(rules_if_expr.yml) exercising multi-line, ${VAR}, and /pattern/i in a
real pipeline with context flags.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 22:08:08 +02:00
k3nny b21a7d60dc feat(resolver,graph): fetch and resolve include: remote: HTTPS URLs
release / Build and publish release (push) Successful in 1m14s
Remote includes (include: remote: https://...) were previously skipped
silently in the resolver and rendered as unexpanded leaf nodes in the
graph.

Changes:
- fetcher.FetchURL: new shared unauthenticated HTTP GET helper
- resolver: resolveRemoteInclude fetches the URL, parses YAML, sets job
  origin to the URL string, recursively resolves sub-includes, and emits
  a warning on failure (lint continues on the rest of the pipeline)
- graph: recurseRemote fetches the URL, captures direct job names, and
  recurses into sub-includes so remote nodes expand like local ones

Adds testdata/includes_remote.yml fixture.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 21:42:15 +02:00
k3nny d34c39927d fix(linter): downgrade needs optional:true missing-job to warning
release / Build and publish release (push) Successful in 1m12s
parseNeedJobNames is replaced by parseNeedEntries which preserves the
optional flag from each needs: entry. When a referenced job does not
exist and optional:true is set, the finding is now WARNING instead of
ERROR, matching GitLab CI runtime behavior (the dependency is silently
skipped when the job is absent from a conditional include).

Optional missing deps are also excluded from the cycle-detection graph
since there is no real dependency edge to trace.

Adds a fixture case in testdata/needs.yml to prevent regression.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 21:27:16 +02:00
k3nny a303f63a5e feat(linter): add file/line to findings; downgrade extends missing-script to warning
Every finding now carries the source file and exact line number of the job
key in its YAML file. Format: [ERROR] job "name" (file.yml:12): message.

Pipeline-level findings (workflow rules, no stages) reference p.SourceFile.
Cross-file include jobs (local, project, component) carry the include source
as their File, set via Pipeline.SetJobOrigin after each ParseBytes call in
the resolver.

Line numbers come from the yaml.Node key node (exact job-name line) in a
new document-level first pass in ParseBytes, replacing the previous
map[string]yaml.Node approach which only gave value-node lines.

Also: jobs that declare extends: but have no script after resolution now
emit WARNING instead of ERROR. The script may come from a base in a remote
include that was not fetched (no token, offline), making the error a false
positive in common project setups.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 21:24:18 +02:00
k3nny a962c996c1 feat(graph): show jobs per file in include dependency graph
Each node in 'glint graph includes' now lists the jobs defined directly
in that file. Jobs appear as rounded Mermaid nodes with a distinct
light-purple style, connected with dashed arrows (-.->). This visual
distinction separates ownership (file -.-> job) from the include
hierarchy (file --> included-file).

The root file's jobs are collected by re-parsing it without include
resolution; local and fetched project/component nodes populate their
job list in the existing recurse* methods.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 21:03:50 +02:00
k3nny e5f926b55f docs: 📝 add logo
release / Build and publish release (push) Successful in 1m47s
2026-06-11 20:40:39 +02:00
k3nny 8c3ce050f5 Merge pull request 'fix(model): handle YAML map forms that caused unmarshall errors' (#3) from fix/unmarshall_errors into main
Reviewed-on: #3
2026-06-11 20:31:30 +02:00
k3nny c4ab64391d fix(model): handle YAML map forms that caused unmarshall errors
Variables with value/description/options sub-keys, default.image in map
form, default.before_script / default.after_script as block scalars, and
rules.changes / rules.exists in {paths, compare_to} map form all caused
"yaml: cannot unmarshal !!map into string" because the struct fields were
typed too narrowly.

Changed types in model.Pipeline, model.DefaultConfig, and model.Rule to
accept any to match GitLab CI spec flexibility (13.7+ variable declarations,
15.3+ rules.changes map form, image map form in default block).

Adds testdata/script_multiline.yml covering all these patterns.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 20:25:53 +02:00
k3nny 4cc50afb5f fix(build): replace actions/checkout@v4 with plain git clone
release / Build and publish release (push) Successful in 1m3s
actions/checkout@v4 requires Node.js which is absent in golang:alpine.
Clone the repo directly with git using an oauth2 token in the URL,
removing the Node.js dependency entirely.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 01:04:27 +02:00
k3nny dfbafd8ed3 chore(build): use golang:1.26-alpine container instead of ubuntu-latest
release / Build and publish release (push) Failing after 41s
Switch from the full ubuntu runner image to golang:1.26-alpine via the
container: directive. Go is pre-installed so actions/setup-go is dropped;
curl, git, and jq are added with apk in the first step.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 01:00:30 +02:00
k3nny 58cfbb4a57 fix(build): replace upload-artifact@v4 with direct Gitea API upload
upload-artifact@v4 uses a backend API incompatible with Gitea (GHES).
Collapse to a single job that builds both targets sequentially and uploads
directly to a Gitea release via the REST API, removing the need for
artifact passing between jobs entirely.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 00:59:09 +02:00
k3nny e7b51929f8 chore(build): add Gitea Actions release pipeline
release / Build (${{ matrix.goos }}/${{ matrix.goarch }}) (amd64, linux, -linux-amd64) (push) Failing after 11m48s
release / Build (${{ matrix.goos }}/${{ matrix.goarch }}) (amd64, windows, .exe) (push) Failing after 5m16s
release / Publish release (push) Has been skipped
Builds Linux x64 and Windows x64 binaries on tag pushes (v*) and
publishes them as assets on a Gitea release via the REST API.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 00:38:21 +02:00
k3nny 88f20165db feat(cli)!: subcommand CLI, graph tree mode, local include resolution
BREAKING CHANGES:
- `glint <file>` removed; use `glint check <file>`
- `--graph <mode>` removed; use `glint graph [mode]`
- `--graph-out` renamed to `--out` on `glint graph`

feat(cli): ruff-style subcommands — `glint check` and `glint graph [mode]`
feat(graph): `glint graph tree` — terminal job tree with context annotations
feat(graph): context flags (--branch/--tag/--source/--var) on `glint graph`
feat(resolver): recursive local include resolution from disk
fix(resolver): extends unknown base emits warning instead of fatal error
fix(model): script/before_script/after_script accept block scalar string form
test(linter): Samba project CI fixtures as integration tests
chore(build): fix .gitignore to not exclude cmd/glint/ directory
docs: update CHANGELOG, README, ROADMAP for v0.2.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 00:27:28 +02:00
k3nny aca37de2b1 fix(ci): fix build 2026-06-10 23:02:06 +02:00
k3nny 51b3e1f297 fix(project): rename tool to glint 2026-06-10 22:40:42 +02:00
100 changed files with 14580 additions and 635 deletions
+40
View File
@@ -0,0 +1,40 @@
name: ci
on:
push:
branches:
- '**'
pull_request:
jobs:
ci:
name: vet, staticcheck, test, build
runs-on: ubuntu-latest
container:
image: golang:1.26-alpine
steps:
- name: Install tools
run: apk add --no-cache git
- name: Checkout
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
SERVER_URL: ${{ github.server_url }}
REPO: ${{ github.repository }}
REF: ${{ github.ref_name }}
run: |
git clone --depth 1 --branch "$REF" \
"$(echo "$SERVER_URL" | sed "s|https://|https://oauth2:${TOKEN}@|")/${REPO}.git" .
- name: vet
run: go vet ./...
- name: staticcheck
run: go tool staticcheck ./...
- name: test
run: go test ./...
- name: build
run: go build ./cmd/glint/...
+70
View File
@@ -0,0 +1,70 @@
name: release
on:
push:
tags:
- 'v*'
jobs:
release:
name: Build and publish release
runs-on: ubuntu-latest
container:
image: golang:1.26-alpine
steps:
- name: Install tools
run: apk add --no-cache curl git jq
- 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: Build Linux (amd64)
env:
GOOS: linux
GOARCH: amd64
CGO_ENABLED: "0"
run: |
go build -trimpath -ldflags="-s -w" \
-o glint-${{ github.ref_name }}-linux-amd64 \
./cmd/glint/...
- name: Build Windows (amd64)
env:
GOOS: windows
GOARCH: amd64
CGO_ENABLED: "0"
run: |
go build -trimpath -ldflags="-s -w" \
-o glint-${{ github.ref_name }}.exe \
./cmd/glint/...
- name: Create release and upload assets
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.ref_name }}
API_URL: ${{ github.api_url }}
REPO: ${{ github.repository }}
run: |
release_id=$(curl -sf -X POST \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
"$API_URL/repos/$REPO/releases" \
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"draft\":false,\"prerelease\":false}" \
| jq -r .id)
for file in glint-${{ github.ref_name }}-linux-amd64 glint-${{ github.ref_name }}.exe; do
curl -sf -X POST \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/octet-stream" \
"$API_URL/repos/$REPO/releases/$release_id/assets?name=$file" \
--data-binary "@$file"
echo "uploaded: $file"
done
+3 -3
View File
@@ -1,11 +1,11 @@
# Compiled binaries # Compiled binaries
gitlab-sim /glint
gitlab-sim.exe /glint.exe
dist/ dist/
bin/ bin/
# Graph output directory # Graph output directory
gitlab-sim-out/ glint-out/
# Go test & coverage artifacts # Go test & coverage artifacts
*.test *.test
+209 -2
View File
@@ -5,6 +5,213 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
This project uses [Semantic Versioning](https://semver.org). This project uses [Semantic Versioning](https://semver.org).
## [0.2.21] - 2026-06-21
### Added
- **`rules:changes:` evaluation** — `glint check` and `glint graph` now evaluate `rules:changes:` conditions when file-change data is provided. Two new flags on both subcommands:
- `--changes <PATH>` — mark one or more file paths as changed (repeatable).
- `--changes-from <REF>` — run `git diff --name-only <REF>` to determine changed files automatically (e.g. `--changes-from origin/main`).
Both flags can be combined. Glob patterns in `rules:changes:` support `*` (within a path segment) and `**` (across segments). When neither flag is given the condition is treated as always matching (permissive), preserving the existing behaviour. The extended map form `{ paths: [...], compare_to: ... }` is also supported.
- **`workflow:rules:changes:` evaluation** — `workflow:rules:` entries now also evaluate `changes:` patterns when changed-file data is available, consistent with job rules.
- **Context summary includes changed files** — the `Context:` line printed by `glint check --format text` now shows the count of changed files when `--changes`/`--changes-from` is given (e.g. `branch=main, source=push, 3 changed file(s)`).
- **Unit test suite** — 100% statement coverage across all packages (`cmd/glint`, `internal/cicontext`, `internal/fetcher`, `internal/graph`, `internal/linter`, `internal/model`, `internal/resolver`).
### Changed
- **Internal:** replaced all `os.Exit` calls in `cmd/glint` with an `exit` variable to enable unit testing without process termination; no behaviour change.
- **Internal:** removed unreachable code paths found during coverage analysis — dead guard in `cicontext.parseRegexLiteral`, unreachable `len(jobs) == 0` branch in `graph.Pipeline`, and the `skipWin` struct field / dead `continue` in `graph.convertToPNG`; `pipelineSVG` return type simplified from `(string, error)` to `string` as it never returned a non-nil error. No behaviour change.
## [0.2.20] - 2026-06-14
### Added
- **`glint explain <RULE>`** — new subcommand that prints the description, rationale, a bad-YAML example, and the corrected fix for any rule ID (GL001GL043). `glint explain` with no argument lists all rules with ID, severity, and title. Rule IDs are case-insensitive (`gl007` and `GL007` are equivalent).
- **`rules:if:` evaluated reachability (GL042)** — warns when every `rules:if:` condition in a job evaluates to false using the values of variables declared in the pipeline YAML, meaning the job can never be included in a pipeline run. The check is conservative: it only fires when all variables referenced in the `if:` expressions are explicitly declared in the YAML; predefined `CI_*` / `GITLAB_*` variables and undeclared variables are treated as unknown (may have any runtime value) to avoid false positives.
- **`inherit:` completeness (GL043)** — warns when a job declares `inherit: default:` (boolean or list form) but the pipeline has no `default:` block, making the declaration a no-op. Also warns when the list form names fields (e.g. `[image, before_script]`) that are not actually set in the `default:` block — those list entries have no effect.
- **`FEATURES.md`** — full feature reference extracted from the README: all 43 lint rules in categorised tables, include resolution capabilities, context simulation details, output format schemas, configuration reference, graph visualisation modes, and developer tools. The README `## Features` section replaced with a compact 6-line `## What it does` summary with a link to `FEATURES.md`.
- **`USAGE.md`** — complete command-by-command usage reference extracted from the README: `glint check` with all output formats and examples, context simulation flags and predefined variable table, remote includes and token resolution, cache and offline mode, component reference format, `glint graph` modes, `glint explain` usage, project config (`.glint.yml`), and inline suppression. The README `## Usage` section replaced with the commands block and a single link to `USAGE.md`.
## [0.2.19] - 2026-06-14
### Added
- **`.glint.yml` project config file** — glint now searches for a `.glint.yml` file starting from the pipeline file's directory and walking up to the first `.git` boundary. Supported keys:
- `ignore: [GL007, GL032]` — suppress rules globally for the project.
- `severity: {GL004: warning}` — override rule severity (`error`, `warning`, or `ignore`). `ignore` is equivalent to listing the rule in `ignore:`.
- `stages: [quality]` — declare extra stage names that are valid beyond those in the pipeline's own `stages:` block; jobs in these stages are not flagged by GL004.
- `token: glpat-xxx` — default GitLab personal access token (lower priority than the `--token` flag and `GITLAB_TOKEN` env var).
- `url: https://gitlab.example.com` — default GitLab instance URL.
- `cache_dir: ~/.cache/glint` — default cache directory for fetched remote includes.
- **Inline suppression comments** — a `# glint: ignore RULE` comment placed immediately before a job definition suppresses that rule for the specific job. Multiple rules can be comma- or space-separated (`# glint: ignore GL007, GL032`). Use `# glint: ignore all` to suppress every finding for the job. Suppressions are scoped to the annotated job; pipeline-level findings are unaffected.
## [0.2.18] - 2026-06-14
### Added
- **`--format json`** — `glint check` can now emit a structured JSON report instead of plain text. The schema (version `1`) includes `glint_version`, `pipeline`, a `findings` array (each finding has `rule`, `severity`, `file`, `line`, `job`, `message`), and a `summary` block (`total`, `errors`, `warnings`). An empty findings array is `[]`, not `null`. In this mode the human-readable summary line is written to stderr so stdout contains only the JSON payload.
- **`--format sarif`** — emits a SARIF 2.1.0 JSON document (schema `https://json.schemastore.org/sarif-2.1.0.json`). The `runs[0].tool.driver` lists every unique rule ID found in findings; each `result` carries `ruleId`, `level` (`error`/`warning`), `message.text` (including the job name prefix), and `locations[0].physicalLocation` with `artifactLocation.uri` and `region.startLine` (when available). Pipeline-level findings without a file have no `locations` entry. This format is consumed natively by GitHub Code Scanning and GitLab SAST.
- **`--format junit`** — emits a JUnit XML document compatible with CI test-report artifact parsers (GitLab: `artifacts:reports:junit`; GitHub: upload-artifact + test-reporter). Each finding becomes a `<testcase>` with a `<failure>` child whose `message` and `type` attributes carry the finding details. A clean pipeline produces a single passing `<testcase>` with no `<failure>` element.
- **`--format github`** — emits GitHub Actions workflow-command annotation lines (`::error file=…,line=…,title=RULE::message` / `::warning …`). GitHub CI renders these as inline comments on the relevant file in pull requests. Pipeline-level findings without a file omit the `file=` parameter.
- **Format validation** — an unknown `--format` value exits with code 2 and a helpful error message listing the valid formats.
## [0.2.17] - 2026-06-14
### Added
- **Recursive include depth limit** — `resolveIncludes` now accepts a `depth` counter and returns a warning when the nesting depth exceeds 100 (matching GitLab's documented limit). This guards against pathologically deep include chains.
- **Cycle detection for project and component includes** — `project:` and `component:` includes were not previously tracked in the `visited` map (only `local:` and `remote:` were). They are now registered before recursing into their sub-includes, preventing cross-file include cycles from causing infinite loops.
- **`include: inputs:` substitution** — when a `component:` include entry has a `with:` block, all `$[[ inputs.KEY ]]` and `$[[ inputs.KEY | default('…') ]]` placeholders in the fetched template YAML are substituted with the corresponding values before parsing. Supported default value forms: `'single quoted'`, `"double quoted"`, bare booleans (`true`/`false`), and bare integers. Missing keys without a default become empty strings. This replaces the previous behaviour of leaving `$[[…]]` tokens in the YAML, which caused false-positive lint findings.
- **Include cache (`--cache-dir`)** — pass `--cache-dir DIR` to `glint check` or `glint graph` to persist fetched remote templates (both `project:` and `component:` includes and `remote:` URLs) to a local directory. Cache entries are keyed by the SHA-256 of the full request coordinates (base URL + project + path + ref). The directory is created automatically on first use.
- **Offline mode (`--offline`)** — pass `--offline` to skip all network calls. Remote includes not present in the cache are surfaced as warnings (same UX as "no token"). When `--offline` is set without an explicit `--cache-dir`, the default platform cache directory (`$XDG_CACHE_HOME/glint` or `~/.cache/glint`) is used automatically.
## [0.2.16] - 2026-06-14
### Added
- **`services:` validation (GL034)** — the map form of a service entry requires a `name` key; emits an `ERROR` when absent. The optional `alias` field must be a valid DNS label (letters, digits, hyphens, and dots; must start and end with an alphanumeric character); invalid aliases emit an `ERROR`.
- **`rules:changes` / `rules:exists` absolute path detection (GL035)** — emits a `WARNING` when a path in `rules:changes:` or `rules:exists:` starts with `/`. GitLab CI evaluates these paths relative to the repository root, so absolute paths can never match. Applies to both the list form and the `{paths: …}` map form.
- **`timeout:` format validation (GL036)** — emits an `ERROR` when a job's `timeout:` (or the pipeline-level `default.timeout:`) is not a valid GitLab CI duration string. Valid formats: `30m`, `1h 30m`, `90 minutes`, `2 hours 30 minutes`, `1 day`, etc.
- **`id_tokens:` `aud` validation (GL037)** — emits an `ERROR` for each OIDC token entry in `id_tokens:` that is missing the required `aud` key. GitLab returns an API error at pipeline start when `aud` is absent.
- **`secrets:` provider validation (GL038)** — emits an `ERROR` for each secret entry in `secrets:` that does not declare a provider key (`vault`, `gcp_secret_manager`, or `azure_key_vault`).
- **`pages:` keyword + `artifacts.paths` consistency (GL039)** — emits a `WARNING` when a job uses the `pages:` keyword but `artifacts.paths` does not include the publish directory (default `public`, or the value of `pages.publish`). GitLab Pages will not deploy unless the publish directory is listed as an artifact.
- **Duplicate stage names (GL040)** — emits a `WARNING` when a stage name appears more than once in the top-level `stages:` list. GitLab silently merges duplicate stage entries, which can produce confusing pipeline ordering.
- **`cache.key.files` glob detection (GL041)** — emits a `WARNING` when an entry in `cache.key.files` contains glob metacharacters (`*`, `?`, `[`). The `files` field requires exact file paths; GitLab does not expand globs there.
## [0.2.15] - 2026-06-13
### Added
- **Static reachability check (GL033)** — warns when every rule in a job's `rules:` block has an explicit `when: never`, making the job permanently excluded from any pipeline run. This is a purely static claim: no matter which `if:` condition evaluates to true, the outcome is always "skip"; and if no rule matches, the implicit fallback is also skip. No expression evaluation or context is required. The finding is a `WARNING` (may be intentional as a "disabled job" pattern). Only jobs where every rule has the literal `when: never` value are flagged; rules with no `when:` (default `on_success`), `when: manual`, `when: always`, or `when: on_failure` are not.
## [0.2.14] - 2026-06-13
### Added
- **`--version` / `-v` flag** — `glint --version`, `glint -v`, and `glint version` all print the compiled version string (e.g. `glint v0.2.14`). The version is also shown at the top of every `--help` output (global, `check --help`, `graph --help`). The version is injected at build time via `-ldflags "-X main.version=..."` using `git describe --tags --always --dirty`.
- **Sorted findings output** — `Lint` now returns findings sorted by `(File, Line, Rule)`. All issues from the same source file appear together in ascending line order; pipeline-level findings with no file location sort first. Previously findings were emitted in map-iteration order (non-deterministic).
### Fixed
- **Warning format consistency** — include-resolution warnings, extends-chain warnings, and the workflow non-start warning now use the same ruff-style `path: [warning] message` format as lint findings instead of the old `[WARNING] …` prefix with no file context.
- **Workflow rule permissive evaluation** — workflow `rules:if:` expressions are now evaluated in strict mode: an expression that cannot be fully parsed returns `false` (skip this rule, try the next) instead of `true` (match everything). Previously, a complex or partially-unsupported condition on the first workflow rule would match every context, blocking all subsequent rules and injecting the wrong variables. Job rules retain permissive evaluation (`true` on parse failure) to avoid silently dropping jobs.
- **Single `=` operator in `rules:if:`** — a bare `=` not followed by `=` or `~` is now accepted as an alias for `==`. This is a common mistake in GitLab CI YAML; previously it caused a parse failure and triggered the permissive fallback.
- **Source location lost through `extends:` resolution** — when a job was resolved via `extends:`, the merged definition was re-encoded and re-decoded as a fresh `model.Job` struct, which does not carry `File` or `Line` (they are not YAML keys). Those fields are now explicitly copied back from the original job before replacing it in `p.Jobs`, so extended jobs report the correct source file and line number in findings.
## [0.2.13] - 2026-06-12
### 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 (GL001GL031) and are stable across versions. The `linter.Finding` struct exposes the ID as a `Rule string` field for programmatic consumers. The README lint rules table is updated with ID columns.
- **`workflow:rules:variables:` now propagate to job rule evaluation** — when a `workflow:rules:` entry matches, any `variables:` it defines are injected into the evaluation context so job `rules:if:` expressions can reference them. Pipeline-level `variables:` are also available as defaults (lower priority). Variable priority order, highest first: `--var` CLI overrides → `--branch`/`--tag`/`--source` shortcuts → workflow-rule variables → pipeline-level variable defaults. This means `$DEPLOY_TARGET == "production"` in a job rule correctly evaluates when a workflow rule sets `DEPLOY_TARGET: production` for the matching branch. The `glint graph tree` command benefits from the same enrichment.
- **`rules:if:` expression evaluator improvements** — six correctness fixes to the GitLab CI expression parser:
- **Multi-line expressions** — newlines (`\n`, `\r`) are now treated as whitespace between tokens, so block-scalar `if:` values (e.g. `if: | ...`) and folded YAML scalars with `||`/`&&` on a continuation line are parsed correctly instead of falling back to permissive `true`.
- **`${VAR}` curly-brace variable syntax** — `${CI_COMMIT_BRANCH}` is now equivalent to `$CI_COMMIT_BRANCH` everywhere a value is expected.
- **Regex flags** — `/pattern/i`, `/pattern/m`, `/pattern/s` are now honoured; the `i` flag (case-insensitive) is translated to Go's `(?i)` prefix before compiling. Unknown flags are silently ignored.
- **Variable as regex RHS** — `$BRANCH =~ $PATTERN` where `$PATTERN` holds a `/regex/[flags]` string is now evaluated by extracting and compiling the pattern from the variable's value; if the value is empty or does not look like a regex literal the expression falls back to permissive `true`.
- **`true` / `false` keywords** — bare `true` and `false` (without quotes) are now recognised as the string values `"true"` and `"false"`, matching GitLab CI's own behaviour. `$GATEWAY_ENABLED == true` and `$FEATURE_FLAG == false` now evaluate correctly.
- **Integer literals** — bare integers (e.g. `$PARALLEL == 4`, `$ENABLED == 1`, `$DISABLED == 0`) are now parsed as their decimal string representations and compared accordingly.
- **File and line numbers on findings** — every finding now includes the source file and line where the job is defined, e.g. `[ERROR] job "deploy" (src/deploy.yml:14): …`. For jobs that come from local or fetched includes the file reflects the include source. Pipeline-level findings (workflow rules, missing stages) reference the root pipeline file.
- **`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
### Added
- **Subcommand CLI** — reworked interface inspired by [ruff](https://docs.astral.sh/ruff/):
- `glint check <file>` — lint a pipeline (replaces bare `glint <file>`)
- `glint graph [mode] <file>` — visualise the pipeline (replaces `--graph` flag)
- Graph modes: no-arg (tree + includes), `tree`, `includes`, `pipeline`, `all`
- Per-command `--help` with ruff-style layout: `Arguments:`, `Options:` (flag declaration on its own line, description below), `[env: ...]` / `[default: ...]` / `[possible values: ...]` metadata, `Examples:` section
- **`glint graph tree`** — jobs displayed as a terminal directory tree grouped by stage (like the `tree` command); job-type annotations (`[manual]`, `[delayed]`, `[trigger]`) when no context is set; evaluated-state annotations (`[skipped]`, `[manual]`) when a context is provided via `--branch` / `--tag` / `--source`
- **Context flags on `glint graph`** — `--branch`, `--tag`, `--source`, `--var` are now available on `glint graph` as well as `glint check`
- **Local include resolution** — `include: local:` entries are now read from disk, recursively resolved, and merged into the pipeline before linting; enables cross-file `extends:` and `needs:` validation for multi-file pipelines
- **Cross-platform release builds** — two Taskfile tasks for tagged release binaries:
- `task build-windows` — cross-compiles for Windows x64; output: `glint-<tag>.exe`
- `task build-linux` — cross-compiles for Linux x64; output: `glint-<tag>-linux-amd64`
- Both tasks require an exact git tag on the current commit
### Fixed
- **`extends:` unknown base no longer fatal** — when a base job referenced by `extends:` does not exist, glint now emits a resolver warning and skips extends resolution for that job rather than aborting with exit code 2; linting continues on the job's own fields
- **`script: |` (block scalar) support** — jobs using a multiline block scalar for `script:`, `before_script:`, or `after_script:` are now parsed correctly; previously caused false-positive "missing script" errors
### Changed
- **`glint <file>` removed** — use `glint check <file>`
- **`--graph <mode>` removed** — replaced by `glint graph [mode]`
- **`--graph-out` renamed to `--out`** — now a flag on `glint graph` (`glint graph pipeline --out <dir>`)
## [0.1.0] - 2026-06-07 ## [0.1.0] - 2026-06-07
### Added ### Added
@@ -24,7 +231,7 @@ This project uses [Semantic Versioning](https://semver.org).
- **Graph output (`--graph`)** — visualises the pipeline instead of running lint rules: - **Graph output (`--graph`)** — visualises the pipeline instead of running lint rules:
- `--graph includes` — [Mermaid](https://mermaid.js.org) flowchart of include dependencies written to stdout; one node per `include:` entry (project, component, local, remote, template), colour-coded by type; pipe to a `.mmd` file or paste into [mermaid.live](https://mermaid.live) - `--graph includes` — [Mermaid](https://mermaid.js.org) flowchart of include dependencies written to stdout; one node per `include:` entry (project, component, local, remote, template), colour-coded by type; pipe to a `.mmd` file or paste into [mermaid.live](https://mermaid.live)
- `--graph pipeline` — GitLab CI-style SVG/PNG pipeline graph written to a timestamped file in `--graph-out` (default: `gitlab-sim-out/`); jobs rendered as white chip cards with a coloured status indicator (blue: regular, orange: manual, purple: trigger, amber: delayed); DAG mode draws job-to-job Bézier arrows when any job has `needs:`, classic mode draws L-shaped connectors between stage columns; converted to PNG automatically when `rsvg-convert`, `inkscape`, or `magick` is available - `--graph pipeline` — GitLab CI-style SVG/PNG pipeline graph written to a timestamped file in `--graph-out` (default: `glint-out/`); jobs rendered as white chip cards with a coloured status indicator (blue: regular, orange: manual, purple: trigger, amber: delayed); DAG mode draws job-to-job Bézier arrows when any job has `needs:`, classic mode draws L-shaped connectors between stage columns; converted to PNG automatically when `rsvg-convert`, `inkscape`, or `magick` is available
- `--graph all` — include Mermaid to stdout, pipeline file path to stderr - `--graph all` — include Mermaid to stdout, pipeline file path to stderr
- New `internal/graph` package (`includes.go`, `pipeline.go`, `render.go`); no new external dependencies - New `internal/graph` package (`includes.go`, `pipeline.go`, `render.go`); no new external dependencies
@@ -74,7 +281,7 @@ This project uses [Semantic Versioning](https://semver.org).
- `only`/`rules` or `except`/`rules` used together (error) - `only`/`rules` or `except`/`rules` used together (error)
- No `stages` block defined (warning) - No `stages` block defined (warning)
- Deprecated `only`/`except` usage (warning) - Deprecated `only`/`except` usage (warning)
- **CLI** — `gitlab-sim <file>` exits 0 on clean pipelines, 1 on errors; prints findings with severity, job name, and message - **CLI** — `glint <file>` exits 0 on clean pipelines, 1 on errors; prints findings with severity, job name, and message
- **YAML parser** — two-pass parse: reserved top-level keys (`stages`, `variables`, `default`, `include`, `workflow`) are decoded into typed structs; remaining keys are treated as job definitions - **YAML parser** — two-pass parse: reserved top-level keys (`stages`, `variables`, `default`, `include`, `workflow`) are decoded into typed structs; remaining keys are treated as job definitions
- **Taskfile** — `build`, `test`, `lint-go`, `validate`, `ci`, `clean` tasks via [Task](https://taskfile.dev) - **Taskfile** — `build`, `test`, `lint-go`, `validate`, `ci`, `clean` tasks via [Task](https://taskfile.dev)
- **Testdata fixtures** — `valid.yml`, `invalid.yml`, `extends.yml`, `needs.yml`, `needs_cycle.yml` - **Testdata fixtures** — `valid.yml`, `invalid.yml`, `extends.yml`, `needs.yml`, `needs_cycle.yml`
+9 -3
View File
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview ## Project Overview
`gitlab-sim` is a CLI tool that validates and lints `.gitlab-ci.yml` pipelines locally, without a GitLab server. It resolves `extends:` inheritance, fetches remote `include: project:` templates and `include: component:` catalog entries, then runs a set of lint rules over the fully-merged pipeline. `glint` is a CLI tool that validates and lints `.gitlab-ci.yml` pipelines locally, without a GitLab server. It resolves `extends:` inheritance, fetches remote `include: project:` templates and `include: component:` catalog entries, then runs a set of lint rules over the fully-merged pipeline.
**Goals:** catch misconfigured pipelines early in a developer's workflow, before pushing to GitLab. Eventual goal: produce a visual graph of the pipeline DAG. **Goals:** catch misconfigured pipelines early in a developer's workflow, before pushing to GitLab. Eventual goal: produce a visual graph of the pipeline DAG.
@@ -34,7 +34,7 @@ This order is intentional and must be preserved: includes must be merged before
| `internal/resolver` | `extends:` resolution and `include:` merging | | `internal/resolver` | `extends:` resolution and `include:` merging |
| `internal/fetcher` | GitLab REST API client (token auth, file fetch) | | `internal/fetcher` | GitLab REST API client (token auth, file fetch) |
| `internal/graph` | Mermaid graph generators (include dependencies, pipeline jobs) | | `internal/graph` | Mermaid graph generators (include dependencies, pipeline jobs) |
| `cmd/gitlab-sim` | CLI entrypoint, flag parsing, output formatting | | `cmd/glint` | CLI entrypoint, flag parsing, output formatting |
### Two-pass YAML parser (`internal/model/parser.go`) ### Two-pass YAML parser (`internal/model/parser.go`)
@@ -138,7 +138,7 @@ All commit messages must follow this format:
- `resolver`: extends/include resolution (`internal/resolver/`) - `resolver`: extends/include resolution (`internal/resolver/`)
- `fetcher`: GitLab API client (`internal/fetcher/`) - `fetcher`: GitLab API client (`internal/fetcher/`)
- `graph`: Mermaid graph generators (`internal/graph/`) - `graph`: Mermaid graph generators (`internal/graph/`)
- `cli`: CLI entrypoint (`cmd/gitlab-sim/`) - `cli`: CLI entrypoint (`cmd/glint/`)
- `testdata`: fixture files only - `testdata`: fixture files only
- `docs`: README, CHANGELOG, or other documentation - `docs`: README, CHANGELOG, or other documentation
- `build`: Taskfile, go.mod, go.sum, CI config - `build`: Taskfile, go.mod, go.sum, CI config
@@ -185,3 +185,9 @@ Every fixture in `testdata/` is run by `task validate`. Files whose expected beh
Token resolution order (first non-empty wins): `GITLAB_TOKEN``CI_JOB_TOKEN``GITLAB_PRIVATE_TOKEN`. Token resolution order (first non-empty wins): `GITLAB_TOKEN``CI_JOB_TOKEN``GITLAB_PRIVATE_TOKEN`.
URL resolution order: `--gitlab-url` flag → `CI_SERVER_URL``GITLAB_URL``https://gitlab.com`. URL resolution order: `--gitlab-url` flag → `CI_SERVER_URL``GITLAB_URL``https://gitlab.com`.
---
## README and CHANGELOG
On each modification, complete README.md and CHANGELOG.md with the changes made.
+267
View File
@@ -0,0 +1,267 @@
# glint — feature reference
This document describes every capability in the current release.
For planned work see [ROADMAP.md](ROADMAP.md).
---
## Lint rules
Every finding carries a stable rule ID (`GL001` `GL043`) that can be used to
suppress, filter, or look up the check. Run `glint explain <ID>` for a
description, bad-YAML example, and fix.
### Pipeline-level
| ID | Sev | Rule |
|----|-----|------|
| GL001 | WARN | No `stages:` block — GitLab falls back to its built-in default stages |
| GL002 | ERR | `workflow.rules[*].when` must be `always` or `never` |
| GL036 | ERR | `default.timeout` is not a valid GitLab CI duration string |
| GL040 | WARN | A stage name appears more than once in `stages:` |
### Job structure
| ID | Sev | Rule |
|----|-----|------|
| GL003 | ERR | Job missing required `script:` (or `run:`) |
| GL004 | ERR | Job `stage:` references a stage not declared in `stages:` |
| GL005 | ERR | `only:` and `rules:` used together |
| GL006 | ERR | `except:` and `rules:` used together |
| GL007 | WARN | `only:` / `except:` used (deprecated; prefer `rules:`) |
### Keyword constraints
| ID | Sev | Rule |
|----|-----|------|
| GL008 | ERR | `when:` has an invalid value |
| GL009 | ERR | `when: delayed` without `start_in:` |
| GL010 | ERR | `start_in:` set but `when:` is not `delayed` |
| GL011 | ERR | `parallel:` integer not in range 2200, or map form missing `matrix:` |
| GL012 | ERR | `retry:` integer not in range 02, or `retry.max` out of range |
| GL013 | ERR | `retry.when:` contains an unrecognised failure type |
| GL014 | ERR | `allow_failure:` is not a boolean or a map with `exit_codes:` |
| GL015 | ERR | `interruptible:` is not a boolean |
| GL016 | ERR | Trigger job also defines `script:` |
| GL017 | ERR | `trigger:` map missing `project:` or `include:` |
| GL018 | ERR | `coverage:` is not a regex wrapped in `/…/` |
| GL019 | ERR | `release:` missing required `tag_name:`, or is not a map |
| GL020 | ERR | `environment.url` set without `environment.name`, or invalid `action:` |
| GL021 | ERR | `artifacts.when` invalid, or `expose_as` set without `paths` |
| GL022 | WARN | `pages` job `artifacts.paths` does not include `public/` |
| GL023 | ERR | `cache.when` or `cache.policy` has an invalid value |
| GL024 | ERR | `rules[*].when` has an invalid value |
| GL025 | ERR | `image:` map form missing `name:` |
| GL026 | ERR | `inherit.default` / `inherit.variables` is not a boolean or list |
| GL034 | ERR | `services:` map form missing `name:`, or `alias:` is not a valid DNS label |
| GL036 | ERR | `timeout:` is not a valid GitLab CI duration string |
| GL037 | ERR | `id_tokens:` entry missing required `aud:` |
| GL038 | ERR | `secrets:` entry missing a provider key (`vault`, `gcp_secret_manager`, `azure_key_vault`) |
| GL039 | WARN | Job uses `pages:` keyword but `artifacts.paths` doesn't include the publish directory |
| GL041 | WARN | `cache.key.files` entry looks like a glob; must be an exact file path |
### Cross-job graph
| ID | Sev | Rule |
|----|-----|------|
| GL027 | ERR/WARN | `needs:` references a job that doesn't exist (WARN when `optional: true`) |
| GL028 | ERR | `needs:` references a job in a later stage |
| GL029 | ERR | Circular dependency in `needs:` graph |
| GL030 | ERR | `dependencies:` references a job that doesn't exist |
| GL031 | ERR | `dependencies:` references a job in the same or a later stage |
### Expression & reachability
| ID | Sev | Rule |
|----|-----|------|
| GL032 | WARN | `rules:if:` references `$VAR` not declared in any `variables:` block (may be false-positive for project-setting variables) |
| GL033 | WARN | Every rule in `rules:` has `when: never` — job permanently excluded (provable without evaluating `if:` expressions) |
| GL035 | WARN | `rules:changes` / `rules:exists` path is absolute — absolute paths never match in GitLab CI |
| GL042 | WARN | Every `rules:if:` condition evaluates to false given the declared variable values — job statically unreachable (only fires when all referenced variables are declared in YAML) |
### Inheritance
| ID | Sev | Rule |
|----|-----|------|
| GL043 | WARN | `inherit: default:` declared but the pipeline has no `default:` block (dead declaration), or the list form names fields not set in `default:` |
### Hidden jobs
Jobs whose name starts with `.` are reusable templates; most rules are skipped for them. This matches GitLab CI's own behaviour.
---
## Include resolution
| Capability | Notes |
|------------|-------|
| `include: local:` | Read from disk, recursively merged before linting |
| `include: remote:` | HTTPS URL fetched unauthenticated; unreachable URLs produce a warning, linting continues |
| `include: project:` | Fetched from the GitLab REST API using the configured token; skipped with a warning when no token is available |
| `include: component:` | Fetched from the GitLab CI/CD Catalog; public components work without a token |
| `include: inputs:` | `$[[ inputs.KEY ]]` and `$[[ inputs.KEY \| default(…) ]]` placeholders substituted from the `with:` block before parsing |
| Depth limit | Include chains capped at 100 levels (matches GitLab); circular cross-file references detected via visited-set tracking |
| Offline mode | `--offline` serves all remote includes from the cache; missing entries produce a warning |
| Include cache | `--cache-dir DIR` (or `~/.cache/glint` by default with `--offline`) persists fetched templates; keyed by SHA-256 of the request coordinates |
**Token resolution order** (first non-empty wins):
| Source | Header |
|--------|--------|
| `--token` flag / `GITLAB_TOKEN` env | `PRIVATE-TOKEN` |
| `CI_JOB_TOKEN` env | `JOB-TOKEN` |
| `GITLAB_PRIVATE_TOKEN` env | `PRIVATE-TOKEN` |
**URL resolution order:** `--gitlab-url` flag → `CI_SERVER_URL` env → `GITLAB_URL` env → `https://gitlab.com`
---
## Context simulation
Pass `--branch`, `--tag`, `--source`, or `--var` to evaluate `rules:if:` and
`only`/`except` filters against a specific pipeline event. When no context flag
is given glint defaults to `--branch main --source push` so that `rules:if:`
expressions are always evaluated.
**Evaluated at lint time:**
- `rules:if:` — full expression language: `==`, `!=`, `=~`, `!~`, `&&`, `||`, `!`, `(…)`, `$VAR`/`${VAR}`, string literals, `null`, regex flags (`/pat/i`)
- `only:` / `except:` — ref keywords, branch-name globs, and `/regex/` patterns
- `workflow:rules:` — evaluated to determine whether the pipeline would run; matching rule's `variables:` are injected before job evaluation
- `rules:changes:` — path-glob patterns evaluated against the supplied changed-file list (see `--changes` / `--changes-from` flags); `*` matches within a segment, `**` crosses `/` boundaries; the extended `{paths: [...], compare_to: ...}` form is supported; without changed-file data the condition is always treated as matching (permissive)
- Variable expansion — `$VAR` / `${VAR}` references in variable values expanded after all sources merge; transitive chains resolved (up to 10 passes)
**Not evaluated** (no git tree at lint time): `rules:exists:`.
**Predefined variables** set by shortcut flags:
| Flag | Variables populated |
|------|---------------------|
| `--branch NAME` | `CI_COMMIT_BRANCH`, `CI_COMMIT_REF_NAME`, `CI_COMMIT_REF_SLUG`, `CI_PIPELINE_SOURCE=push` |
| `--tag NAME` | `CI_COMMIT_TAG`, `CI_COMMIT_REF_NAME`, `CI_COMMIT_REF_SLUG`, `CI_PIPELINE_SOURCE=push`; clears `CI_COMMIT_BRANCH` |
| `--source EVENT` | `CI_PIPELINE_SOURCE` |
| `--var KEY=VALUE` | any variable; overrides shortcuts; repeatable |
Use `--list-vars` to print the resolved variable table to stderr.
**Changed-file flags** (for `rules:changes:` evaluation):
| Flag | Effect |
|------|--------|
| `--changes PATH` | Mark PATH as changed; repeatable |
| `--changes-from REF` | Run `git diff --name-only REF` to auto-detect changed files |
---
## Output formats
Pass `--format` to `glint check`. In structured formats the summary line is
written to stderr so stdout contains only the machine-readable payload.
| Format | Flag | Description |
|--------|------|-------------|
| Text (default) | `--format text` | Ruff-style `file:line: RULE [sev] message` |
| JSON | `--format json` | Stable schema (version 1); `findings` array + `summary` block |
| SARIF 2.1.0 | `--format sarif` | Consumed by GitHub Code Scanning and GitLab SAST |
| JUnit XML | `--format junit` | CI test-report artifact (`artifacts:reports:junit`) |
| GitHub annotations | `--format github` | `::error file=…,line=…,title=RULE::message` inline PR comments |
**JSON schema (`schema_version: 1`):**
```json
{
"schema_version": 1,
"glint_version": "v0.2.20",
"pipeline": ".gitlab-ci.yml",
"findings": [
{"rule":"GL004","severity":"error","file":".gitlab-ci.yml","line":14,
"job":"deploy","message":"stage \"production\" is not defined in 'stages'"}
],
"summary": {"total": 1, "errors": 1, "warnings": 0}
}
```
---
## Configuration
### `.glint.yml` project config file
Searched from the pipeline file's directory upward to the first `.git` boundary.
```yaml
# Suppress rules globally for this project.
ignore:
- GL007 # migrating from only:/except:
- GL032 # dynamic variables injected by CI
# Override rule severity (error | warning | ignore).
# 'ignore' is equivalent to listing the rule in ignore:.
severity:
GL004: warning # demote during a stage migration
GL035: error # promote to hard error for this project
# Extra stage names valid beyond those in the pipeline's own stages: block.
stages:
- quality
- security
# Default GitLab token (lower priority than --token and GITLAB_TOKEN).
token: glpat-xxxx
# Default GitLab instance URL.
url: https://gitlab.example.com
# Default cache directory.
cache_dir: ~/.cache/glint
```
**Priority chain:** `--token`/`--gitlab-url` flags > `.glint.yml` > environment variables.
### Inline suppression (`# glint: ignore`)
Suppress a finding for a specific job by placing a comment immediately before it:
```yaml
# glint: ignore GL007
legacy-job:
only: [main]
script: echo ok
# Multiple rules (comma- or space-separated):
# glint: ignore GL007, GL032
other-job:
script: echo ok
# Suppress every rule for this job:
# glint: ignore all
noisy-job:
script: echo ok
```
Suppressions are scoped to the single job they precede and do not affect other
jobs or pipeline-level findings.
---
## Graph visualization (`glint graph`)
| Mode | Output |
|------|--------|
| `tree` (default) | Terminal job tree: stages as branches, jobs as leaves; annotated with `[manual]`, `[delayed]`, `[trigger]` where applicable |
| `includes` | Mermaid flowchart to stdout; colour-coded nodes by include type (local, remote, project, component, template) |
| `pipeline` | GitLab CI-style SVG/PNG written to `--out` directory (default: `glint-out/`); converted to PNG when `rsvg-convert`, `inkscape`, or `magick` is available |
| `all` | `includes` to stdout + `pipeline` file path to stderr |
In DAG pipelines (any job has `needs:`) the pipeline graph uses job-to-job
Bézier connectors instead of stage-to-stage lines.
---
## Developer tools
| Tool | Description |
|------|-------------|
| `glint explain <RULE>` | Print description, rationale, bad-YAML example, and fix for a rule. Case-insensitive (`gl007` = `GL007`). |
| `glint explain` | List all rules with ID, severity, and title. |
| `--list-vars` | Print all resolved pipeline variables (pipeline + workflow rules + context) to stderr before linting. |
| `--version` / `-v` | Print the compiled version string. |
+34 -248
View File
@@ -1,25 +1,28 @@
# gitlab-sim <p align="center">
<img src="assets/glint-logo.png" alt="glint logo" width="220" />
</p>
[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE) <h1 align="center">glint</h1>
[![Release](https://img.shields.io/badge/release-v0.1.0-blue.svg)](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.21-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.
A local tool to validate and lint `.gitlab-ci.yml` pipelines without needing a GitLab server. A local tool to validate and lint `.gitlab-ci.yml` pipelines without needing a GitLab server.
## Features ## What it does
- **YAML validation** — detects malformed pipeline files early - **Lints** — 43 rules covering pipeline structure, keyword constraints, `needs:`/`dependencies:` graphs, expression reachability, and deprecations (GL001GL043); run `glint explain <ID>` for any rule
- **Stage validation** — every job's `stage` must be declared in `stages` - **Resolves includes** — local files, HTTPS URLs, GitLab project templates, and CI/CD Catalog components, with offline cache support
- **`extends:` resolution** — resolves single and multi-level template inheritance before linting, so derived jobs are evaluated against their fully merged definition - **Simulates context** — `--branch`, `--tag`, `--source` flags evaluate `rules:if:` and `only`/`except` to show which jobs would be active, manual, or skipped
- **`needs:` DAG validation** — checks that `needs:` references exist, respect stage ordering, and contain no circular dependencies - **Multiple output formats** — `--format text` (default, ruff-style), `json`, `sarif` (GitHub Code Scanning / GitLab SAST), `junit`, `github` (PR annotations)
- **`dependencies:` validation** — checks that artifact dependency references exist and are in earlier stages - **Project config** — `.glint.yml` for rule suppression, severity overrides, token/URL defaults; `# glint: ignore RULE` for per-job inline suppression
- **Keyword validation** — validates constraints on `when`, `parallel`, `retry`, `allow_failure`, `trigger`, `artifacts`, `cache`, `release`, `environment`, `coverage`, `rules`, and more - **Graph visualization** — `glint graph` prints a terminal job tree; `glint graph pipeline` renders a GitLab CI-style SVG/PNG
- **Remote project includes** — fetches `include: project:` templates from the GitLab API so extends/needs can be validated against the full merged pipeline
- **CI/CD catalog components** — resolves `include: component:` references from the GitLab CI/CD Catalog; public components work without a token See [FEATURES.md](FEATURES.md) for the complete feature reference and lint rules table, and [ROADMAP.md](ROADMAP.md) for planned improvements.
- **Deprecation warnings** — flags `only`/`except` usage in favour of `rules`
- **Graph output** — emits Mermaid diagrams for the include dependency tree and the pipeline jobs layout (DAG or classic stage ordering)
- **Context simulation** — pass `--branch`, `--tag`, or `--source` to see which jobs would be active, manual, or skipped for a specific branch push, tag, or pipeline event; evaluates `rules:if:` expressions and `only`/`except` filters
## Requirements ## Requirements
@@ -29,9 +32,9 @@ A local tool to validate and lint `.gitlab-ci.yml` pipelines without needing a G
## Installation ## Installation
```bash ```bash
git clone https://git.k3nny.fr/gitlab-sim git clone https://git.k3nny.fr/glint
cd gitlab-sim cd glint
go build -o gitlab-sim ./cmd/gitlab-sim/... go build -o glint ./cmd/glint/...
``` ```
Or with Task: Or with Task:
@@ -42,237 +45,18 @@ task build
## Usage ## Usage
```bash ```
gitlab-sim [options] <pipeline.yml> glint [OPTIONS] <COMMAND>
Commands:
check Lint a pipeline file — exits 0 (clean) or 1 (errors found)
graph Visualise the pipeline as a job tree or Mermaid graph
explain Print description and fix for a lint rule
``` ```
Exits `0` when no errors are found, `1` when at least one error is reported. Run `glint <command> --help` for all flags. See [USAGE.md](USAGE.md) for full
examples covering output formats, context simulation, remote includes, cache,
### Remote project includes graph modes, and project configuration.
Pipelines that include templates from other GitLab projects are supported.
Provide a token so `gitlab-sim` can fetch them:
```bash
# personal access token (read_api scope)
GITLAB_TOKEN=glpat-xxxx gitlab-sim .gitlab-ci.yml
# CI/CD job token (when running inside a pipeline)
CI_JOB_TOKEN=$CI_JOB_TOKEN gitlab-sim .gitlab-ci.yml
# self-hosted GitLab
GITLAB_TOKEN=glpat-xxxx GITLAB_URL=https://gitlab.example.com gitlab-sim .gitlab-ci.yml
# or via flags
gitlab-sim --token glpat-xxxx --gitlab-url https://gitlab.example.com .gitlab-ci.yml
```
**Project includes** require a token; without one they are skipped with a
warning and the rest of the pipeline is linted as-is.
**Component includes** (`include: component: ...`) attempt the fetch
unauthenticated, so public [CI/CD Catalog](https://gitlab.com/explore/catalog)
components work without a token. A warning is emitted if the fetch fails.
Token resolution order (first non-empty wins):
| Source | Header used |
|--------|-------------|
| `--token` flag / `GITLAB_TOKEN` | `PRIVATE-TOKEN` |
| `CI_JOB_TOKEN` | `JOB-TOKEN` |
| `GITLAB_PRIVATE_TOKEN` | `PRIVATE-TOKEN` |
Instance URL resolution order: `--gitlab-url` flag → `CI_SERVER_URL`
`GITLAB_URL``https://gitlab.com`
### Component reference format
```
<host>/<project-path>/<component-name>@<version>
gitlab.com/components/secret-detection/secret-detection@v0.1.0
gitlab.com/my-org/ci-catalog/lint@main
gitlab.example.com/platform/components/build@~latest
```
The component file is looked up in order:
1. `templates/<component-name>.yml` (single-file layout)
2. `templates/<component-name>/template.yml` (directory layout)
Component input parameters (`with:`) are not validated — they are resolved by
GitLab at runtime. Jobs in fetched components may use `$[[ inputs.xxx ]]`
placeholders in fields like `stage`; `gitlab-sim` skips those fields rather
than producing false positive errors.
### Graph output
Pass `--graph` to visualise the pipeline instead of running lint rules.
```bash
# Include dependency graph (which files include which) → Mermaid to stdout
gitlab-sim --graph includes .gitlab-ci.yml > includes.mmd
# GitLab-like pipeline layout → PNG (or SVG fallback) written to --graph-out dir
gitlab-sim --graph pipeline .gitlab-ci.yml
# prints the output file path, e.g.: gitlab-sim-out/pipeline-20260607-143022.png
# Both at once: Mermaid to stdout + pipeline file path to stderr
gitlab-sim --graph all .gitlab-ci.yml > includes.mmd
# Custom output directory
gitlab-sim --graph pipeline --graph-out /tmp/graphs .gitlab-ci.yml
```
**Include graph** (`--graph includes`) — [Mermaid](https://mermaid.js.org) flowchart written to stdout.
Pipe to a `.mmd` file or paste into [mermaid.live](https://mermaid.live).
One node per include entry, colour-coded by type:
- Orange (bold): the main pipeline file
- Purple: `project:` includes
- Green: `component:` includes
- Blue: `local:` includes
- Grey: `remote:` URL includes
- Light orange: GitLab-provided `template:` includes
**Pipeline graph** (`--graph pipeline`) — GitLab CI-style SVG rendered to a timestamped file
in the `--graph-out` directory (default: `gitlab-sim-out/`). Converted to PNG automatically
when `rsvg-convert`, `inkscape`, or `magick` is available; falls back to SVG otherwise.
Jobs are colour-coded by type:
- Blue (`#1f75cb`): regular jobs
- Orange (`#fc6d26`): `when: manual` jobs
- Purple (`#6b4fbb`): `trigger:` jobs
- Amber (`#fca326`): `when: delayed` jobs
DAG mode (job-to-job Bézier arrows) activates automatically when any job has a `needs:` list.
Classic mode draws L-shaped or straight connectors between stage columns otherwise.
### Context simulation
Pass `--branch`, `--tag`, or `--source` to see which jobs would run for a given
pipeline event. The pipeline is still fully linted; context output is printed first.
```bash
# What runs on a push to develop?
gitlab-sim --branch develop .gitlab-ci.yml
# What runs when a v1.2.0 tag is pushed?
gitlab-sim --tag v1.2.0 .gitlab-ci.yml
# Merge request pipeline
gitlab-sim --source merge_request_event .gitlab-ci.yml
# Arbitrary variable overrides (repeatable)
gitlab-sim --branch main --var DEPLOY_ENV=production .gitlab-ci.yml
```
**Evaluated:**
- `rules:if:` — full expression language: `==`, `!=`, `=~`, `!~`, `&&`, `||`, `!`, `()`, `$VAR`, string literals, `null`
- `only:` / `except:` — ref keywords (`branches`, `tags`, `merge_requests`, `schedules`, …), branch name globs (`feat/*`), and `/regex/` patterns
**Not evaluated** (no git tree at lint time): `rules:changes:`, `rules:exists:`.
Rules without an `if:` clause always match.
**Predefined variables** set automatically by the shortcut flags:
| Flag | Variables populated |
|------|---------------------|
| `--branch <name>` | `CI_COMMIT_BRANCH`, `CI_COMMIT_REF_NAME`, `CI_COMMIT_REF_SLUG`, `CI_PIPELINE_SOURCE=push` |
| `--tag <name>` | `CI_COMMIT_TAG`, `CI_COMMIT_REF_NAME`, `CI_COMMIT_REF_SLUG`, `CI_PIPELINE_SOURCE=push` (clears `CI_COMMIT_BRANCH`) |
| `--source <event>` | `CI_PIPELINE_SOURCE` |
| `--var KEY=VALUE` | any variable; overrides shortcuts |
### Example output
```
# Clean pipeline, no context
OK: .gitlab-ci.yml — no issues found (5 jobs, 3 stages)
# With --branch develop context
Context: branch=develop, source=push
Active (3): build, deploy-staging, test
Skipped (2): deploy-prod, release-notes
OK: .gitlab-ci.yml — no issues found (5 jobs, 3 stages)
# With --tag v1.0.0 context
Context: tag=v1.0.0, source=push
Active (4): build, deploy-prod, release-notes, test
Skipped (1): deploy-staging
OK: .gitlab-ci.yml — no issues found (5 jobs, 3 stages)
# Pipeline with issues
[ERROR] job "deploy": stage "production" is not defined in 'stages'
[ERROR] job "test": needs unknown job "build-app"
[WARNING] job "old-job": 'only'/'except' are deprecated; prefer 'rules'
3 finding(s): 2 error(s)
```
## Lint rules
### Pipeline-level
| Severity | Rule |
|----------|------|
| ERROR | `workflow.rules[*].when` is not `always` or `never` |
| WARNING | No `stages` defined (GitLab falls back to default stages) |
### Job-level — structure
| Severity | Rule |
|----------|------|
| ERROR | Job is missing required `script` (or `run`) — non-trigger, non-template jobs |
| ERROR | Job references a `stage` not declared in `stages` |
| ERROR | `only` and `rules` used together on the same job |
| ERROR | `except` and `rules` used together on the same job |
| WARNING | `only`/`except` used (deprecated, prefer `rules`) |
### Job-level — keyword constraints
| Severity | Rule |
|----------|------|
| ERROR | `when` is not one of `on_success`, `on_failure`, `always`, `manual`, `delayed`, `never` |
| ERROR | `when: delayed` without `start_in` |
| ERROR | `start_in` set but `when` is not `delayed` |
| ERROR | `parallel` integer not in range 2200 |
| ERROR | `parallel` map form missing `matrix` key |
| ERROR | `retry` integer not in range 02 |
| ERROR | `retry.max` not in range 02 |
| ERROR | `retry.when` contains an invalid failure type |
| ERROR | `allow_failure` is not a boolean or a map with `exit_codes` |
| ERROR | `interruptible` is not a boolean |
| ERROR | `trigger` job also has `script` |
| ERROR | `trigger` map missing `project` or `include` |
| ERROR | `coverage` is not a regex pattern wrapped in `/` |
| ERROR | `release` missing required `tag_name` |
| ERROR | `environment.url` set without `environment.name` |
| ERROR | `environment.action` is not one of `start`, `stop`, `prepare`, `verify`, `access` |
| ERROR | `artifacts.when` is not `on_success`, `on_failure`, or `always` |
| ERROR | `artifacts.expose_as` set without `artifacts.paths` |
| ERROR | `cache.when` is not `on_success`, `on_failure`, or `always` |
| ERROR | `cache.policy` is not `pull`, `push`, or `pull-push` |
| ERROR | `rules[*].when` is not one of the valid `when` values |
| ERROR | `image` map form missing `name` key |
| ERROR | `inherit.default` / `inherit.variables` is not a boolean or list |
| WARNING | `pages` job `artifacts.paths` does not include `public` |
### Cross-job graph
| Severity | Rule |
|----------|------|
| ERROR | `needs:` references a job that does not exist |
| ERROR | `needs:` references a job in a later stage |
| ERROR | Circular dependency detected in `needs:` graph |
| ERROR | `dependencies:` references a job that does not exist |
| ERROR | `dependencies:` references a job in the same or a later stage |
| ERROR | `extends:` references an unknown job |
| ERROR | Cycle detected in `extends:` graph |
### Hidden jobs (templates)
Jobs whose name starts with `.` are treated as reusable templates and skipped for most rules. This matches GitLab's own behaviour.
## Development ## Development
@@ -285,6 +69,8 @@ task test # run Go unit tests
task lint-go # run go vet task lint-go # run go vet
task validate # run the binary against all testdata fixtures task validate # run the binary against all testdata fixtures
task ci # full check: vet → test → build → validate task ci # full check: vet → test → build → validate
task build-windows # cross-compile for Windows x64 (requires a tagged commit → glint-<tag>.exe)
task build-linux # cross-compile for Linux x64 (requires a tagged commit → glint-<tag>-linux-amd64)
task clean # remove build artifacts task clean # remove build artifacts
``` ```
@@ -292,7 +78,7 @@ task clean # remove build artifacts
``` ```
. .
├── cmd/gitlab-sim/ # CLI entrypoint ├── cmd/glint/ # CLI entrypoint
├── internal/ ├── internal/
│ ├── cicontext/ # CI variable context, rules:if: evaluator, job reachability │ ├── cicontext/ # CI variable context, rules:if: evaluator, job reachability
│ ├── fetcher/ # GitLab API client (project include fetching) │ ├── fetcher/ # GitLab API client (project include fetching)
+68 -87
View File
@@ -1,61 +1,30 @@
# Roadmap # Roadmap
This document tracks planned improvements to `gitlab-sim`. Items are grouped by theme, roughly in priority order within each group. Nothing here is a commitment — the tool is experimental and the list will shift as real usage surfaces better priorities. This document tracks planned improvements to `glint`. Items are grouped by theme, roughly in priority order within each group. Nothing here is a commitment — the tool is experimental and the list will shift as real usage surfaces better priorities.
--- ---
## Context-aware validation ## Context simulation
Pipelines in Git Flow, Trunk-Based Development, or any branching strategy are rarely uniform: jobs activate or skip based on `$CI_COMMIT_BRANCH`, `$CI_COMMIT_TAG`, `$CI_PIPELINE_SOURCE`, and similar runtime variables. Today `gitlab-sim` validates structure but cannot tell which jobs are actually reachable for a given context. Pass `--branch`, `--tag`, `--source`, or `--var` to `glint check` or `glint graph` to evaluate `rules:if:` expressions and `only`/`except` filters against a specific pipeline event.
The plan is to make the execution context injectable so the linter can evaluate `rules:if:` / `only` / `except` conditions and report per-context reachability. - ~~**Single-context simulation**~~ — ✓ shipped v0.2.0; `--branch`, `--tag`, `--source`, `--var` flags on both subcommands; jobs classified as active / manual / skipped
- ~~**`workflow:rules:variables:` propagation**~~ — ✓ shipped post-v0.2.0; variables from the matching workflow rule entry injected into the evaluation context before job `rules:if:` expressions are evaluated
**CLI surface** - ~~**Expression evaluator: multi-line `if:` values**~~ — ✓ shipped post-v0.2.0; newlines in block-scalar and folded YAML `if:` values treated as whitespace
- ~~**Expression evaluator: `${VAR}` syntax**~~ — ✓ shipped post-v0.2.0; `${CI_COMMIT_BRANCH}` equivalent to `$CI_COMMIT_BRANCH` everywhere
```bash - ~~**Expression evaluator: regex flags**~~ — ✓ shipped post-v0.2.0; `/pattern/i`, `/pattern/m`, `/pattern/s` supported
# Push to develop - ~~**Expression evaluator: variable as regex RHS**~~ — ✓ shipped post-v0.2.0; `$BRANCH =~ $PATTERN` where `$PATTERN` holds a `/regex/` string evaluates correctly
gitlab-sim --branch develop .gitlab-ci.yml - ~~**Expression evaluator: bare `true`/`false` and integer literals**~~ — ✓ shipped post-v0.2.0; `$FLAG == true`, `$COUNT == 4` compare as decimal strings matching GitLab CI behaviour
- ~~**Implicit default context**~~ — ✓ shipped v0.2.11; defaults to `--branch main --source push` when no context flag is given, so `rules:if:` is always evaluated
# Tag push (v1.2.0) — sets CI_COMMIT_TAG and clears CI_COMMIT_BRANCH - ~~**`--list-vars` debug flag**~~ — ✓ shipped v0.2.11; prints sorted `KEY=VALUE` of all collected pipeline variables (root file + includes + workflow-rule union + effective context) to stderr
gitlab-sim --tag v1.2.0 .gitlab-ci.yml - ~~**Variable expansion**~~ — ✓ shipped v0.2.13; `$VAR`/`${VAR}` references within variable values expanded after all sources are merged; transitive chains resolved; visible in `--list-vars`
- ~~**Non-string scalar variables**~~ — ✓ shipped v0.2.13; `BUILD: true`, `RETRIES: 3` rendered and injected correctly instead of being silently dropped
# Merge request pipeline - ~~**YAML `\/` escape in double-quoted strings**~~ — ✓ shipped v0.2.13; regex patterns like `/^us\//` in double-quoted `if:` blocks no longer cause a parse error
gitlab-sim --source merge_request_event \ - ~~**Workflow rule strict evaluation**~~ — ✓ shipped v0.2.14; unparseable `if:` skips the rule instead of matching everything; prevents wrong variables being injected
--var CI_MERGE_REQUEST_TARGET_BRANCH_NAME=main \ - ~~**Single `=` operator**~~ — ✓ shipped v0.2.14; bare `=` accepted as alias for `==` in `rules:if:` expressions
.gitlab-ci.yml - ~~**`rules:changes:` evaluation**~~ — ✓ shipped v0.2.21; `--changes PATH` and `--changes-from REF` flags; doublestar glob matching (`*` within segment, `**` across segments); permissive when no file list provided
- **Multi-context simulation** — run multiple contexts in one invocation and print a comparison table (`--context branch=main --context branch=develop --context tag=v1.0.0`)
# Explicit variable overrides for anything not covered by the shortcuts - **Context-scoped linting** — skip `needs:`/`dependencies:` cross-checks for jobs that are statically unreachable in the given context
gitlab-sim --var CI_COMMIT_BRANCH=feat/my-feature \
--var CI_ENVIRONMENT_NAME=staging \
.gitlab-ci.yml
# Simulate multiple contexts in one run (print per-context job tables)
gitlab-sim --context branch=main \
--context branch=develop \
--context tag=v1.0.0 \
.gitlab-ci.yml
```
**What context injection enables**
- Each job is resolved to **active** / **manual** / **skipped** for the given context
- Warn when the entire pipeline would produce zero runnable jobs (common mistake when a `workflow:rules:` block is too restrictive)
- Lint only the active job subset — skip `needs:` / `dependencies:` cross-checks for jobs that never co-execute in that context
- `--context` multi-simulation: print a table showing which jobs activate per context, making it easy to audit Git Flow rules across branches and tags at once
**Expression evaluator scope**
GitLab's `rules:if:` expression language will be implemented incrementally:
| Priority | Operators / features |
|----------|----------------------|
| 1 (MVP) | `==`, `!=`, `null` check, `&&`, `\|\|`, `!`, parentheses |
| 2 | Regex match `=~` / `!~` with `/pattern/` literals |
| 3 | `$CI_COMMIT_BRANCH =~ /^feat\//`, anchored patterns |
| 4 | `only: branches / tags / merge_requests` shorthand mapping |
| 5 | `changes:` path glob evaluation against a real or mock file tree |
Predefined variables populated automatically from `--branch` / `--tag` / `--source` shortcuts: `CI_COMMIT_BRANCH`, `CI_COMMIT_TAG`, `CI_COMMIT_REF_NAME`, `CI_COMMIT_REF_SLUG`, `CI_PIPELINE_SOURCE`, `CI_DEFAULT_BRANCH` (defaulting to `main`).
--- ---
@@ -63,44 +32,45 @@ Predefined variables populated automatically from `--branch` / `--tag` / `--sour
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
- **`inherit:` completeness** — flag when a job overrides a default field that would require `inherit: default: false` to suppress - ~~**`pages:publish` + `artifacts.paths` consistency (GL039)**~~ — ✓ shipped v0.2.16; warns when the publish directory is missing from `artifacts.paths`
- **Unreachable jobs** — detect jobs that can never run because every `rules:` branch evaluates to `never` (static analysis only, no variable expansion) - ~~**Duplicate stage names (GL040)**~~ — ✓ shipped v0.2.16; warns when a stage appears more than once in `stages:`
- **Duplicate stage names** — GitLab silently merges them; warn to avoid confusion - ~~**`cache:key:files` must be exact paths (GL041)**~~ — ✓ shipped v0.2.16; warns when entries look like glob patterns
- **`cache:key:files`** — must be a list of paths, not a glob - ~~**Unreachable jobs**~~ — covered by GL033 (shipped v0.2.15); every-`when:never` rules block is statically dead
- ~~**`inherit:` completeness (GL043)**~~ — ✓ shipped v0.2.20; warns when `inherit: default:` is declared but there's no `default:` block, or list form names fields not set in `default:`
--- ---
## Include resolution ## Include resolution
- **`include: local:`** full resolution — parse and merge locally-referenced YAML files the same way remote project includes are handled; enables cross-file `extends:` and `needs:` validation for monorepo setups - ~~**`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
--- ---
## Pipeline graph improvements ## Pipeline graph improvements
The SVG renderer covers 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
- ~~**`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
@@ -110,31 +80,42 @@ The SVG renderer covers the basic layout. These would bring it closer to GitLab'
--- ---
## 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 (GL042)**~~ — ✓ shipped v0.2.20; warns when all `rules:if:` conditions evaluate to false given declared variable values (only fires when all referenced vars are declared in YAML)
---
## CI / editor integration ## CI / editor integration
- **GitLab CI template** — a `.gitlab-ci.yml` snippet that runs `gitlab-sim` 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
- **GitHub Actions action** — `uses: k3nny/gitlab-sim@v1` wrapper for repositories that mirror or manage GitLab pipelines from GitHub - **GitHub Actions action** — `uses: k3nny/glint@v1` wrapper for repositories that mirror or manage GitLab pipelines from GitHub
- **Pre-commit hook** — entry for [pre-commit](https://pre-commit.com) so `gitlab-sim` runs automatically on `git commit` when `.gitlab-ci.yml` changes - **Pre-commit hook** — entry for [pre-commit](https://pre-commit.com) so `glint` runs automatically on `git commit` when `.gitlab-ci.yml` changes
- **LSP server** — `gitlab-sim lsp` mode exposing diagnostics over the Language Server Protocol; enables inline squiggles in VS Code, JetBrains, Neovim, etc. without a dedicated extension - **LSP server** — `glint lsp` mode exposing diagnostics over the Language Server Protocol; enables inline squiggles in VS Code, JetBrains, Neovim, etc. without a dedicated extension
- **VS Code extension** — thin wrapper around the LSP server with syntax highlighting for `.gitlab-ci.yml` - **VS Code extension** — thin wrapper around the LSP server with syntax highlighting for `.gitlab-ci.yml`
--- ---
## Configuration ## Configuration — ✓ shipped v0.2.19
- **`.gitlab-sim.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** — `# gitlab-sim: 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; GL001GL031 assigned; GL032 added v0.2.11; GL033 added v0.2.15; GL034GL041 added v0.2.16; output formats (--format json/sarif/junit/github) added v0.2.18; GL042GL043 added v0.2.20
- **`--explain <rule-id>`** — print the rule description, rationale, and an example fix - ~~**`glint explain <rule-id>`**~~ ✓ shipped v0.2.20; prints rule description, rationale, bad-YAML example, and fix; `glint explain` (no arg) lists all rules
- ~~**Semantic versioning and first release**~~ — shipped as `v0.1.0` (2026-06-07) - ~~**Semantic versioning and first release**~~ — shipped as `v0.1.0` (2026-06-07)
- ~~**Subcommand CLI**~~ — shipped as `v0.2.0` (2026-06-11); `glint check` / `glint graph [mode]` with ruff-style `--help`
- **Changelog automation** — generate release notes from Conventional Commits via `git-cliff` or similar - **Changelog automation** — generate release notes from Conventional Commits via `git-cliff` or similar
- **Fuzz testing** — add a `go test -fuzz` target for the YAML parser to harden it against malformed input - **Fuzz testing** — add a `go test -fuzz` target for the YAML parser to harden it against malformed input
+121 -20
View File
@@ -1,8 +1,10 @@
version: "3" version: "3"
vars: vars:
BINARY: gitlab-sim 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:
@@ -10,9 +12,9 @@ tasks:
cmd: task --list cmd: task --list
build: build:
desc: Build the gitlab-sim binary desc: Build the glint binary
cmds: cmds:
- "{{.GO}} build -o {{.BINARY}} ./cmd/gitlab-sim/..." - "{{.GO}} build -ldflags \"-X main.version={{.VERSION}}\" -o {{.BINARY}} ./cmd/glint/..."
sources: sources:
- "**/*.go" - "**/*.go"
- go.mod - go.mod
@@ -24,50 +26,149 @@ tasks:
cmd: "{{.GO}} test ./..." cmd: "{{.GO}} test ./..."
validate: validate:
desc: Run gitlab-sim against all testdata fixtures desc: Run glint against all testdata fixtures
deps: [build] deps: [build]
cmds: cmds:
- cmd: ./{{.BINARY}} testdata/valid.yml - cmd: ./{{.BINARY}} check testdata/valid.yml
ignore_error: false ignore_error: false
- cmd: ./{{.BINARY}} testdata/extends.yml - cmd: ./{{.BINARY}} check testdata/extends.yml
ignore_error: false ignore_error: false
- cmd: ./{{.BINARY}} testdata/keywords_valid.yml - cmd: ./{{.BINARY}} check testdata/keywords_valid.yml
ignore_error: false ignore_error: false
- cmd: ./{{.BINARY}} testdata/invalid.yml - cmd: ./{{.BINARY}} check testdata/invalid.yml
ignore_error: true ignore_error: true
- cmd: ./{{.BINARY}} testdata/needs.yml - cmd: ./{{.BINARY}} check testdata/needs.yml
ignore_error: true ignore_error: true
- cmd: ./{{.BINARY}} testdata/needs_cycle.yml - cmd: ./{{.BINARY}} check testdata/needs_cycle.yml
ignore_error: true ignore_error: true
- cmd: ./{{.BINARY}} testdata/keywords_invalid.yml - cmd: ./{{.BINARY}} check testdata/keywords_invalid.yml
ignore_error: true ignore_error: true
- cmd: ./{{.BINARY}} testdata/includes_project.yml - cmd: ./{{.BINARY}} check testdata/includes_remote.yml
ignore_error: false ignore_error: false
- cmd: ./{{.BINARY}} testdata/includes_component.yml - cmd: ./{{.BINARY}} check testdata/includes_project.yml
ignore_error: false ignore_error: false
- cmd: ./{{.BINARY}} testdata/context_rules.yml - cmd: ./{{.BINARY}} check testdata/includes_component.yml
ignore_error: false ignore_error: false
- cmd: ./{{.BINARY}} --branch main testdata/context_rules.yml - cmd: ./{{.BINARY}} check testdata/script_multiline.yml
ignore_error: false ignore_error: false
- cmd: ./{{.BINARY}} --branch develop testdata/context_rules.yml - cmd: ./{{.BINARY}} check testdata/context_rules.yml
ignore_error: false ignore_error: false
- cmd: ./{{.BINARY}} --branch feat/my-feature testdata/context_rules.yml - cmd: ./{{.BINARY}} check --branch main testdata/context_rules.yml
ignore_error: false ignore_error: false
- cmd: ./{{.BINARY}} --tag v1.0.0 testdata/context_rules.yml - cmd: ./{{.BINARY}} check --branch develop testdata/context_rules.yml
ignore_error: false
- cmd: ./{{.BINARY}} check --branch feat/my-feature testdata/context_rules.yml
ignore_error: false
- cmd: ./{{.BINARY}} check --tag v1.0.0 testdata/context_rules.yml
ignore_error: false
- cmd: ./{{.BINARY}} check testdata/rules_if_expr.yml
ignore_error: false
- cmd: ./{{.BINARY}} check --branch main testdata/rules_if_expr.yml
ignore_error: false
- 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
- cmd: ./{{.BINARY}} check testdata/static_dead_rules.yml
ignore_error: false
- cmd: ./{{.BINARY}} check testdata/inherit_dead.yml
ignore_error: false
- cmd: ./{{.BINARY}} check testdata/inherit_dead_fields.yml
ignore_error: false
- cmd: ./{{.BINARY}} explain GL007
ignore_error: false
- cmd: ./{{.BINARY}} explain gl042
ignore_error: false
- cmd: ./{{.BINARY}} explain
ignore_error: false 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
build-windows:
desc: Build the glint binary for Windows x64 (requires a tagged commit)
vars:
TAG:
sh: git describe --tags --exact-match
preconditions:
- sh: git describe --tags --exact-match
msg: "Current commit is not tagged — Windows build requires a git tag"
cmds:
- "GOOS=windows GOARCH=amd64 {{.GO}} build -ldflags \"-X main.version={{.TAG}}\" -o {{.BINARY}}-{{.TAG}}.exe ./cmd/glint/..."
sources:
- "**/*.go"
- go.mod
generates:
- "{{.BINARY}}-{{.TAG}}.exe"
build-linux:
desc: Build the glint binary for Linux x64 (requires a tagged commit)
vars:
TAG:
sh: git describe --tags --exact-match
preconditions:
- sh: git describe --tags --exact-match
msg: "Current commit is not tagged — Linux build requires a git tag"
cmds:
- "GOOS=linux GOARCH=amd64 {{.GO}} build -ldflags \"-X main.version={{.TAG}}\" -o {{.BINARY}}-{{.TAG}}-linux-amd64 ./cmd/glint/..."
sources:
- "**/*.go"
- go.mod
generates:
- "{{.BINARY}}-{{.TAG}}-linux-amd64"
clean: clean:
desc: Remove build artifacts desc: Remove build artifacts
cmd: rm -f {{.BINARY}} cmd: rm -f {{.BINARY}} {{.BINARY}}-*.exe {{.BINARY}}-*-linux-amd64
+314
View File
@@ -0,0 +1,314 @@
# glint — usage reference
Full examples and option descriptions for every command.
For the lint rules reference see [FEATURES.md](FEATURES.md).
---
## `glint check`
Lint a pipeline file. Exits `0` when no errors are found, `1` when at least
one error is reported (warnings alone do not fail).
```bash
glint check .gitlab-ci.yml
```
### Output formats
Pass `--format` to control the output. Plain text is the default.
```bash
# Default: ruff-style text (human-readable)
glint check .gitlab-ci.yml
# JSON — stable schema, machine-readable
glint check --format json .gitlab-ci.yml
# SARIF 2.1.0 — GitHub Code Scanning / GitLab SAST
glint check --format sarif .gitlab-ci.yml > glint.sarif
# JUnit XML — CI test-report artifact (GitLab: artifacts:reports:junit)
glint check --format junit .gitlab-ci.yml > glint-junit.xml
# GitHub Actions annotations — inline PR diff comments
glint check --format github .gitlab-ci.yml
```
In structured formats (`json`, `sarif`, `junit`, `github`) the summary line is
written to stderr so stdout contains only the machine-readable payload.
**JSON schema (`schema_version: 1`):**
```json
{
"schema_version": 1,
"glint_version": "v0.2.20",
"pipeline": ".gitlab-ci.yml",
"findings": [
{"rule":"GL004","severity":"error","file":".gitlab-ci.yml","line":14,
"job":"deploy","message":"stage \"production\" is not defined in 'stages'"}
],
"summary": {"total": 1, "errors": 1, "warnings": 0}
}
```
**GitHub annotation lines:**
```
::error file=.gitlab-ci.yml,line=14,title=GL004::job "deploy": stage "production" is not defined in 'stages'
```
### Context simulation
Pass `--branch`, `--tag`, or `--source` to evaluate `rules:if:` and
`only`/`except` against a specific pipeline event. The pipeline is still fully
linted; the context summary is printed first. When no context flag is given,
glint defaults to `--branch main --source push`.
```bash
# What runs on a push to develop?
glint check --branch develop .gitlab-ci.yml
# What runs when a v1.2.0 tag is pushed?
glint check --tag v1.2.0 .gitlab-ci.yml
# Merge request pipeline
glint check --source merge_request_event .gitlab-ci.yml
# Arbitrary variable overrides (repeatable)
glint check --branch main --var DEPLOY_ENV=production .gitlab-ci.yml
# Debug variable resolution
glint check --list-vars .gitlab-ci.yml
```
**Predefined variables** set by the shortcut flags:
| Flag | Variables populated |
|------|---------------------|
| `--branch NAME` | `CI_COMMIT_BRANCH`, `CI_COMMIT_REF_NAME`, `CI_COMMIT_REF_SLUG`, `CI_PIPELINE_SOURCE=push` |
| `--tag NAME` | `CI_COMMIT_TAG`, `CI_COMMIT_REF_NAME`, `CI_COMMIT_REF_SLUG`, `CI_PIPELINE_SOURCE=push`; clears `CI_COMMIT_BRANCH` |
| `--source EVENT` | `CI_PIPELINE_SOURCE` |
| `--var KEY=VALUE` | any variable; overrides shortcuts; repeatable |
### Remote project includes
Provide a token so glint can fetch `include: project:` templates:
```bash
# Personal access token (read_api scope)
GITLAB_TOKEN=glpat-xxxx glint check .gitlab-ci.yml
# CI/CD job token (when running inside a pipeline)
CI_JOB_TOKEN=$CI_JOB_TOKEN glint check .gitlab-ci.yml
# Self-hosted GitLab
GITLAB_TOKEN=glpat-xxxx GITLAB_URL=https://gitlab.example.com glint check .gitlab-ci.yml
# Via flags (override env vars)
glint check --token glpat-xxxx --gitlab-url https://gitlab.example.com .gitlab-ci.yml
```
Project includes are skipped with a warning when no token is available; linting
continues with whatever is resolved.
**Token resolution order** (first non-empty wins):
| Source | Header |
|--------|--------|
| `--token` flag / `GITLAB_TOKEN` env | `PRIVATE-TOKEN` |
| `CI_JOB_TOKEN` env | `JOB-TOKEN` |
| `GITLAB_PRIVATE_TOKEN` env | `PRIVATE-TOKEN` |
**URL resolution order:** `--gitlab-url` flag → `CI_SERVER_URL` env → `GITLAB_URL` env → `https://gitlab.com`
### Include cache and offline mode
```bash
# Cache fetched templates to disk (keyed by SHA-256 of the request coordinates)
glint check --cache-dir ~/.cache/glint .gitlab-ci.yml
# Fully offline — serve from cache, warn on misses, make no network calls
glint check --offline .gitlab-ci.yml
glint check --offline --cache-dir /path/to/cache .gitlab-ci.yml
```
`--offline` without `--cache-dir` uses `~/.cache/glint` automatically.
There is no automatic cache expiry; delete the directory or individual entries
to force a re-fetch.
### Component reference format
`include: component:` references follow the format:
```
<host>/<project-path>/<component-name>@<version>
gitlab.com/components/secret-detection/secret-detection@v0.1.0
gitlab.com/my-org/ci-catalog/lint@main
gitlab.example.com/platform/components/build@~latest
```
The component file is looked up in order:
1. `templates/<component-name>.yml` (single-file layout)
2. `templates/<component-name>/template.yml` (directory layout)
Public Catalog components work without a token. `with:` input parameters are
substituted locally before parsing; they are not validated against the
component spec.
### Example output
```
# Clean pipeline
Context: branch=main, source=push
Active (5): build, deploy-staging, test, lint, security-scan
OK: .gitlab-ci.yml — no issues found (5 job(s), 3 stage(s))
# With --branch develop
Context: branch=develop, source=push
Active (3): build, deploy-staging, test
Skipped (2): deploy-prod, release-notes
OK: .gitlab-ci.yml — no issues found (5 job(s), 3 stage(s))
# Pipeline with issues
.gitlab-ci.yml:14: GL004 [error] job "deploy": stage "production" is not defined in 'stages'
.gitlab-ci.yml:22: GL027 [error] job "test": needs unknown job "build-app"
.gitlab-ci.yml:31: GL007 [warning] job "old-job": 'only'/'except' are deprecated; prefer 'rules'
3 finding(s): 2 error(s)
```
---
## `glint graph`
Visualise the pipeline. Without a mode word, prints a job tree and the include
dependency graph separated by `---`.
```bash
# Default: job tree + include dependency graph
glint graph .gitlab-ci.yml
# Job tree only
glint graph tree .gitlab-ci.yml
# Job tree with context (shows active/manual/skipped annotations)
glint graph tree --branch develop .gitlab-ci.yml
# Include dependency graph → Mermaid flowchart to stdout
glint graph includes .gitlab-ci.yml > includes.mmd
# GitLab-like pipeline layout → PNG/SVG written to --out dir
glint graph pipeline .gitlab-ci.yml
# prints the output path, e.g.: glint-out/pipeline-20260614-143022.png
# Mermaid to stdout + pipeline file path to stderr
glint graph all .gitlab-ci.yml > includes.mmd
# Custom output directory
glint graph pipeline --out /tmp/graphs .gitlab-ci.yml
```
**Job tree** — stages as branches, jobs as leaves; annotated with `[manual]`,
`[delayed]`, `[trigger]` where applicable. Context flags apply the same
evaluation as `glint check`.
**Include graph** — [Mermaid](https://mermaid.js.org) flowchart; pipe to `.mmd`
or paste into [mermaid.live](https://mermaid.live). Nodes are colour-coded by
include type: orange (main file), purple (project), green (component), blue
(local), grey (remote URL), light orange (GitLab template).
**Pipeline graph** — GitLab CI-style SVG rendered to a timestamped file.
Converted to PNG when `rsvg-convert`, `inkscape`, or `magick` is available.
DAG mode (Bézier arrows between jobs) activates automatically when any job has
a `needs:` list; classic mode uses stage-column connectors otherwise.
---
## `glint explain`
Print the documentation for a specific lint rule.
```bash
# Description, example, and fix for GL007
glint explain GL007
# Case-insensitive
glint explain gl007
# List all rules with ID, severity, and title
glint explain
```
---
## Project configuration (`.glint.yml`)
Place a `.glint.yml` anywhere in the directory tree from the pipeline file up
to the first `.git` boundary. glint searches upward and uses the first file it
finds.
```yaml
# Suppress rules globally for this project.
ignore:
- GL007 # migrating from only:/except:
- GL032 # dynamic variables injected by CI
# Override rule severity: error | warning | ignore
# 'ignore' is equivalent to listing the rule under ignore:.
severity:
GL004: warning # demote during a stage migration
GL035: error # promote to hard error for this project
# Extra stage names valid beyond those declared in the pipeline YAML
# (e.g. injected by an include template you don't own).
stages:
- quality
- security
# Default token — lower priority than --token and GITLAB_TOKEN.
token: glpat-xxxx
# Default GitLab instance URL.
url: https://gitlab.example.com
# Default cache directory for fetched remote includes.
cache_dir: ~/.cache/glint
```
**Priority chain:** `--token`/`--gitlab-url` flags > `.glint.yml` > environment variables.
---
## Inline suppression (`# glint: ignore`)
Suppress a finding for a specific job by placing a comment immediately before
the job definition in the pipeline YAML:
```yaml
# glint: ignore GL007
legacy-job:
stage: build
only: [main]
script: echo ok
# Multiple rules — comma- or space-separated:
# glint: ignore GL007, GL032
other-job:
stage: build
script: echo ok
# Suppress every rule for this job:
# glint: ignore all
noisy-job:
stage: build
script: echo ok
```
Suppressions are scoped to the single job they precede and do not affect other
jobs or pipeline-level findings. For project-wide suppression, use `.glint.yml`
`ignore:` instead.
Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

+70
View File
@@ -0,0 +1,70 @@
package main
import (
"fmt"
"os"
"sort"
"strings"
"git.k3nny.fr/glint/internal/linter"
)
func cmdExplain(args []string) {
if len(args) == 0 {
printRuleList()
return
}
ruleID := strings.ToUpper(args[0])
entry, ok := linter.RuleCatalog[ruleID]
if !ok {
fmt.Fprintf(os.Stderr, "glint explain: unknown rule %q\n\nRun 'glint explain' to list all rules.\n", ruleID)
exit(2)
return
}
printRuleEntry(ruleID, entry)
}
func printRuleEntry(id string, e linter.RuleEntry) {
sev := strings.ToLower(string(e.Severity))
header := fmt.Sprintf("%s [%s] %s", id, sev, e.Title)
rule := fmt.Sprintf("\n%s\n%s\n\n%s\n",
header,
strings.Repeat("─", len(header)),
e.Description,
)
fmt.Print(rule)
if e.Example != "" {
fmt.Println("Example:")
fmt.Println()
for _, line := range strings.Split(e.Example, "\n") {
fmt.Printf(" %s\n", line)
}
fmt.Println()
}
if e.Fix != "" {
fmt.Println("Fix:")
fmt.Println()
for _, line := range strings.Split(e.Fix, "\n") {
fmt.Printf(" %s\n", line)
}
fmt.Println()
}
}
func printRuleList() {
ids := make([]string, 0, len(linter.RuleCatalog))
for id := range linter.RuleCatalog {
ids = append(ids, id)
}
sort.Strings(ids)
fmt.Printf("glint %s — lint rules\n\n", version)
for _, id := range ids {
e := linter.RuleCatalog[id]
sev := strings.ToLower(string(e.Severity))
fmt.Printf(" %-6s [%-7s] %s\n", id, sev, e.Title)
}
fmt.Println("\nUse 'glint explain <RULE>' for details on a specific rule.")
}
+87
View File
@@ -0,0 +1,87 @@
package main
import (
"strings"
"git.k3nny.fr/glint/internal/config"
"git.k3nny.fr/glint/internal/linter"
)
// applyConfig filters and adjusts findings according to the project config
// and inline suppression comments parsed from the pipeline YAML.
//
// Processing order:
// 1. Build a combined ignore set from config.Ignore and any severity entry
// whose value is "ignore".
// 2. Drop findings whose rule is in the ignore set.
// 3. Drop findings suppressed by an inline "# glint: ignore" comment on the
// job definition (from p.Suppressions).
// 4. Apply severity overrides ("error" / "warning") from config.Severity.
func applyConfig(findings []linter.Finding, cfg config.Config, suppressions map[string][]string) []linter.Finding {
// Build the global ignore set (uppercased rule IDs).
ignoreSet := make(map[string]bool, len(cfg.Ignore))
for _, r := range cfg.Ignore {
ignoreSet[strings.ToUpper(r)] = true
}
// Severity entries with value "ignore" are equivalent to Ignore entries.
sevMap := make(map[string]string, len(cfg.Severity)) // upperRule → lowerLevel
for rule, sev := range cfg.Severity {
upper := strings.ToUpper(rule)
lower := strings.ToLower(sev)
sevMap[upper] = lower
if lower == "ignore" {
ignoreSet[upper] = true
}
}
if len(ignoreSet) == 0 && len(sevMap) == 0 && len(suppressions) == 0 {
return findings
}
kept := findings[:0:0] // reuse underlying array but return fresh slice
for _, f := range findings {
ruleUpper := strings.ToUpper(f.Rule)
// Global ignore.
if ignoreSet[ruleUpper] {
continue
}
// Inline suppression.
if isSuppressed(f.Job, ruleUpper, suppressions) {
continue
}
// Severity override.
if level, ok := sevMap[ruleUpper]; ok {
switch level {
case "error":
f.Severity = linter.Error
case "warning":
f.Severity = linter.Warning
}
}
kept = append(kept, f)
}
return kept
}
// isSuppressed reports whether jobName has a "# glint: ignore" directive that
// covers ruleUpper. The wildcard entry "*" (from "# glint: ignore all")
// suppresses every rule.
func isSuppressed(jobName, ruleUpper string, suppressions map[string][]string) bool {
if jobName == "" || len(suppressions) == 0 {
return false
}
rules, ok := suppressions[jobName]
if !ok {
return false
}
for _, r := range rules {
if r == "*" || r == ruleUpper {
return true
}
}
return false
}
+116
View File
@@ -0,0 +1,116 @@
package main
import (
"testing"
"git.k3nny.fr/glint/internal/config"
"git.k3nny.fr/glint/internal/linter"
)
func TestApplyConfig(t *testing.T) {
findings := []linter.Finding{
{Severity: linter.Error, Rule: "GL004", Job: "deploy", File: "ci.yml", Line: 10, Message: "bad stage"},
{Severity: linter.Warning, Rule: "GL007", Job: "old-job", File: "ci.yml", Line: 20, Message: "deprecated"},
{Severity: linter.Warning, Rule: "GL032", Job: "check", File: "ci.yml", Line: 30, Message: "var ref"},
}
t.Run("no config — pass through", func(t *testing.T) {
got := applyConfig(findings, config.Config{}, nil)
if len(got) != 3 {
t.Errorf("got %d findings, want 3", len(got))
}
})
t.Run("ignore GL007", func(t *testing.T) {
cfg := config.Config{Ignore: []string{"GL007"}}
got := applyConfig(findings, cfg, nil)
if len(got) != 2 {
t.Fatalf("got %d findings, want 2", len(got))
}
for _, f := range got {
if f.Rule == "GL007" {
t.Error("GL007 should be suppressed")
}
}
})
t.Run("ignore case-insensitive", func(t *testing.T) {
cfg := config.Config{Ignore: []string{"gl007"}}
got := applyConfig(findings, cfg, nil)
if len(got) != 2 {
t.Fatalf("got %d, want 2", len(got))
}
})
t.Run("severity demote error to warning", func(t *testing.T) {
cfg := config.Config{Severity: map[string]string{"GL004": "warning"}}
got := applyConfig(findings, cfg, nil)
if len(got) != 3 {
t.Fatalf("got %d findings, want 3", len(got))
}
if got[0].Severity != linter.Warning {
t.Errorf("GL004 severity = %s, want WARNING", got[0].Severity)
}
})
t.Run("severity promote warning to error", func(t *testing.T) {
cfg := config.Config{Severity: map[string]string{"GL007": "error"}}
got := applyConfig(findings, cfg, nil)
if got[1].Severity != linter.Error {
t.Errorf("GL007 severity = %s, want ERROR", got[1].Severity)
}
})
t.Run("severity ignore is equivalent to ignore list", func(t *testing.T) {
cfg := config.Config{Severity: map[string]string{"GL032": "ignore"}}
got := applyConfig(findings, cfg, nil)
if len(got) != 2 {
t.Fatalf("got %d, want 2", len(got))
}
for _, f := range got {
if f.Rule == "GL032" {
t.Error("GL032 should be suppressed via severity=ignore")
}
}
})
t.Run("inline suppression by job", func(t *testing.T) {
suppressions := map[string][]string{
"old-job": {"GL007"},
}
got := applyConfig(findings, config.Config{}, suppressions)
if len(got) != 2 {
t.Fatalf("got %d, want 2", len(got))
}
for _, f := range got {
if f.Job == "old-job" && f.Rule == "GL007" {
t.Error("old-job GL007 should be suppressed")
}
}
})
t.Run("inline suppression wildcard", func(t *testing.T) {
suppressions := map[string][]string{
"old-job": {"*"},
}
got := applyConfig(findings, config.Config{}, suppressions)
// old-job had GL007; should be gone
if len(got) != 2 {
t.Fatalf("got %d, want 2", len(got))
}
})
t.Run("pipeline-level findings not suppressed by job comment", func(t *testing.T) {
pipelineFindings := []linter.Finding{
{Severity: linter.Error, Rule: "GL001", Job: "", File: "ci.yml", Line: 0, Message: "no stages"},
}
suppressions := map[string][]string{
"": {"GL001"}, // empty job key should not match pipeline-level
}
got := applyConfig(pipelineFindings, config.Config{}, suppressions)
// Pipeline-level findings (f.Job == "") are never suppressed by job comments.
if len(got) != 1 {
t.Errorf("got %d, want 1 (pipeline-level finding must not be suppressed)", len(got))
}
})
}
+332
View File
@@ -0,0 +1,332 @@
package main
import (
"encoding/json"
"encoding/xml"
"fmt"
"io"
"strings"
"git.k3nny.fr/glint/internal/linter"
)
// --- JSON ---
type jsonReport struct {
SchemaVersion int `json:"schema_version"`
GlintVersion string `json:"glint_version"`
Pipeline string `json:"pipeline"`
Findings []jsonFinding `json:"findings"`
Summary jsonSummary `json:"summary"`
}
type jsonFinding struct {
Rule string `json:"rule,omitempty"`
Severity string `json:"severity"`
File string `json:"file,omitempty"`
Line int `json:"line,omitempty"`
Job string `json:"job,omitempty"`
Message string `json:"message"`
}
type jsonSummary struct {
Total int `json:"total"`
Errors int `json:"errors"`
Warnings int `json:"warnings"`
}
func writeJSON(w io.Writer, findings []linter.Finding, pipeline string) {
errs, warns := countSeverities(findings)
jf := make([]jsonFinding, 0, len(findings))
for _, f := range findings {
jf = append(jf, jsonFinding{
Rule: f.Rule,
Severity: strings.ToLower(string(f.Severity)),
File: f.File,
Line: f.Line,
Job: f.Job,
Message: f.Message,
})
}
report := jsonReport{
SchemaVersion: 1,
GlintVersion: version,
Pipeline: pipeline,
Findings: jf,
Summary: jsonSummary{Total: len(findings), Errors: errs, Warnings: warns},
}
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
_ = enc.Encode(report)
}
// --- SARIF 2.1.0 ---
type sarifLog struct {
Schema string `json:"$schema"`
Version string `json:"version"`
Runs []sarifRun `json:"runs"`
}
type sarifRun struct {
Tool sarifTool `json:"tool"`
Results []sarifResult `json:"results"`
}
type sarifTool struct {
Driver sarifDriver `json:"driver"`
}
type sarifDriver struct {
Name string `json:"name"`
Version string `json:"version"`
InformationURI string `json:"informationUri"`
Rules []sarifRule `json:"rules"`
}
type sarifRule struct {
ID string `json:"id"`
ShortDescription sarifMessage `json:"shortDescription"`
HelpURI string `json:"helpUri,omitempty"`
}
type sarifMessage struct {
Text string `json:"text"`
}
type sarifResult struct {
RuleID string `json:"ruleId,omitempty"`
Level string `json:"level"`
Message sarifMessage `json:"message"`
Locations []sarifLocation `json:"locations,omitempty"`
}
type sarifLocation struct {
PhysicalLocation sarifPhysLoc `json:"physicalLocation"`
}
type sarifPhysLoc struct {
ArtifactLocation sarifArtifact `json:"artifactLocation"`
Region *sarifRegion `json:"region,omitempty"`
}
type sarifArtifact struct {
URI string `json:"uri"`
URIBaseID string `json:"uriBaseId,omitempty"`
}
type sarifRegion struct {
StartLine int `json:"startLine"`
}
func writeSARIF(w io.Writer, findings []linter.Finding, _ string) {
// Collect unique rule IDs (preserving first-seen order).
seenRules := map[string]bool{}
var rules []sarifRule
for _, f := range findings {
if f.Rule != "" && !seenRules[f.Rule] {
seenRules[f.Rule] = true
rules = append(rules, sarifRule{
ID: f.Rule,
ShortDescription: sarifMessage{Text: "glint rule " + f.Rule},
HelpURI: "https://git.k3nny.fr/glint",
})
}
}
if rules == nil {
rules = []sarifRule{}
}
var results []sarifResult
for _, f := range findings {
level := "warning"
if f.Severity == linter.Error {
level = "error"
}
msg := f.Message
if f.Job != "" {
msg = fmt.Sprintf("job %q: %s", f.Job, msg)
}
result := sarifResult{
RuleID: f.Rule,
Level: level,
Message: sarifMessage{Text: msg},
}
if f.File != "" {
loc := sarifLocation{
PhysicalLocation: sarifPhysLoc{
ArtifactLocation: sarifArtifact{URI: f.File, URIBaseID: "%SRCROOT%"},
},
}
if f.Line > 0 {
loc.PhysicalLocation.Region = &sarifRegion{StartLine: f.Line}
}
result.Locations = []sarifLocation{loc}
}
results = append(results, result)
}
if results == nil {
results = []sarifResult{}
}
log := sarifLog{
Schema: "https://json.schemastore.org/sarif-2.1.0.json",
Version: "2.1.0",
Runs: []sarifRun{{
Tool: sarifTool{Driver: sarifDriver{
Name: "glint",
Version: strings.TrimPrefix(version, "v"),
InformationURI: "https://git.k3nny.fr/glint",
Rules: rules,
}},
Results: results,
}},
}
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
_ = enc.Encode(log)
}
// --- JUnit XML ---
type junitTestsuites struct {
XMLName xml.Name `xml:"testsuites"`
Name string `xml:"name,attr"`
Tests int `xml:"tests,attr"`
Failures int `xml:"failures,attr"`
Time string `xml:"time,attr"`
Suites []junitTestsuite `xml:"testsuite"`
}
type junitTestsuite struct {
Name string `xml:"name,attr"`
Tests int `xml:"tests,attr"`
Failures int `xml:"failures,attr"`
Time string `xml:"time,attr"`
Cases []junitTestcase `xml:"testcase"`
}
type junitTestcase struct {
Name string `xml:"name,attr"`
Classname string `xml:"classname,attr"`
Failure *junitFailure `xml:"failure,omitempty"`
}
type junitFailure struct {
Message string `xml:"message,attr"`
Type string `xml:"type,attr"`
Body string `xml:",chardata"`
}
func writeJUnit(w io.Writer, findings []linter.Finding, pipeline string) {
_, fails := countSeverities(findings)
_ = fails // re-derive below to count both errors and warnings as failures
var cases []junitTestcase
if len(findings) == 0 {
cases = []junitTestcase{{Name: "no issues found", Classname: pipeline}}
} else {
for _, f := range findings {
name := f.Rule
if name == "" {
name = "lint"
}
classname := f.File
if classname == "" {
classname = pipeline
}
if f.Job != "" {
classname += "#" + f.Job
}
msg := f.Message
if f.Job != "" {
msg = fmt.Sprintf("job %q: %s", f.Job, msg)
}
cases = append(cases, junitTestcase{
Name: name,
Classname: classname,
Failure: &junitFailure{
Message: msg,
Type: strings.ToLower(string(f.Severity)),
Body: f.String(),
},
})
}
}
suite := junitTestsuite{
Name: pipeline,
Tests: len(cases),
Failures: len(findings), // all findings are failures
Time: "0",
Cases: cases,
}
suites := junitTestsuites{
Name: "glint",
Tests: len(cases),
Failures: len(findings),
Time: "0",
Suites: []junitTestsuite{suite},
}
fmt.Fprint(w, `<?xml version="1.0" encoding="UTF-8"?>`)
fmt.Fprintln(w)
enc := xml.NewEncoder(w)
enc.Indent("", " ")
_ = enc.Encode(suites)
_ = enc.Flush()
fmt.Fprintln(w)
}
// --- GitHub Actions annotations ---
// writeGitHub emits GitHub Actions workflow command annotation lines.
// Each finding becomes an ::error:: or ::warning:: line that GitHub CI
// renders as an inline comment on the relevant file in pull requests.
func writeGitHub(w io.Writer, findings []linter.Finding) {
for _, f := range findings {
level := "warning"
if f.Severity == linter.Error {
level = "error"
}
msg := f.Message
if f.Job != "" {
msg = fmt.Sprintf("job %q: %s", f.Job, msg)
}
// GitHub annotation messages must not contain raw newlines, percent signs,
// carriage returns, or colons in the parameter block.
msg = strings.ReplaceAll(msg, "%", "%25")
msg = strings.ReplaceAll(msg, "\r", "%0D")
msg = strings.ReplaceAll(msg, "\n", "%0A")
var params []string
if f.File != "" {
params = append(params, "file="+f.File)
if f.Line > 0 {
params = append(params, fmt.Sprintf("line=%d", f.Line))
}
}
if f.Rule != "" {
params = append(params, "title="+f.Rule)
}
paramStr := ""
if len(params) > 0 {
paramStr = " " + strings.Join(params, ",")
}
fmt.Fprintf(w, "::%s%s::%s\n", level, paramStr, msg)
}
}
// --- shared helpers ---
func countSeverities(findings []linter.Finding) (errors, warnings int) {
for _, f := range findings {
if f.Severity == linter.Error {
errors++
} else {
warnings++
}
}
return
}
+228
View File
@@ -0,0 +1,228 @@
package main
import (
"bytes"
"encoding/json"
"encoding/xml"
"strings"
"testing"
"git.k3nny.fr/glint/internal/linter"
)
var testFindings = []linter.Finding{
{
Severity: linter.Error,
Rule: "GL004",
Job: "deploy",
File: ".gitlab-ci.yml",
Line: 14,
Message: `stage "production" is not defined in 'stages'`,
},
{
Severity: linter.Warning,
Rule: "GL007",
Job: "old-job",
File: ".gitlab-ci.yml",
Line: 31,
Message: "'only'/'except' are deprecated; prefer 'rules'",
},
}
func TestWriteJSON(t *testing.T) {
t.Run("findings", func(t *testing.T) {
var buf bytes.Buffer
writeJSON(&buf, testFindings, ".gitlab-ci.yml")
var got jsonReport
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if got.SchemaVersion != 1 {
t.Errorf("schema_version = %d, want 1", got.SchemaVersion)
}
if len(got.Findings) != 2 {
t.Fatalf("got %d findings, want 2", len(got.Findings))
}
if got.Summary.Errors != 1 || got.Summary.Warnings != 1 {
t.Errorf("summary = %+v, want {2,1,1}", got.Summary)
}
if got.Findings[0].Rule != "GL004" || got.Findings[0].Severity != "error" {
t.Errorf("finding[0] = %+v", got.Findings[0])
}
if got.Findings[0].Job != "deploy" || got.Findings[0].Line != 14 {
t.Errorf("finding[0] job/line = %s/%d", got.Findings[0].Job, got.Findings[0].Line)
}
})
t.Run("empty", func(t *testing.T) {
var buf bytes.Buffer
writeJSON(&buf, nil, "ci.yml")
var got jsonReport
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if got.Findings == nil {
t.Error("findings must be [] not null")
}
if got.Summary.Total != 0 {
t.Errorf("total = %d, want 0", got.Summary.Total)
}
})
}
func TestWriteSARIF(t *testing.T) {
t.Run("findings", func(t *testing.T) {
var buf bytes.Buffer
writeSARIF(&buf, testFindings, ".gitlab-ci.yml")
var got sarifLog
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if got.Version != "2.1.0" {
t.Errorf("version = %q, want 2.1.0", got.Version)
}
if len(got.Runs) != 1 {
t.Fatalf("runs count = %d, want 1", len(got.Runs))
}
run := got.Runs[0]
if run.Tool.Driver.Name != "glint" {
t.Errorf("driver.name = %q", run.Tool.Driver.Name)
}
if len(run.Results) != 2 {
t.Fatalf("results count = %d, want 2", len(run.Results))
}
r0 := run.Results[0]
if r0.RuleID != "GL004" || r0.Level != "error" {
t.Errorf("result[0] ruleId/level = %s/%s", r0.RuleID, r0.Level)
}
if len(r0.Locations) != 1 {
t.Fatalf("result[0] locations count = %d, want 1", len(r0.Locations))
}
loc := r0.Locations[0].PhysicalLocation
if loc.ArtifactLocation.URI != ".gitlab-ci.yml" {
t.Errorf("artifactLocation.uri = %q", loc.ArtifactLocation.URI)
}
if loc.Region == nil || loc.Region.StartLine != 14 {
t.Errorf("region = %v", loc.Region)
}
// Message should include job name prefix.
if !strings.Contains(r0.Message.Text, `job "deploy"`) {
t.Errorf("message missing job prefix: %q", r0.Message.Text)
}
})
t.Run("empty", func(t *testing.T) {
var buf bytes.Buffer
writeSARIF(&buf, nil, "ci.yml")
var got sarifLog
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
run := got.Runs[0]
if run.Results == nil {
t.Error("results must be [] not null")
}
if run.Tool.Driver.Rules == nil {
t.Error("rules must be [] not null")
}
})
}
func TestWriteJUnit(t *testing.T) {
t.Run("findings", func(t *testing.T) {
var buf bytes.Buffer
writeJUnit(&buf, testFindings, ".gitlab-ci.yml")
if !strings.HasPrefix(buf.String(), "<?xml") {
t.Error("output does not start with XML declaration")
}
var got junitTestsuites
if err := xml.Unmarshal(buf.Bytes(), &got); err != nil {
t.Fatalf("invalid XML: %v\noutput:\n%s", err, buf.String())
}
if got.Failures != 2 {
t.Errorf("failures = %d, want 2", got.Failures)
}
if len(got.Suites) != 1 {
t.Fatalf("suites count = %d, want 1", len(got.Suites))
}
suite := got.Suites[0]
if len(suite.Cases) != 2 {
t.Fatalf("test cases count = %d, want 2", len(suite.Cases))
}
if suite.Cases[0].Name != "GL004" {
t.Errorf("case[0].name = %q", suite.Cases[0].Name)
}
if suite.Cases[0].Failure == nil {
t.Error("case[0] has no failure element")
}
})
t.Run("empty_is_passing", func(t *testing.T) {
var buf bytes.Buffer
writeJUnit(&buf, nil, "ci.yml")
var got junitTestsuites
if err := xml.Unmarshal(buf.Bytes(), &got); err != nil {
t.Fatalf("invalid XML: %v", err)
}
if got.Failures != 0 {
t.Errorf("failures = %d, want 0", got.Failures)
}
suite := got.Suites[0]
if len(suite.Cases) != 1 || suite.Cases[0].Failure != nil {
t.Error("expected exactly one passing testcase when no findings")
}
})
}
func TestWriteGitHub(t *testing.T) {
cases := []struct {
name string
findings []linter.Finding
wantLine string
}{
{
name: "error with file and line",
findings: []linter.Finding{testFindings[0]},
wantLine: "::error file=.gitlab-ci.yml,line=14,title=GL004::job \"deploy\": stage \"production\" is not defined in 'stages'",
},
{
name: "warning",
findings: []linter.Finding{testFindings[1]},
wantLine: "::warning file=.gitlab-ci.yml,line=31,title=GL007::job \"old-job\": 'only'/'except' are deprecated; prefer 'rules'",
},
{
name: "no file or line",
findings: []linter.Finding{{
Severity: linter.Error,
Rule: "GL001",
Message: "no stages defined",
}},
wantLine: "::error title=GL001::no stages defined",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var buf bytes.Buffer
writeGitHub(&buf, tc.findings)
got := strings.TrimRight(buf.String(), "\n")
if got != tc.wantLine {
t.Errorf("\ngot: %s\nwant: %s", got, tc.wantLine)
}
})
}
}
func TestCountSeverities(t *testing.T) {
errs, warns := countSeverities(testFindings)
if errs != 1 || warns != 1 {
t.Errorf("countSeverities = (%d, %d), want (1, 1)", errs, warns)
}
}
+681
View File
@@ -0,0 +1,681 @@
package main
import (
"flag"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"git.k3nny.fr/glint/internal/cicontext"
"git.k3nny.fr/glint/internal/config"
"git.k3nny.fr/glint/internal/fetcher"
"git.k3nny.fr/glint/internal/graph"
"git.k3nny.fr/glint/internal/linter"
"git.k3nny.fr/glint/internal/model"
"git.k3nny.fr/glint/internal/resolver"
)
// version is set at build time via -ldflags "-X main.version=vX.Y.Z".
var version = "dev"
// exit is a variable so tests can capture exit calls without terminating.
var exit = os.Exit
// userHomeDirFn is a variable so tests can simulate UserHomeDir failure.
var userHomeDirFn = os.UserHomeDir
// 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 := userHomeDirFn(); err == nil {
return filepath.Join(home, ".cache", "glint")
}
return ""
}
// execCommandOutput is a variable so tests can mock external command execution.
var execCommandOutput = func(name string, args ...string) ([]byte, error) {
return exec.Command(name, args...).Output()
}
// gitDiffFiles runs "git diff --name-only <ref>" and returns the list of changed
// file paths. Returns nil + error when the command fails (e.g. not in a git repo
// or the ref doesn't exist).
func gitDiffFiles(ref string) ([]string, error) {
out, err := execCommandOutput("git", "diff", "--name-only", ref)
if err != nil {
return nil, fmt.Errorf("git diff --name-only %s: %w", ref, err)
}
var files []string
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if line != "" {
files = append(files, line)
}
}
return files, nil
}
const globalUsage = `glint: Lint and visualise GitLab CI pipelines locally.
Usage: glint [OPTIONS] <COMMAND>
Commands:
check Lint a pipeline file — exits 0 (clean) or 1 (errors found)
graph Visualise the pipeline as a job tree or Mermaid graph
explain Show description and fix for a lint rule (e.g. glint explain GL007)
Options:
-h, --help Print help
-v, --version Print version
For help with a specific command, see: ` + "`glint <command> --help`" + `.
`
func main() {
if len(os.Args) < 2 {
fmt.Fprint(os.Stderr, globalUsage)
exit(2)
return
}
switch os.Args[1] {
case "check":
cmdCheck(os.Args[2:])
case "graph":
cmdGraph(os.Args[2:])
case "explain":
cmdExplain(os.Args[2:])
case "-h", "--help", "help":
fmt.Fprintf(os.Stderr, "glint %s\n\n", version)
fmt.Fprint(os.Stderr, globalUsage)
case "-v", "--version", "version":
fmt.Printf("glint %s\n", version)
default:
fmt.Fprintf(os.Stderr, "glint: unknown command %q\n\n%s", os.Args[1], globalUsage)
exit(2)
}
}
// multiFlag allows a flag to be specified multiple times.
type multiFlag []string
func (f *multiFlag) String() string { return strings.Join(*f, ", ") }
func (f *multiFlag) Set(v string) error {
*f = append(*f, v)
return nil
}
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")
listVars := fs.Bool("list-vars", false, "print all collected pipeline variables (from root and included files) to stderr, then continue")
var vars multiFlag
fs.Var(&vars, "var", "set a CI variable as KEY=VALUE; repeatable")
var changesFiles multiFlag
fs.Var(&changesFiles, "changes", "mark a file path as changed for rules:changes: evaluation; repeatable")
changesFrom := fs.String("changes-from", "", "git ref to diff against for rules:changes: evaluation (e.g. HEAD~1, origin/main)")
fs.Usage = func() {
fmt.Fprintf(os.Stderr, "glint %s\n\n", version)
fmt.Fprint(os.Stderr, `Lint a GitLab CI pipeline file.
Resolves local includes and extends chains, then runs all lint rules.
Exits 0 when no errors are found, 1 when at least one error is reported.
Usage: glint check [OPTIONS] <PIPELINE>
Arguments:
<PIPELINE> Path to the .gitlab-ci.yml file to lint
Options:
--format <FORMAT>
Output format for findings.
[default: text] [possible values: text, json, sarif, junit, github]
--token <TOKEN>
GitLab personal access token. Required to fetch project: includes;
component: includes are attempted unauthenticated.
[env: GITLAB_TOKEN | CI_JOB_TOKEN | GITLAB_PRIVATE_TOKEN]
--gitlab-url <URL>
GitLab instance URL.
[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>
Simulate a branch push. Populates: CI_COMMIT_BRANCH,
CI_COMMIT_REF_NAME, CI_COMMIT_REF_SLUG, CI_PIPELINE_SOURCE=push.
[default: main]
--tag <NAME>
Simulate a tag push. Populates: CI_COMMIT_TAG, CI_COMMIT_REF_NAME,
CI_COMMIT_REF_SLUG, CI_PIPELINE_SOURCE=push. Clears CI_COMMIT_BRANCH.
--source <EVENT>
Override CI_PIPELINE_SOURCE.
[default: push] [possible values: push, merge_request_event, schedule, web, api]
--var <KEY=VALUE>
Set or override a CI variable. Takes precedence over --branch, --tag,
and --source. Repeatable.
--changes <PATH>
Mark a file as changed for rules:changes: evaluation. Repeatable.
When given, only jobs whose rules:changes: patterns match at least one
--changes path will have that rule fire; without --changes or
--changes-from the condition is treated as always matching (permissive).
--changes-from <REF>
Run "git diff --name-only <REF>" to determine changed files for
rules:changes: evaluation. Combined with --changes if both are given.
--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
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:
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
glint check --list-vars .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 --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
glint check --changes src/main.go --changes Dockerfile .gitlab-ci.yml
glint check --changes-from origin/main .gitlab-ci.yml
`)
}
_ = 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)
exit(2)
return
}
if fs.NArg() != 1 {
fs.Usage()
exit(2)
return
}
path := fs.Arg(0)
rootDir := filepath.Dir(filepath.Clean(path))
// 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)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
exit(2)
return
}
// 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)
for _, w := range warnings {
fmt.Fprintf(os.Stderr, "%s: [warning] include %s\n", path, w)
}
extWarnings, err := resolver.Resolve(p)
if err != nil {
fmt.Fprintf(os.Stderr, "error: resolving extends: %v\n", err)
exit(2)
return
}
for _, w := range extWarnings {
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)
// Wire up rules:changes: evaluation when file-change data is provided.
if *changesFrom != "" || len(changesFiles) > 0 {
var allChanged []string
reliable := len(changesFiles) > 0
if *changesFrom != "" {
files, err := gitDiffFiles(*changesFrom)
if err != nil {
fmt.Fprintf(os.Stderr, "%s: [warning] --changes-from: %v\n", path, err)
} else {
allChanged = append(allChanged, files...)
reliable = true
}
}
allChanged = append(allChanged, changesFiles...)
if reliable {
if allChanged == nil {
allChanged = []string{}
}
ctx.SetChangedFiles(allChanged)
}
}
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)
}
findings := linter.Lint(p)
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 {
fmt.Println(f)
}
}
if len(findings) == 0 {
fmt.Fprintf(summaryOut, "OK: %s — no issues found (%d job(s), %d stage(s))\n", path, len(p.Jobs), len(p.Stages))
} else {
fmt.Fprintf(summaryOut, "%d finding(s): %d error(s)\n", len(findings), errCount)
}
if errCount > 0 {
exit(1)
}
}
var knownGraphModes = map[string]bool{
"tree": true, "includes": true, "pipeline": true, "all": true,
}
func cmdGraph(args []string) {
// Optional mode word must come before any flags or the file path.
mode := "default"
if len(args) > 0 && !strings.HasPrefix(args[0], "-") && knownGraphModes[args[0]] {
mode = args[0]
args = args[1:]
}
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)
fmt.Fprint(os.Stderr, `Visualise the pipeline as a job tree and/or Mermaid graph.
Usage: glint graph [MODE] [OPTIONS] <PIPELINE>
Arguments:
[MODE] Graph mode; must appear before options [default: tree+includes]
[possible values: tree, includes, pipeline, all]
<PIPELINE> Path to the .gitlab-ci.yml file
Options:
--out <DIR>
Output directory for rendered graph files.
Used by the pipeline and all modes only. [default: glint-out]
--token <TOKEN>
GitLab personal access token. Used to fetch remote project: includes
when building the include dependency graph.
[env: GITLAB_TOKEN | CI_JOB_TOKEN | GITLAB_PRIVATE_TOKEN]
--gitlab-url <URL>
GitLab instance URL.
[env: CI_SERVER_URL | GITLAB_URL] [default: https://gitlab.com]
--branch <NAME>
Simulate a branch push. Jobs in tree output are annotated with their
evaluated state ([skipped] or [manual]; no tag means active).
Populates: CI_COMMIT_BRANCH, CI_COMMIT_REF_NAME, CI_COMMIT_REF_SLUG,
CI_PIPELINE_SOURCE=push.
[default: main]
--tag <NAME>
Simulate a tag push. Populates: CI_COMMIT_TAG, CI_COMMIT_REF_NAME,
CI_COMMIT_REF_SLUG, CI_PIPELINE_SOURCE=push. Clears CI_COMMIT_BRANCH.
--source <EVENT>
Override CI_PIPELINE_SOURCE.
[default: push] [possible values: push, merge_request_event, schedule, web, api]
--var <KEY=VALUE>
Set or override a CI variable. Repeatable.
--changes <PATH>
Mark a file path as changed for rules:changes: evaluation. Repeatable.
--changes-from <REF>
Run "git diff --name-only <REF>" to determine changed files for
rules:changes: evaluation.
--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
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:
glint graph .gitlab-ci.yml
glint graph tree .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 tree --changes src/main.go .gitlab-ci.yml
glint graph includes .gitlab-ci.yml > includes.mmd
glint graph pipeline .gitlab-ci.yml
glint graph pipeline --out /tmp/graphs .gitlab-ci.yml
glint graph all .gitlab-ci.yml > includes.mmd
`)
}
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")
listVars := fs.Bool("list-vars", false, "print all collected pipeline variables to stderr, then continue")
var vars multiFlag
fs.Var(&vars, "var", "set a CI variable as KEY=VALUE; repeatable")
var changesFiles multiFlag
fs.Var(&changesFiles, "changes", "mark a file path as changed for rules:changes: evaluation; repeatable")
changesFrom := fs.String("changes-from", "", "git ref to diff against for rules:changes: evaluation (e.g. HEAD~1, origin/main)")
_ = 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 {
fs.Usage()
exit(2)
return
}
path := fs.Arg(0)
resolvedCacheDir := *cacheDir
if *offline && resolvedCacheDir == "" {
resolvedCacheDir = defaultCacheDir()
}
cfg := fetcher.AutoConfig().WithOverrides(*gitlabURL, *token, resolvedCacheDir, *offline)
p, err := model.Parse(path)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
exit(2)
return
}
rootDir := filepath.Dir(filepath.Clean(path))
resolver.ResolveIncludes(p, cfg, rootDir) //nolint:errcheck
resolver.Resolve(p) //nolint:errcheck
ctx := cicontext.New(*branch, *tag, *source, vars)
// Wire up rules:changes: evaluation when file-change data is provided.
if *changesFrom != "" || len(changesFiles) > 0 {
var allChanged []string
reliable := len(changesFiles) > 0
if *changesFrom != "" {
files, err := gitDiffFiles(*changesFrom)
if err != nil {
fmt.Fprintf(os.Stderr, "%s: [warning] --changes-from: %v\n", path, err)
} else {
allChanged = append(allChanged, files...)
reliable = true
}
}
allChanged = append(allChanged, changesFiles...)
if reliable {
if allChanged == nil {
allChanged = []string{}
}
ctx.SetChangedFiles(allChanged)
}
}
if !ctx.IsEmpty() {
enrichContext(ctx, p)
}
if *listVars {
printVars(p, ctx)
}
switch mode {
case "default":
fmt.Print(graph.Tree(p, ctx))
fmt.Println("---")
fmt.Print(graph.Includes(path, p.Include, cfg))
case "tree":
fmt.Print(graph.Tree(p, ctx))
case "includes":
fmt.Print(graph.Includes(path, p.Include, cfg))
case "pipeline":
outPath, err := graph.RenderPipeline(p, *out)
if err != nil {
fmt.Fprintf(os.Stderr, "error: rendering pipeline graph: %v\n", err)
exit(2)
return
}
fmt.Println(outPath)
case "all":
fmt.Print(graph.Includes(path, p.Include, cfg))
outPath, err := graph.RenderPipeline(p, *out)
if err != nil {
fmt.Fprintf(os.Stderr, "error: rendering pipeline graph: %v\n", err)
exit(2)
return
}
fmt.Fprintln(os.Stderr, outPath)
}
}
// 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) {
fmt.Printf("Context: %s\n\n", ctx.Summary())
byState := map[cicontext.JobState][]string{}
for name, job := range p.Jobs {
if strings.HasPrefix(name, ".") {
continue
}
state := cicontext.EvalJob(job, ctx)
byState[state] = append(byState[state], name)
}
for _, jobs := range byState {
sort.Strings(jobs)
}
printJobGroup("Active ", byState[cicontext.JobActive])
printJobGroup("Manual ", byState[cicontext.JobManual])
printJobGroup("Skipped", byState[cicontext.JobSkipped])
fmt.Println()
}
func printJobGroup(label string, jobs []string) {
if len(jobs) == 0 {
return
}
fmt.Printf("%s (%d): %s\n", label, len(jobs), strings.Join(jobs, ", "))
}
+838
View File
@@ -0,0 +1,838 @@
package main
import (
"bytes"
"errors"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"git.k3nny.fr/glint/internal/cicontext"
"git.k3nny.fr/glint/internal/linter"
"git.k3nny.fr/glint/internal/model"
)
// captureExit replaces the exit variable with a function that records the code,
// and restores it after the test. Call the returned cleanup func in defer.
func captureExit(t *testing.T) *int {
t.Helper()
orig := exit
code := -1
exit = func(c int) { code = c }
t.Cleanup(func() { exit = orig })
return &code
}
// writePipeline writes a minimal valid pipeline file to a temp dir and returns its path.
func writePipeline(t *testing.T, content string) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, ".gitlab-ci.yml")
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
return path
}
const minimalPipeline = `
stages: [build]
build-job:
stage: build
script: make
`
// ── main() ───────────────────────────────────────────────────────────────────
func TestMain_NoArgs(t *testing.T) {
code := captureExit(t)
os.Args = []string{"glint"}
main()
if *code != 2 { t.Errorf("no args: want exit(2), got %d", *code) }
}
func TestMain_HelpFlag(t *testing.T) {
captureExit(t) // should not exit
os.Args = []string{"glint", "--help"}
main()
}
func TestMain_VersionFlag(t *testing.T) {
captureExit(t)
os.Args = []string{"glint", "--version"}
main()
}
func TestMain_HelpAlias(t *testing.T) {
captureExit(t)
os.Args = []string{"glint", "help"}
main()
}
func TestMain_VersionAlias(t *testing.T) {
captureExit(t)
os.Args = []string{"glint", "version"}
main()
}
func TestMain_UnknownCommand(t *testing.T) {
code := captureExit(t)
os.Args = []string{"glint", "badcmd"}
main()
if *code != 2 { t.Errorf("unknown cmd: want exit(2), got %d", *code) }
}
// ── cmdCheck ─────────────────────────────────────────────────────────────────
func TestCmdCheck_ValidPipeline(t *testing.T) {
code := captureExit(t)
path := writePipeline(t, minimalPipeline)
os.Args = []string{"glint", "check", path}
cmdCheck([]string{path})
if *code != -1 { t.Errorf("valid pipeline: want no exit, got %d", *code) }
}
func TestCmdCheck_NoArgs(t *testing.T) {
code := captureExit(t)
cmdCheck([]string{})
if *code != 2 { t.Errorf("no args: want exit(2), got %d", *code) }
}
func TestCmdCheck_InvalidFormat(t *testing.T) {
code := captureExit(t)
path := writePipeline(t, minimalPipeline)
cmdCheck([]string{"--format", "badformat", path})
if *code != 2 { t.Errorf("bad format: want exit(2), got %d", *code) }
}
func TestCmdCheck_MissingFile(t *testing.T) {
code := captureExit(t)
cmdCheck([]string{"/nonexistent/pipeline.yml"})
if *code != 2 { t.Errorf("missing file: want exit(2), got %d", *code) }
}
func TestCmdCheck_WithErrors_ExitsOne(t *testing.T) {
code := captureExit(t)
// Pipeline with an error finding (invalid stage reference)
content := `
stages: [build]
test-job:
stage: nonexistent
script: echo
`
path := writePipeline(t, content)
cmdCheck([]string{path})
if *code != 1 { t.Errorf("pipeline with errors: want exit(1), got %d", *code) }
}
func TestCmdCheck_FormatJSON(t *testing.T) {
captureExit(t)
path := writePipeline(t, minimalPipeline)
cmdCheck([]string{"--format", "json", path})
}
func TestCmdCheck_FormatSARIF(t *testing.T) {
captureExit(t)
path := writePipeline(t, minimalPipeline)
cmdCheck([]string{"--format", "sarif", path})
}
func TestCmdCheck_FormatJUnit(t *testing.T) {
captureExit(t)
path := writePipeline(t, minimalPipeline)
cmdCheck([]string{"--format", "junit", path})
}
func TestCmdCheck_FormatGitHub(t *testing.T) {
captureExit(t)
path := writePipeline(t, minimalPipeline)
cmdCheck([]string{"--format", "github", path})
}
func TestCmdCheck_WithBranch(t *testing.T) {
captureExit(t)
path := writePipeline(t, minimalPipeline)
cmdCheck([]string{"--branch", "develop", path})
}
func TestCmdCheck_WithTag(t *testing.T) {
captureExit(t)
path := writePipeline(t, minimalPipeline)
cmdCheck([]string{"--tag", "v1.0.0", path})
}
func TestCmdCheck_WithSource(t *testing.T) {
captureExit(t)
path := writePipeline(t, minimalPipeline)
cmdCheck([]string{"--source", "schedule", path})
}
func TestCmdCheck_WithVar(t *testing.T) {
captureExit(t)
path := writePipeline(t, minimalPipeline)
cmdCheck([]string{"--var", "MY_VAR=hello", path})
}
func TestCmdCheck_ListVars(t *testing.T) {
captureExit(t)
content := `
stages: [build]
variables:
MY_VAR: hello
build-job:
stage: build
script: echo
`
path := writePipeline(t, content)
cmdCheck([]string{"--list-vars", "--branch", "main", path})
}
func TestCmdCheck_WithOffline(t *testing.T) {
captureExit(t)
path := writePipeline(t, minimalPipeline)
cmdCheck([]string{"--offline", path})
}
func TestCmdCheck_WorkflowRulesExclude(t *testing.T) {
captureExit(t)
content := `
stages: [build]
workflow:
rules:
- if: '$CI_COMMIT_TAG'
when: always
- when: never
build-job:
stage: build
script: echo
`
path := writePipeline(t, content)
// branch push — workflow rule won't match a tag, so pipeline won't start
cmdCheck([]string{"--branch", "main", path})
}
// ── cmdGraph ─────────────────────────────────────────────────────────────────
func TestCmdGraph_NoArgs(t *testing.T) {
code := captureExit(t)
cmdGraph([]string{})
if *code != 2 { t.Errorf("no args: want exit(2), got %d", *code) }
}
func TestCmdGraph_Tree(t *testing.T) {
captureExit(t)
path := writePipeline(t, minimalPipeline)
cmdGraph([]string{"tree", path})
}
func TestCmdGraph_Includes(t *testing.T) {
captureExit(t)
path := writePipeline(t, minimalPipeline)
cmdGraph([]string{"includes", path})
}
func TestCmdGraph_Default(t *testing.T) {
captureExit(t)
path := writePipeline(t, minimalPipeline)
cmdGraph([]string{path})
}
func TestCmdGraph_Pipeline(t *testing.T) {
captureExit(t)
path := writePipeline(t, minimalPipeline)
outDir := t.TempDir()
cmdGraph([]string{"pipeline", "--out", outDir, path})
}
func TestCmdGraph_All(t *testing.T) {
captureExit(t)
path := writePipeline(t, minimalPipeline)
outDir := t.TempDir()
cmdGraph([]string{"all", "--out", outDir, path})
}
func TestCmdGraph_MissingFile(t *testing.T) {
code := captureExit(t)
cmdGraph([]string{"/nonexistent.yml"})
if *code != 2 { t.Errorf("missing file: want exit(2), got %d", *code) }
}
func TestCmdGraph_WithBranch(t *testing.T) {
captureExit(t)
path := writePipeline(t, minimalPipeline)
cmdGraph([]string{"tree", "--branch", "develop", path})
}
func TestCmdGraph_WithTag(t *testing.T) {
captureExit(t)
path := writePipeline(t, minimalPipeline)
cmdGraph([]string{"tree", "--tag", "v1.0.0", path})
}
func TestCmdGraph_WithListVars(t *testing.T) {
captureExit(t)
path := writePipeline(t, minimalPipeline)
cmdGraph([]string{"tree", "--list-vars", "--branch", "main", path})
}
func TestCmdGraph_Offline(t *testing.T) {
captureExit(t)
path := writePipeline(t, minimalPipeline)
cmdGraph([]string{"tree", "--offline", path})
}
// ── cmdExplain ────────────────────────────────────────────────────────────────
func TestCmdExplain_NoArgs_ListsRules(t *testing.T) {
captureExit(t)
cmdExplain([]string{})
}
func TestCmdExplain_ValidRule(t *testing.T) {
captureExit(t)
cmdExplain([]string{"GL001"})
}
func TestCmdExplain_UnknownRule(t *testing.T) {
code := captureExit(t)
cmdExplain([]string{"GL999"})
if *code != 2 { t.Errorf("unknown rule: want exit(2), got %d", *code) }
}
func TestCmdExplain_LowercaseRule(t *testing.T) {
captureExit(t)
// lowercase should work (converted to upper inside)
cmdExplain([]string{"gl001"})
}
// ── defaultCacheDir ───────────────────────────────────────────────────────────
func TestDefaultCacheDir(t *testing.T) {
// Should return a non-empty string (either XDG or ~/.cache/glint)
got := defaultCacheDir()
if got == "" { t.Error("expected non-empty cache dir") }
}
func TestDefaultCacheDir_XDG(t *testing.T) {
orig := os.Getenv("XDG_CACHE_HOME")
os.Setenv("XDG_CACHE_HOME", "/tmp/xdg-cache")
defer os.Setenv("XDG_CACHE_HOME", orig)
got := defaultCacheDir()
if got != "/tmp/xdg-cache/glint" {
t.Errorf("XDG_CACHE_HOME: got %q want /tmp/xdg-cache/glint", got)
}
}
// ── multiFlag ────────────────────────────────────────────────────────────────
func TestMultiFlag(t *testing.T) {
var f multiFlag
if f.String() != "" { t.Error("empty: expected empty string") }
if err := f.Set("a"); err != nil { t.Fatal(err) }
if err := f.Set("b"); err != nil { t.Fatal(err) }
if f.String() != "a, b" { t.Errorf("got %q", f.String()) }
}
// ── printVars ─────────────────────────────────────────────────────────────────
func TestPrintVars(t *testing.T) {
p := &model.Pipeline{
Variables: map[string]any{"KEY": "val", "OTHER": map[string]any{"value": "v2"}},
Workflow: &model.Workflow{
Rules: []model.Rule{{Variables: map[string]any{"RULE_VAR": "x"}}},
},
}
ctx := cicontext.New("main", "", "", nil)
printVars(p, ctx) // should not panic
printVars(p, nil) // nil ctx: no context vars printed
}
func TestPrintVarMap(t *testing.T) {
printVarMap(nil) // nil map: prints (none)
printVarMap(map[string]any{}) // empty: prints (none)
printVarMap(map[string]any{"A": "1", "B": map[string]any{"value": "2"}})
printVarMap(map[string]any{"C": 42}) // non-scalar: (complex)
}
func TestVarValueString(t *testing.T) {
if varValueString("hello") != "hello" { t.Error("string") }
if varValueString(map[string]any{"value": "v"}) != "v" { t.Error("map with value") }
if varValueString(map[string]any{"other": "x"}) != "(complex)" { t.Error("complex") }
}
// ── enrichContext ─────────────────────────────────────────────────────────────
func TestEnrichContext(t *testing.T) {
p := &model.Pipeline{
Variables: map[string]any{"ENV": "prod"},
}
ctx := cicontext.New("main", "", "", nil)
runs := enrichContext(ctx, p)
if !runs { t.Error("expected pipeline to run on main branch with no workflow rules") }
if ctx.Get("ENV") != "prod" { t.Error("pipeline var should be injected") }
}
// ── printContext ──────────────────────────────────────────────────────────────
func TestPrintContext(t *testing.T) {
p := &model.Pipeline{
Jobs: map[string]model.Job{
"active-job": {Name: "active-job", Script: []any{"echo"}, Stage: "build"},
"manual-job": {Name: "manual-job", Script: []any{"echo"}, When: "manual"},
"skipped-job": {Name: "skipped-job", Rules: []model.Rule{{If: `$CI_COMMIT_TAG != ""`, When: "on_success"}}},
".hidden": {Name: ".hidden"},
},
}
ctx := cicontext.New("main", "", "", nil)
printContext(p, ctx) // should not panic
}
// ── printJobGroup ─────────────────────────────────────────────────────────────
func TestPrintJobGroup(t *testing.T) {
printJobGroup("Active ", []string{"a", "b"}) // should not panic
printJobGroup("Empty ", []string{}) // empty: no output
}
// ── main() switch cases ───────────────────────────────────────────────────────
func TestMain_Check(t *testing.T) {
captureExit(t)
path := writePipeline(t, minimalPipeline)
os.Args = []string{"glint", "check", path}
main()
}
func TestMain_Graph(t *testing.T) {
captureExit(t)
path := writePipeline(t, minimalPipeline)
os.Args = []string{"glint", "graph", path}
main()
}
func TestMain_Explain(t *testing.T) {
captureExit(t)
os.Args = []string{"glint", "explain", "GL001"}
main()
}
// ── defaultCacheDir — UserHomeDir failure ─────────────────────────────────────
func TestDefaultCacheDir_HomeDirFails(t *testing.T) {
orig := userHomeDirFn
userHomeDirFn = func() (string, error) { return "", errors.New("no home") }
t.Cleanup(func() { userHomeDirFn = orig })
origXDG := os.Getenv("XDG_CACHE_HOME")
os.Unsetenv("XDG_CACHE_HOME")
t.Cleanup(func() { os.Setenv("XDG_CACHE_HOME", origXDG) })
got := defaultCacheDir()
if got != "" {
t.Errorf("expected empty string when UserHomeDir fails, got %q", got)
}
}
// ── cmdCheck — config load error ──────────────────────────────────────────────
func TestCmdCheck_ConfigError(t *testing.T) {
captureExit(t)
dir := t.TempDir()
path := filepath.Join(dir, ".gitlab-ci.yml")
if err := os.WriteFile(path, []byte(minimalPipeline), 0o644); err != nil {
t.Fatal(err)
}
// Create an unreadable .glint.yml — config.Load returns a non-NotExist error.
cfgPath := filepath.Join(dir, ".glint.yml")
if err := os.WriteFile(cfgPath, []byte("ignore: []"), 0o000); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.Chmod(cfgPath, 0o644) }) // allow cleanup
cmdCheck([]string{path})
// Warning is emitted to stderr; linting still proceeds, so no exit(2).
}
// ── cmdCheck — glintCfg.Stages ────────────────────────────────────────────────
func TestCmdCheck_ConfigStages(t *testing.T) {
captureExit(t)
dir := t.TempDir()
path := filepath.Join(dir, ".gitlab-ci.yml")
if err := os.WriteFile(path, []byte(minimalPipeline), 0o644); err != nil {
t.Fatal(err)
}
// Write a .glint.yml that declares an extra stage.
glintCfg := "stages:\n - extra-stage\n"
if err := os.WriteFile(filepath.Join(dir, ".glint.yml"), []byte(glintCfg), 0o644); err != nil {
t.Fatal(err)
}
cmdCheck([]string{path})
}
// ── cmdCheck — ResolveIncludes warnings ──────────────────────────────────────
func TestCmdCheck_IncludeWarning(t *testing.T) {
captureExit(t)
// Clear all token env vars so the project include is skipped (no token).
for _, k := range []string{"GITLAB_TOKEN", "CI_JOB_TOKEN", "GITLAB_PRIVATE_TOKEN"} {
t.Setenv(k, "")
}
content := `
stages: [build]
include:
- project: some/group/project
ref: main
file: template.yml
build-job:
stage: build
script: echo
`
path := writePipeline(t, content)
cmdCheck([]string{path})
// warning printed to stderr; pipeline still lints cleanly → no exit
}
// ── cmdCheck — resolver.Resolve error (cycle) ─────────────────────────────────
func TestCmdCheck_ResolveCycle(t *testing.T) {
code := captureExit(t)
content := `
stages: [build]
.base:
script: echo base
extends: .child
.child:
script: echo child
extends: .base
real-job:
stage: build
script: echo
`
path := writePipeline(t, content)
cmdCheck([]string{path})
if *code != 2 {
t.Errorf("cycle in extends: want exit(2), got %d", *code)
}
}
// ── cmdCheck — extends unknown base (extWarnings) ────────────────────────────
func TestCmdCheck_ExtendsWarning(t *testing.T) {
captureExit(t)
content := `
stages: [build]
build-job:
stage: build
extends: .nonexistent-base
script: echo
`
path := writePipeline(t, content)
cmdCheck([]string{path})
// Warning is printed; exit code is not 2 (extends warning is non-fatal).
}
// ── cmdGraph — RenderPipeline errors ─────────────────────────────────────────
func TestCmdGraph_PipelineError(t *testing.T) {
code := captureExit(t)
path := writePipeline(t, minimalPipeline)
// Create a regular file at the outDir path so os.MkdirAll fails.
outFile := filepath.Join(t.TempDir(), "not-a-dir")
if err := os.WriteFile(outFile, []byte{}, 0o644); err != nil {
t.Fatal(err)
}
cmdGraph([]string{"pipeline", "--out", outFile, path})
if *code != 2 {
t.Errorf("pipeline outDir-is-file: want exit(2), got %d", *code)
}
}
func TestCmdGraph_AllError(t *testing.T) {
code := captureExit(t)
path := writePipeline(t, minimalPipeline)
outFile := filepath.Join(t.TempDir(), "not-a-dir")
if err := os.WriteFile(outFile, []byte{}, 0o644); err != nil {
t.Fatal(err)
}
cmdGraph([]string{"all", "--out", outFile, path})
if *code != 2 {
t.Errorf("all outDir-is-file: want exit(2), got %d", *code)
}
}
// ── enrichContext — workflow rule variables ───────────────────────────────────
func TestEnrichContext_WorkflowVars(t *testing.T) {
p := &model.Pipeline{
Workflow: &model.Workflow{
Rules: []model.Rule{{
// No If: → always matches; Variables are injected into ctx.
When: "always",
Variables: map[string]any{"DEPLOY_ENV": "production"},
}},
},
}
ctx := cicontext.New("main", "", "", nil)
runs := enrichContext(ctx, p)
if !runs {
t.Error("expected pipeline to run with unconditional workflow rule")
}
if ctx.Get("DEPLOY_ENV") != "production" {
t.Errorf("expected DEPLOY_ENV=production, got %q", ctx.Get("DEPLOY_ENV"))
}
}
// ── writeJUnit — empty Rule and File ─────────────────────────────────────────
func TestWriteJUnit_EmptyRuleAndFile(t *testing.T) {
var buf bytes.Buffer
// Rule == "" → name falls back to "lint" (format.go:231-233).
// File == "" → classname falls back to pipeline arg (format.go:235-237).
writeJUnit(&buf, []linter.Finding{
{Severity: linter.Error, Rule: "", File: "", Message: "something went wrong"},
}, "my-pipeline.yml")
out := buf.String()
if !strings.Contains(out, "lint") {
t.Errorf("expected 'lint' as testcase name when Rule is empty; got:\n%s", out)
}
if !strings.Contains(out, "my-pipeline.yml") {
t.Errorf("expected pipeline path as classname when File is empty; got:\n%s", out)
}
}
// ── execCommandOutput (default implementation) ────────────────────────────────
func TestExecCommandOutput_Default(t *testing.T) {
// Call the default implementation directly (without mocking) to cover the
// closure body in main.go.
orig := execCommandOutput
execCommandOutput = func(name string, args ...string) ([]byte, error) {
return exec.Command(name, args...).Output()
}
t.Cleanup(func() { execCommandOutput = orig })
out, err := execCommandOutput("echo", "hello")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(string(out), "hello") {
t.Errorf("unexpected output: %q", string(out))
}
}
// ── gitDiffFiles ─────────────────────────────────────────────────────────────
func TestGitDiffFiles_Success(t *testing.T) {
orig := execCommandOutput
execCommandOutput = func(name string, args ...string) ([]byte, error) {
return []byte("src/main.go\nDockerfile\n"), nil
}
t.Cleanup(func() { execCommandOutput = orig })
files, err := gitDiffFiles("origin/main")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(files) != 2 || files[0] != "src/main.go" || files[1] != "Dockerfile" {
t.Errorf("unexpected files: %v", files)
}
}
func TestGitDiffFiles_Error(t *testing.T) {
orig := execCommandOutput
execCommandOutput = func(name string, args ...string) ([]byte, error) {
return nil, errors.New("not a git repository")
}
t.Cleanup(func() { execCommandOutput = orig })
files, err := gitDiffFiles("HEAD~1")
if err == nil {
t.Fatal("expected error")
}
if files != nil {
t.Errorf("expected nil files on error, got %v", files)
}
}
func TestGitDiffFiles_EmptyOutput(t *testing.T) {
orig := execCommandOutput
execCommandOutput = func(name string, args ...string) ([]byte, error) {
return []byte(""), nil
}
t.Cleanup(func() { execCommandOutput = orig })
files, err := gitDiffFiles("HEAD~1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Empty output → nil slice (no lines passed the filter)
if files != nil {
t.Errorf("expected nil (no files), got %v", files)
}
}
// ── cmdCheck --changes / --changes-from ──────────────────────────────────────
func TestCmdCheck_ChangesFlag(t *testing.T) {
code := captureExit(t)
content := `stages: [build]
build-job:
stage: build
script: echo
rules:
- changes: [src/**/*.go]
when: on_success
`
path := writePipeline(t, content)
// With --changes src/main.go: the rule fires → active job, no error
cmdCheck([]string{"--changes", "src/main.go", path})
if *code != -1 {
t.Errorf("expected clean exit with matching --changes, got %d", *code)
}
}
func TestCmdCheck_ChangesFrom_Fails(t *testing.T) {
// When --changes-from fails (not a git repo / bad ref), a warning is emitted
// and the check continues normally (permissive behaviour).
orig := execCommandOutput
execCommandOutput = func(name string, args ...string) ([]byte, error) {
return nil, errors.New("fatal: not a git repository")
}
t.Cleanup(func() { execCommandOutput = orig })
code := captureExit(t)
path := writePipeline(t, minimalPipeline)
// Should warn but not crash; pipeline is clean → no exit(1).
cmdCheck([]string{"--changes-from", "origin/main", path})
if *code == 1 {
t.Errorf("expected no exit(1) when --changes-from fails gracefully, got %d", *code)
}
}
func TestCmdCheck_ChangesFrom_Success(t *testing.T) {
orig := execCommandOutput
execCommandOutput = func(name string, args ...string) ([]byte, error) {
return []byte("src/main.go\n"), nil
}
t.Cleanup(func() { execCommandOutput = orig })
code := captureExit(t)
content := `stages: [build]
build-job:
stage: build
script: echo
rules:
- changes: [src/**]
when: on_success
`
path := writePipeline(t, content)
cmdCheck([]string{"--changes-from", "origin/main", path})
if *code != -1 {
t.Errorf("expected clean exit, got %d", *code)
}
}
func TestCmdCheck_ChangesFrom_EmptyDiff(t *testing.T) {
// --changes-from succeeds but returns no files (nothing changed).
// reliable=true, allChanged stays nil → allChanged = []string{} branch is hit.
orig := execCommandOutput
execCommandOutput = func(name string, args ...string) ([]byte, error) {
return []byte(""), nil // empty output → no changed files
}
t.Cleanup(func() { execCommandOutput = orig })
code := captureExit(t)
content := `stages: [build]
build-job:
stage: build
script: echo
rules:
- changes: [src/**]
when: on_success
`
path := writePipeline(t, content)
// build-job's rule fires only if src/** matches; with 0 changed files it is skipped.
// Pipeline is clean (no lint errors) → no exit(1).
cmdCheck([]string{"--changes-from", "origin/main", path})
if *code == 1 {
t.Errorf("unexpected exit(1): %d", *code)
}
}
func TestCmdGraph_ChangesFrom_Fails(t *testing.T) {
orig := execCommandOutput
execCommandOutput = func(name string, args ...string) ([]byte, error) {
return nil, errors.New("not a git repository")
}
t.Cleanup(func() { execCommandOutput = orig })
code := captureExit(t)
path := writePipeline(t, minimalPipeline)
cmdGraph([]string{"tree", "--changes-from", "origin/main", path})
if *code == 1 {
t.Errorf("unexpected exit(1) when --changes-from fails in graph mode")
}
}
func TestCmdGraph_ChangesFrom_Success(t *testing.T) {
orig := execCommandOutput
execCommandOutput = func(name string, args ...string) ([]byte, error) {
return []byte("src/app.go\n"), nil
}
t.Cleanup(func() { execCommandOutput = orig })
code := captureExit(t)
path := writePipeline(t, minimalPipeline)
cmdGraph([]string{"tree", "--changes-from", "origin/main", path})
if *code == 1 {
t.Errorf("unexpected exit(1) in graph --changes-from success path")
}
}
func TestCmdGraph_ChangesFrom_EmptyDiff(t *testing.T) {
orig := execCommandOutput
execCommandOutput = func(name string, args ...string) ([]byte, error) {
return []byte(""), nil
}
t.Cleanup(func() { execCommandOutput = orig })
code := captureExit(t)
path := writePipeline(t, minimalPipeline)
// reliable=true, allChanged nil → allChanged = []string{} branch hit
cmdGraph([]string{"tree", "--changes-from", "origin/main", path})
if *code == 1 {
t.Errorf("unexpected exit(1) in graph --changes-from empty diff")
}
}
func TestCmdGraph_ChangesFlag(t *testing.T) {
code := captureExit(t)
content := `stages: [build]
build-job:
stage: build
script: echo
rules:
- changes: [src/**]
when: on_success
`
path := writePipeline(t, content)
cmdGraph([]string{"tree", "--changes", "src/app.go", path})
if *code == 1 {
t.Errorf("unexpected exit(1) with valid pipeline and --changes flag")
}
}
// ── isSuppressed ─────────────────────────────────────────────────────────────
func TestIsSuppressed(t *testing.T) {
suppressions := map[string][]string{
"build-job": {"GL001", "GL002"},
"all-job": {"*"},
}
if isSuppressed("", "GL001", suppressions) { t.Error("empty job: not suppressed") }
if isSuppressed("build-job", "GL003", suppressions) { t.Error("GL003 not in list") }
if !isSuppressed("build-job", "GL001", suppressions) { t.Error("GL001 should be suppressed") }
if !isSuppressed("all-job", "GL042", suppressions) { t.Error("wildcard should suppress") }
if isSuppressed("unknown-job", "GL001", suppressions) { t.Error("unknown job: not suppressed") }
}
+12 -2
View File
@@ -1,5 +1,15 @@
module git.k3nny.fr/gitlab-sim module git.k3nny.fr/glint
go 1.26.4 go 1.26.4
require gopkg.in/yaml.v3 v3.0.1 // indirect require (
github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c // indirect
golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 // indirect
golang.org/x/mod v0.31.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/tools v0.40.1-0.20260108161641-ca281cf95054 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
honnef.co/go/tools v0.7.0 // indirect
)
tool honnef.co/go/tools/cmd/staticcheck
+13
View File
@@ -1,3 +1,16 @@
github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs=
github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ=
golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 h1:1P7xPZEwZMoBoz0Yze5Nx2/4pxj6nw9ZqHWXqP0iRgQ=
golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/tools v0.40.1-0.20260108161641-ca281cf95054 h1:CHVDrNHx9ZoOrNN9kKWYIbT5Rj+WF2rlwPkhbQQ5V4U=
golang.org/x/tools v0.40.1-0.20260108161641-ca281cf95054/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU=
honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc=
+291
View File
@@ -0,0 +1,291 @@
package cicontext
import (
"testing"
"git.k3nny.fr/glint/internal/model"
)
// ── doublestarMatch ───────────────────────────────────────────────────────────
func TestDoublestarMatch(t *testing.T) {
cases := []struct {
pattern string
path string
want bool
}{
// exact match
{"Dockerfile", "Dockerfile", true},
{"Dockerfile", "dockerfile", false},
// single-segment wildcard
{"*.go", "main.go", true},
{"*.go", "main.py", false},
// * does not cross /
{"*.go", "src/main.go", false},
// ** matches multiple segments
{"**/*.go", "src/main.go", true},
{"**/*.go", "src/pkg/util.go", true},
{"**/*.go", "src/main.py", false},
// ** at end matches everything
{"src/**", "src/main.go", true},
{"src/**", "src/a/b/c.go", true},
{"src/**", "other/main.go", false},
// ** in the middle
{"src/**/*.go", "src/pkg/main.go", true},
{"src/**/*.go", "src/a/b/main.go", true},
{"src/**/*.go", "test/pkg/main.go", false},
// bare ** matches everything
{"**", "anything/and/everything.txt", true},
{"**", "file.go", true},
// no wildcard, multi-segment
{"src/main.go", "src/main.go", true},
{"src/main.go", "src/other.go", false},
// ** matches zero segments too
{"src/**/main.go", "src/main.go", true},
{"src/**/main.go", "src/pkg/main.go", true},
// malformed pattern (gracefully handled)
{"[invalid", "file.go", false},
}
for _, tc := range cases {
t.Run(tc.pattern+"~"+tc.path, func(t *testing.T) {
got := doublestarMatch(tc.pattern, tc.path)
if got != tc.want {
t.Errorf("doublestarMatch(%q, %q) = %v; want %v", tc.pattern, tc.path, got, tc.want)
}
})
}
}
// ── extractChangesPaths ───────────────────────────────────────────────────────
func TestExtractChangesPaths(t *testing.T) {
t.Run("nil → nil", func(t *testing.T) {
if extractChangesPaths(nil) != nil {
t.Error("expected nil")
}
})
t.Run("string", func(t *testing.T) {
got := extractChangesPaths("Dockerfile")
if len(got) != 1 || got[0] != "Dockerfile" {
t.Errorf("got %v", got)
}
})
t.Run("[]string", func(t *testing.T) {
got := extractChangesPaths([]string{"a.go", "b.go"})
if len(got) != 2 || got[0] != "a.go" || got[1] != "b.go" {
t.Errorf("got %v", got)
}
})
t.Run("[]any strings", func(t *testing.T) {
got := extractChangesPaths([]any{"src/**", "*.yml"})
if len(got) != 2 || got[0] != "src/**" || got[1] != "*.yml" {
t.Errorf("got %v", got)
}
})
t.Run("[]any ignores non-strings", func(t *testing.T) {
got := extractChangesPaths([]any{"ok", 42})
if len(got) != 1 || got[0] != "ok" {
t.Errorf("got %v", got)
}
})
t.Run("map with paths key", func(t *testing.T) {
got := extractChangesPaths(map[string]any{
"paths": []any{"src/**"},
"compare_to": "origin/main",
})
if len(got) != 1 || got[0] != "src/**" {
t.Errorf("got %v", got)
}
})
t.Run("map without paths key → nil", func(t *testing.T) {
got := extractChangesPaths(map[string]any{"compare_to": "origin/main"})
if got != nil {
t.Errorf("expected nil, got %v", got)
}
})
t.Run("unknown type → nil", func(t *testing.T) {
got := extractChangesPaths(12345)
if got != nil {
t.Errorf("expected nil, got %v", got)
}
})
}
// ── changesMatch ──────────────────────────────────────────────────────────────
func TestChangesMatch(t *testing.T) {
t.Run("nil changes → always true", func(t *testing.T) {
ctx := New("main", "", "", nil)
ctx.SetChangedFiles([]string{"anything.go"})
if !changesMatch(nil, ctx) {
t.Error("nil changes should always match")
}
})
t.Run("nil changedFiles → permissive true", func(t *testing.T) {
ctx := New("main", "", "", nil)
// changedFiles is nil by default → permissive
if !changesMatch([]any{"src/**"}, ctx) {
t.Error("nil changedFiles should be permissive (true)")
}
})
t.Run("no match → false", func(t *testing.T) {
ctx := New("main", "", "", nil)
ctx.SetChangedFiles([]string{"test/main_test.go"})
if changesMatch([]any{"src/**/*.go"}, ctx) {
t.Error("should not match: changed file not under src/")
}
})
t.Run("match → true", func(t *testing.T) {
ctx := New("main", "", "", nil)
ctx.SetChangedFiles([]string{"src/pkg/main.go"})
if !changesMatch([]any{"src/**/*.go"}, ctx) {
t.Error("should match: src/pkg/main.go satisfies src/**/*.go")
}
})
t.Run("empty changedFiles + non-nil changes → false", func(t *testing.T) {
ctx := New("main", "", "", nil)
ctx.SetChangedFiles([]string{}) // explicit empty list
if changesMatch([]any{"Dockerfile"}, ctx) {
t.Error("empty file list with active filter should be false")
}
})
t.Run("one of many changed files matches", func(t *testing.T) {
ctx := New("main", "", "", nil)
ctx.SetChangedFiles([]string{"README.md", "src/main.go", "go.sum"})
if !changesMatch([]any{"src/**"}, ctx) {
t.Error("src/main.go should satisfy src/**")
}
})
}
// ── EvalJob with rules:changes: ───────────────────────────────────────────────
func TestEvalJob_RulesChanges(t *testing.T) {
makeJob := func(rules []model.Rule) model.Job {
return model.Job{Name: "job", Script: []any{"echo"}, Rules: rules}
}
t.Run("no changed files (permissive) — rule fires", func(t *testing.T) {
ctx := New("main", "", "", nil)
// changedFiles nil → permissive
job := makeJob([]model.Rule{{Changes: []any{"src/**/*.go"}, When: "on_success"}})
if EvalJob(job, ctx) != JobActive {
t.Error("expected active: permissive when no file list")
}
})
t.Run("matching changed file — rule fires", func(t *testing.T) {
ctx := New("main", "", "", nil)
ctx.SetChangedFiles([]string{"src/main.go"})
job := makeJob([]model.Rule{{Changes: []any{"src/**"}, When: "on_success"}})
if EvalJob(job, ctx) != JobActive {
t.Error("expected active: src/main.go matches src/**")
}
})
t.Run("no matching file — rule skipped, no other rule → skipped", func(t *testing.T) {
ctx := New("main", "", "", nil)
ctx.SetChangedFiles([]string{"docs/README.md"})
job := makeJob([]model.Rule{{Changes: []any{"src/**"}, When: "on_success"}})
if EvalJob(job, ctx) != JobSkipped {
t.Error("expected skipped: docs/README.md does not match src/**")
}
})
t.Run("changes filter with if: both must pass", func(t *testing.T) {
ctx := New("main", "", "", nil)
ctx.SetChangedFiles([]string{"src/main.go"})
job := makeJob([]model.Rule{
{If: `$CI_COMMIT_BRANCH == "develop"`, Changes: []any{"src/**"}, When: "on_success"},
})
// if: fails → rule skipped even though changes match
if EvalJob(job, ctx) != JobSkipped {
t.Error("expected skipped: if: condition fails")
}
})
t.Run("map form changes: {paths: [...]}", func(t *testing.T) {
ctx := New("main", "", "", nil)
ctx.SetChangedFiles([]string{"Dockerfile"})
job := makeJob([]model.Rule{{
Changes: map[string]any{"paths": []any{"Dockerfile"}, "compare_to": "origin/main"},
When: "on_success",
}})
if EvalJob(job, ctx) != JobActive {
t.Error("expected active: map form changes should work")
}
})
}
// ── EvalWorkflow with rules:changes: ─────────────────────────────────────────
func TestEvalWorkflow_Changes(t *testing.T) {
makePipeline := func(rules []model.Rule) *model.Pipeline {
return &model.Pipeline{Workflow: &model.Workflow{Rules: rules}}
}
t.Run("no changedFiles (permissive) → pipeline runs", func(t *testing.T) {
ctx := New("main", "", "", nil)
p := makePipeline([]model.Rule{{Changes: []any{"src/**"}, When: "always"}})
runs, _ := EvalWorkflow(p, ctx)
if !runs {
t.Error("expected pipeline to run: permissive when no file list")
}
})
t.Run("matching file → pipeline runs", func(t *testing.T) {
ctx := New("main", "", "", nil)
ctx.SetChangedFiles([]string{"src/app.go"})
p := makePipeline([]model.Rule{{Changes: []any{"src/**"}, When: "always"}})
runs, _ := EvalWorkflow(p, ctx)
if !runs {
t.Error("expected pipeline to run: src/app.go matches src/**")
}
})
t.Run("no matching file → no rule matches → pipeline blocked", func(t *testing.T) {
ctx := New("main", "", "", nil)
ctx.SetChangedFiles([]string{"docs/README.md"})
p := makePipeline([]model.Rule{{Changes: []any{"src/**"}, When: "always"}})
runs, _ := EvalWorkflow(p, ctx)
if runs {
t.Error("expected pipeline blocked: docs/README.md does not match src/**")
}
})
}
// ── SetChangedFiles / Summary ─────────────────────────────────────────────────
func TestSetChangedFiles(t *testing.T) {
ctx := New("main", "", "", nil)
if ctx.changedFiles != nil {
t.Error("changedFiles should be nil initially")
}
ctx.SetChangedFiles([]string{"a.go", "b.go"})
if len(ctx.changedFiles) != 2 {
t.Errorf("expected 2 changed files, got %d", len(ctx.changedFiles))
}
// nil receiver should not panic
var nilCtx *Context
nilCtx.SetChangedFiles([]string{"x"})
}
func TestSummary_WithChangedFiles(t *testing.T) {
ctx := New("main", "", "", nil)
// Without changed files
if s := ctx.Summary(); s != "branch=main, source=push" {
t.Errorf("unexpected summary without changes: %q", s)
}
// With changed files
ctx.SetChangedFiles([]string{"a.go", "b.go"})
s := ctx.Summary()
if s != "branch=main, source=push, 2 changed file(s)" {
t.Errorf("unexpected summary with changes: %q", s)
}
// With empty changed files (strict, 0 files)
ctx.SetChangedFiles([]string{})
s = ctx.Summary()
if s != "branch=main, source=push, 0 changed file(s)" {
t.Errorf("unexpected summary with 0 changes: %q", s)
}
}
+183 -12
View File
@@ -1,12 +1,17 @@
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
changedFiles []string // nil = not provided (permissive); non-nil = known set of changed files
} }
// 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 +22,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 +87,33 @@ 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
}
// SetChangedFiles records the list of files that changed for rules:changes:
// evaluation. A non-nil slice (even empty) enables strict matching: only jobs
// whose rules:changes: patterns match at least one file in the list will have
// that rule fire. A nil slice (the default) means "not provided" — rules:changes:
// conditions are treated as always satisfied (permissive), preserving the
// behaviour when no changed-file data is available.
func (c *Context) SetChangedFiles(files []string) {
if c != nil {
c.changedFiles = files
}
}
// 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() {
@@ -87,9 +128,139 @@ func (c *Context) Summary() string {
if v := c.Get("CI_PIPELINE_SOURCE"); v != "" { if v := c.Get("CI_PIPELINE_SOURCE"); v != "" {
parts = append(parts, "source="+v) parts = append(parts, "source="+v)
} }
if c.changedFiles != nil {
parts = append(parts, fmt.Sprintf("%d changed file(s)", len(c.changedFiles)))
}
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 {
+253
View File
@@ -0,0 +1,253 @@
package cicontext
import (
"testing"
)
// ── New / IsEmpty / Get / Inject ──────────────────────────────────────────────
func TestNew_Empty(t *testing.T) {
ctx := New("", "", "", nil)
if !ctx.IsEmpty() {
t.Error("expected empty context")
}
if ctx.Get("CI_COMMIT_BRANCH") != "" {
t.Error("expected empty Get on empty context")
}
}
func TestNew_Branch(t *testing.T) {
ctx := New("feature/abc", "", "", nil)
if ctx.Get("CI_COMMIT_BRANCH") != "feature/abc" {
t.Errorf("unexpected branch: %q", ctx.Get("CI_COMMIT_BRANCH"))
}
if ctx.Get("CI_COMMIT_REF_SLUG") != "feature-abc" {
t.Errorf("unexpected slug: %q", ctx.Get("CI_COMMIT_REF_SLUG"))
}
if ctx.Get("CI_PIPELINE_SOURCE") != "push" {
t.Error("expected default source=push for branch")
}
}
func TestNew_Tag(t *testing.T) {
ctx := New("", "v1.0.0", "", nil)
if ctx.Get("CI_COMMIT_TAG") != "v1.0.0" {
t.Errorf("unexpected tag: %q", ctx.Get("CI_COMMIT_TAG"))
}
// tag should clear CI_COMMIT_BRANCH
if ctx.Get("CI_COMMIT_BRANCH") != "" {
t.Error("CI_COMMIT_BRANCH should be cleared when tag is set")
}
if ctx.Get("CI_PIPELINE_SOURCE") != "push" {
t.Error("expected default source=push for tag")
}
}
func TestNew_BranchAndTagTogether(t *testing.T) {
// branch set first, then tag overwrites REF_NAME and clears BRANCH
ctx := New("main", "v2.0", "", nil)
if ctx.Get("CI_COMMIT_BRANCH") != "" {
t.Error("BRANCH should be cleared by tag")
}
if ctx.Get("CI_COMMIT_TAG") != "v2.0" {
t.Errorf("unexpected tag %q", ctx.Get("CI_COMMIT_TAG"))
}
}
func TestNew_ExtraVars(t *testing.T) {
ctx := New("main", "", "", []string{"DEPLOY_ENV=prod", "BAD_NO_EQUALS"})
if ctx.Get("DEPLOY_ENV") != "prod" {
t.Errorf("extra var not set: %q", ctx.Get("DEPLOY_ENV"))
}
if ctx.Get("BAD_NO_EQUALS") != "" {
t.Error("malformed KEY=VALUE should not be set")
}
}
func TestNew_SourceOverride(t *testing.T) {
ctx := New("", "", "schedule", nil)
if ctx.Get("CI_PIPELINE_SOURCE") != "schedule" {
t.Errorf("unexpected source: %q", ctx.Get("CI_PIPELINE_SOURCE"))
}
}
func TestNew_DefaultBranch(t *testing.T) {
ctx := New("feature", "", "", nil)
if ctx.Get("CI_DEFAULT_BRANCH") != "main" {
t.Errorf("unexpected default branch: %q", ctx.Get("CI_DEFAULT_BRANCH"))
}
}
func TestInject(t *testing.T) {
ctx := New("main", "", "", nil)
// pinned var should not be overwritten
ctx.Inject("CI_COMMIT_BRANCH", "other")
if ctx.Get("CI_COMMIT_BRANCH") != "main" {
t.Error("pinned var was overwritten by Inject")
}
// non-pinned var should be injected
ctx.Inject("MY_VAR", "hello")
if ctx.Get("MY_VAR") != "hello" {
t.Errorf("Inject failed: %q", ctx.Get("MY_VAR"))
}
}
func TestInject_NilVarsMap(t *testing.T) {
ctx := &Context{}
ctx.Inject("K", "v") // should not panic
if ctx.Get("K") != "v" {
t.Error("Inject on nil Vars should initialise the map")
}
}
func TestGet_NilContext(t *testing.T) {
var ctx *Context
if ctx.Get("X") != "" {
t.Error("nil context Get should return empty string")
}
}
// ── Summary ───────────────────────────────────────────────────────────────────
func TestSummary(t *testing.T) {
cases := []struct {
name string
branch string
tag string
source string
want string
}{
{"empty context", "", "", "", ""},
{"branch only", "main", "", "", "branch=main, source=push"},
{"tag only", "", "v1.0", "", "tag=v1.0, source=push"},
{"source only", "", "", "schedule", "source=schedule"},
{"branch + source", "develop", "", "merge_request_event", "branch=develop, source=merge_request_event"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ctx := New(tc.branch, tc.tag, tc.source, nil)
got := ctx.Summary()
if got != tc.want {
t.Errorf("got %q want %q", got, tc.want)
}
})
}
}
// ── ScalarString ──────────────────────────────────────────────────────────────
func TestScalarString(t *testing.T) {
cases := []struct {
name string
input any
wantS string
wantOK bool
}{
{"string", "hello", "hello", true},
{"bool true", true, "true", true},
{"bool false", false, "false", true},
{"int", 42, "42", true},
{"float64", 3.14, "3.14", true},
{"map with value key", map[string]any{"value": "v"}, "v", true},
{"map without value key", map[string]any{"description": "x"}, "", false},
{"nil", nil, "", false},
{"slice", []any{1, 2}, "", false},
{"nested map value", map[string]any{"value": 99}, "99", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
s, ok := ScalarString(tc.input)
if ok != tc.wantOK || s != tc.wantS {
t.Errorf("got (%q, %v) want (%q, %v)", s, ok, tc.wantS, tc.wantOK)
}
})
}
}
// ── ExpandVars / expandVarRefs ────────────────────────────────────────────────
func TestExpandVars(t *testing.T) {
t.Run("simple expansion", func(t *testing.T) {
ctx := &Context{Vars: map[string]string{"A": "hello", "B": "$A world"}}
ctx.ExpandVars()
if ctx.Vars["B"] != "hello world" {
t.Errorf("got %q", ctx.Vars["B"])
}
})
t.Run("transitive chain", func(t *testing.T) {
ctx := &Context{Vars: map[string]string{"A": "x", "B": "$A", "C": "$B"}}
ctx.ExpandVars()
if ctx.Vars["C"] != "x" {
t.Errorf("got %q", ctx.Vars["C"])
}
})
t.Run("circular reference stops", func(t *testing.T) {
ctx := &Context{Vars: map[string]string{"A": "$B", "B": "$A"}}
ctx.ExpandVars() // must not infinite-loop
})
t.Run("nil context is noop", func(t *testing.T) {
var ctx *Context
ctx.ExpandVars() // must not panic
})
t.Run("empty vars is noop", func(t *testing.T) {
ctx := &Context{}
ctx.ExpandVars() // must not panic
})
}
func TestExpandVarRefs(t *testing.T) {
vars := map[string]string{"FOO": "bar", "X": "123"}
cases := []struct {
name string
input string
want string
}{
{"no dollar", "hello", "hello"},
{"simple ref", "$FOO", "bar"},
{"braced ref", "${FOO}", "bar"},
{"unknown ref unchanged", "$UNKNOWN", "$UNKNOWN"},
{"unknown braced unchanged", "${UNKNOWN}", "${UNKNOWN}"},
{"trailing dollar", "abc$", "abc$"},
{"dollar at end after ident", "$X_", "$X_"},
// Malformed ${…} without closing brace: "${" emitted, ident chars consumed.
{"malformed brace no close", "${FOO", "${"},
{"mixed", "pre_${FOO}_$X", "pre_bar_123"},
{"dollar no ident after", "$ abc", "$ abc"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := expandVarRefs(tc.input, vars)
if got != tc.want {
t.Errorf("got %q want %q", got, tc.want)
}
})
}
}
// ── slugify ───────────────────────────────────────────────────────────────────
func TestSlugify(t *testing.T) {
cases := []struct {
input string
want string
}{
{"main", "main"},
{"feature/my-branch", "feature-my-branch"},
{"Feature_123", "feature-123"},
{"--leading", "leading"},
{"trailing--", "trailing"},
{"v1.2.3", "v1-2-3"},
}
for _, tc := range cases {
t.Run(tc.input, func(t *testing.T) {
got := slugify(tc.input)
if got != tc.want {
t.Errorf("got %q want %q", got, tc.want)
}
})
}
}
+169 -22
View File
@@ -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
} }
@@ -252,16 +339,14 @@ func (p *exprParser) parseStringLiteral() (string, bool) {
} }
func (p *exprParser) parseRegexLiteral() (string, bool) { func (p *exprParser) parseRegexLiteral() (string, bool) {
if p.peek() != '/' {
return "", false
}
p.pos++ // consume opening '/' p.pos++ // consume opening '/'
var sb strings.Builder var sb strings.Builder
for p.pos < len(p.s) { for p.pos < len(p.s) {
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 +360,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 == '_'
} }
+245
View File
@@ -9,6 +9,10 @@ func TestEvalIf(t *testing.T) {
"CI_COMMIT_TAG": "", "CI_COMMIT_TAG": "",
"CI_PIPELINE_SOURCE": "push", "CI_PIPELINE_SOURCE": "push",
"DEPLOY_ENV": "staging", "DEPLOY_ENV": "staging",
"BRANCH_PATTERN": "/^dev/",
"BRANCH_PATTERN_CI": "/^DEV/i",
"EMPTY_PATTERN": "",
"PLAIN_PATTERN": "develop",
} }
return m[key] return m[key]
} }
@@ -69,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,194 @@ func TestEvalIf(t *testing.T) {
}) })
} }
} }
// TestEvalIfParserEdgeCases exercises low-level parser branches not reached
// by the main table-driven tests above.
func TestEvalIfParserEdgeCases(t *testing.T) {
vars := func(key string) string {
switch key {
case "BRANCH":
return "develop"
case "UNTERMINATED_RE":
return "/no-end"
case "ESCAPED_RE":
return `/^dev\./`
}
return ""
}
// parseOr: right side of || fails to parse.
if !EvalIf(`$BRANCH || !(`, vars) {
t.Error("parseOr bad right: expect permissive-true")
}
if EvalIfStrict(`$BRANCH || !(`, vars) {
t.Error("parseOr bad right: expect strict-false")
}
// parsePrimary: inner parseOr succeeds but no closing ')'.
if !EvalIf(`($BRANCH == "develop"`, vars) {
t.Error("unmatched paren: expect permissive-true")
}
if EvalIfStrict(`($BRANCH == "develop"`, vars) {
t.Error("unmatched paren: expect strict-false")
}
// parseComparison ==: right-hand parseValue fails (! is not a valid value start).
if !EvalIf(`$BRANCH == !invalid`, vars) {
t.Error("== bad rhs: expect permissive-true")
}
// parseComparison !=: right-hand parseValue fails.
if !EvalIf(`$BRANCH != ${`, vars) {
t.Error("!= bad rhs: expect permissive-true")
}
// parseRegexRHS: peek is neither '/' nor '$' → return "", false, false.
// parseComparison =~: patOk=false, permissive=false → return false, false.
if !EvalIf(`$BRANCH =~ "literal"`, vars) {
t.Error("=~ string literal rhs: expect permissive-true")
}
if EvalIfStrict(`$BRANCH =~ "literal"`, vars) {
t.Error("=~ string literal rhs: expect strict-false")
}
// parseComparison =~: bad regex pattern → compile error → return true, true.
if !EvalIf(`$BRANCH =~ /[unclosed/`, vars) {
t.Error("=~ bad regex: expect permissive-true")
}
// parseComparison !~: patOk=false → return false, false.
if !EvalIf(`$BRANCH !~ "literal"`, vars) {
t.Error("!~ string literal rhs: expect permissive-true")
}
if EvalIfStrict(`$BRANCH !~ "literal"`, vars) {
t.Error("!~ string literal rhs: expect strict-false")
}
// parseComparison !~: bad regex pattern → compile error → return true, true.
if !EvalIf(`$BRANCH !~ /[unclosed/`, vars) {
t.Error("!~ bad regex: expect permissive-true")
}
// parseComparison single =: RHS parseValue fails.
if !EvalIf(`$BRANCH = !invalid`, vars) {
t.Error("single = bad rhs: expect permissive-true")
}
// parseValue: '$' not followed by a valid identifier.
if !EvalIf(`$} == "develop"`, vars) {
t.Error("$ bad ident: expect permissive-true")
}
// parseValue: '${' with no closing '}' or empty name.
if !EvalIf(`${} == "develop"`, vars) {
t.Error("${} empty name: expect permissive-true")
}
if !EvalIf(`${BRANCH == "develop"`, vars) {
t.Error("${BRANCH no close brace: expect permissive-true")
}
// parseStringLiteral: escape sequence — \v is consumed and the next byte
// is written literally, so "de\velop" → "develop" which matches BRANCH.
if !EvalIf(`$BRANCH == "de\velop"`, vars) {
t.Error(`string escape: "de\velop" should decode to "develop"`)
}
// parseStringLiteral: unterminated string literal.
if !EvalIf(`$BRANCH == "no-end`, vars) {
t.Error("unterminated string: expect permissive-true")
}
if EvalIfStrict(`$BRANCH == "no-end`, vars) {
t.Error("unterminated string: expect strict-false")
}
// parseRegexLiteral: escape sequence in regex (backslash preserved).
// /^d\evelop/ → pattern "^d\evelop" — \e is invalid in Go regexp
// → compile error → permissive true.
if !EvalIf(`$BRANCH =~ /^d\evelop/`, vars) {
t.Error("regex escape (bad compile): expect permissive-true")
}
// parseRegexLiteral: unterminated regex (no closing '/').
if !EvalIf(`$BRANCH =~ /no-end`, vars) {
t.Error("unterminated regex: expect permissive-true")
}
if EvalIfStrict(`$BRANCH =~ /no-end`, vars) {
t.Error("unterminated regex: expect strict-false")
}
// applyRegexFlags: 'm' and 's' flags (exercises two additional switch cases).
if !EvalIf(`$BRANCH =~ /^DEV/ims`, vars) {
t.Error("regex /ims flags: case-insensitive should match 'develop'")
}
// extractRegexFromString: unterminated regex in variable value.
// UNTERMINATED_RE = "/no-end" (no closing '/') → permissive.
if !EvalIf(`$BRANCH =~ $UNTERMINATED_RE`, vars) {
t.Error("unterminated re in var: expect permissive-true")
}
// extractRegexFromString: escape sequence in variable value.
// ESCAPED_RE = /^dev\./ → pattern = "^dev\." (literal dot) → no match for "develop".
if EvalIf(`$BRANCH =~ $ESCAPED_RE`, vars) {
t.Error("ESCAPED_RE=/^dev\\./ requires a literal dot; 'develop' has no dot")
}
// !~ permissive path (line 203-205): var value is a plain string, not /regex/ → permissive.
// BRANCH = "develop" which does not start with '/' → extractRegexFromString returns ok=false
// → parseRegexRHS returns permissive=true → !~ case returns (true, true).
if !EvalIf(`$BRANCH !~ $BRANCH`, vars) {
t.Error("!~ plain-var rhs: plain variable value triggers permissive-true")
}
// parseRegexRHS: '$' in rhs but parseValue fails (line 244-246).
// '$}' — '$' followed by '}' which is not a valid identifier start → varOk=false.
if !EvalIf(`$BRANCH =~ $}`, vars) {
t.Error("=~ $}: parseValue fails → permissive-true (EvalIf overall)")
}
}
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)
}
})
}
}
+120 -10
View File
@@ -1,10 +1,11 @@
package cicontext package cicontext
import ( import (
"path"
"regexp" "regexp"
"strings" "strings"
"git.k3nny.fr/gitlab-sim/internal/model" "git.k3nny.fr/glint/internal/model"
) )
// JobState describes whether a job would be included in a pipeline run for the // JobState describes whether a job would be included in a pipeline run for the
@@ -28,26 +29,37 @@ 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
}
if !changesMatch(rule.Changes, ctx) {
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.
@@ -66,6 +78,9 @@ func EvalJob(job model.Job, ctx *Context) JobState {
if !ruleIfMatches(rule.If, vars) { if !ruleIfMatches(rule.If, vars) {
continue continue
} }
if !changesMatch(rule.Changes, ctx) {
continue
}
return whenToState(rule.When) return whenToState(rule.When)
} }
return JobSkipped // no rule matched → job is excluded return JobSkipped // no rule matched → job is excluded
@@ -90,6 +105,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":
@@ -247,3 +269,91 @@ func matchGlob(pattern, s string) bool {
} }
return len(s) == 0 return len(s) == 0
} }
// ── rules:changes: evaluation ─────────────────────────────────────────────────
// changesMatch reports whether the rule's changes: filter is satisfied.
//
// - rule.Changes == nil → no filter; always true
// - ctx.changedFiles == nil → file list not provided; always true (permissive)
// - otherwise → true iff at least one changed file matches at least one pattern
func changesMatch(changes any, ctx *Context) bool {
patterns := extractChangesPaths(changes)
if patterns == nil {
return true // no changes: filter
}
if ctx.changedFiles == nil {
return true // no file list provided → permissive
}
for _, changed := range ctx.changedFiles {
for _, pat := range patterns {
if doublestarMatch(pat, changed) {
return true
}
}
}
return false
}
// extractChangesPaths normalises a rule.Changes value into a []string of glob
// patterns. Returns nil when no changes: filter is present.
func extractChangesPaths(changes any) []string {
switch v := changes.(type) {
case nil:
return nil
case string:
return []string{v}
case []string:
return v
case []any:
var out []string
for _, item := range v {
if s, ok := item.(string); ok {
out = append(out, s)
}
}
return out
case map[string]any:
// Extended form: { paths: [...], compare_to: "..." }
if raw, ok := v["paths"]; ok {
return extractChangesPaths(raw)
}
return nil
}
return nil
}
// doublestarMatch reports whether pattern matches the file path using GitLab-style
// glob rules: '*' matches any character sequence within a single path segment;
// '**' matches zero or more path segments (crossing '/' boundaries).
func doublestarMatch(pattern, filePath string) bool {
return matchParts(strings.Split(pattern, "/"), strings.Split(filePath, "/"))
}
// matchParts is the recursive engine for doublestarMatch.
func matchParts(pat, name []string) bool {
for len(pat) > 0 {
if pat[0] == "**" {
if len(pat) == 1 {
return true // trailing ** matches everything remaining
}
// ** can match 0, 1, 2, … leading segments of name.
for i := 0; i <= len(name); i++ {
if matchParts(pat[1:], name[i:]) {
return true
}
}
return false
}
if len(name) == 0 {
return false
}
ok, err := path.Match(pat[0], name[0])
if err != nil || !ok {
return false
}
pat = pat[1:]
name = name[1:]
}
return len(name) == 0
}
@@ -0,0 +1,262 @@
package cicontext
import (
"testing"
"git.k3nny.fr/glint/internal/model"
)
// ── JobState.String ───────────────────────────────────────────────────────────
func TestJobStateString(t *testing.T) {
if JobActive.String() != "active" { t.Errorf("active: got %q", JobActive.String()) }
if JobManual.String() != "manual" { t.Errorf("manual: got %q", JobManual.String()) }
if JobSkipped.String() != "skipped" { t.Errorf("skipped: got %q", JobSkipped.String()) }
}
// ── whenToState ───────────────────────────────────────────────────────────────
func TestWhenToState(t *testing.T) {
cases := []struct { when string; want JobState }{
{"never", JobSkipped},
{"manual", JobManual},
{"on_success", JobActive},
{"always", JobActive},
{"", JobActive},
}
for _, tc := range cases {
got := whenToState(tc.when)
if got != tc.want {
t.Errorf("whenToState(%q)=%v want %v", tc.when, got, tc.want)
}
}
}
// ── EvalJob: only/except paths ────────────────────────────────────────────────
func TestEvalJob_OnlyExcept(t *testing.T) {
ctx := New("main", "", "", nil)
t.Run("only branches matches", func(t *testing.T) {
job := model.Job{Only: []any{"branches"}}
if EvalJob(job, ctx) != JobActive {
t.Error("expected active on branch")
}
})
t.Run("only tags excludes branch push", func(t *testing.T) {
job := model.Job{Only: []any{"tags"}}
if EvalJob(job, ctx) != JobSkipped {
t.Error("expected skipped — we are on a branch, not tag")
}
})
t.Run("except branches skips on branch push", func(t *testing.T) {
job := model.Job{Script: "echo ok", Except: []any{"branches"}}
if EvalJob(job, ctx) != JobSkipped {
t.Error("expected skipped — branches excluded")
}
})
t.Run("except non-matching source does not skip", func(t *testing.T) {
job := model.Job{Except: []any{"schedules"}}
if EvalJob(job, ctx) != JobActive {
t.Error("expected active — schedule is not the source")
}
})
t.Run("job when: manual with no filter", func(t *testing.T) {
job := model.Job{When: "manual"}
if EvalJob(job, ctx) != JobManual {
t.Error("expected manual")
}
})
t.Run("empty context returns active", func(t *testing.T) {
emptyCtx := New("", "", "", nil)
job := model.Job{Only: []any{"tags"}}
if EvalJob(job, emptyCtx) != JobActive {
t.Error("empty context should always return active")
}
})
}
// ── EvalJob: rules path ───────────────────────────────────────────────────────
func TestEvalJob_Rules(t *testing.T) {
ctx := New("main", "", "", nil)
t.Run("rule with never when excludes", func(t *testing.T) {
job := model.Job{Rules: []model.Rule{{When: "never"}}}
if EvalJob(job, ctx) != JobSkipped {
t.Error("expected skipped — when:never")
}
})
t.Run("no matching rule → skipped", func(t *testing.T) {
job := model.Job{Rules: []model.Rule{
{If: "$CI_COMMIT_BRANCH == \"develop\"", When: "on_success"},
}}
if EvalJob(job, ctx) != JobSkipped {
t.Error("expected skipped — no rule matches main branch with develop condition")
}
})
}
// ── onlyMatches / exceptMatches ───────────────────────────────────────────────
func TestOnlyMatches(t *testing.T) {
branchCtx := New("main", "", "", nil)
tagCtx := New("", "v1.0", "", nil)
mrCtx := New("", "", "merge_request_event", nil)
cases := []struct {
name string
only any
ctx *Context
want bool
}{
{"nil only always matches", nil, branchCtx, true},
{"unrecognised form permissive", 42, branchCtx, true},
{"string form — branches keyword", "branches", branchCtx, true},
{"string form — tags keyword miss", "tags", branchCtx, false},
{"slice — merge_requests keyword", []any{"merge_requests"}, mrCtx, true},
{"slice — schedules keyword", []any{"schedules"}, branchCtx, false},
{"slice — pipelines keyword", []any{"pipelines"}, New("","","pipeline",nil), true},
{"slice — pushes keyword", []any{"pushes"}, New("","","push",nil), true},
{"slice — web keyword", []any{"web"}, New("","","web",nil), true},
{"slice — api keyword", []any{"api"}, New("","","api",nil), true},
{"slice — tag keyword match", []any{"tags"}, tagCtx, true},
{"glob pattern matches", []any{"feat/*"}, New("feat/abc","","",nil), true},
{"glob no match", []any{"feat/*"}, branchCtx, false},
{"regex pattern matches branch", []any{"/^main.*/"}, branchCtx, true},
{"regex pattern no match", []any{"/^develop/"}, branchCtx, false},
{"invalid regex treated as glob", []any{"/[invalid/"}, branchCtx, false},
{"map form with refs key", map[string]any{"refs": []any{"branches"}}, branchCtx, true},
{"map form no refs — permissive", map[string]any{"variables": []any{}}, branchCtx, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := onlyMatches(tc.only, tc.ctx)
if got != tc.want {
t.Errorf("onlyMatches: got %v want %v", got, tc.want)
}
})
}
}
func TestExceptMatches(t *testing.T) {
branchCtx := New("main", "", "", nil)
cases := []struct {
name string
except any
want bool
}{
{"nil except never excludes", nil, false},
{"unrecognised form not excluded", 42, false},
{"branches excludes on branch push", []any{"branches"}, true},
{"tags does not exclude branch push", []any{"tags"}, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := exceptMatches(tc.except, branchCtx)
if got != tc.want {
t.Errorf("exceptMatches: got %v want %v", got, tc.want)
}
})
}
}
// ── extractRefList ────────────────────────────────────────────────────────────
func TestExtractRefList(t *testing.T) {
cases := []struct {
name string
input any
wantN int // -1 means nil expected
}{
{"string", "branches", 1},
{"slice of strings", []any{"a", "b", "c"}, 3},
{"slice with non-string items skipped", []any{"a", 42, "b"}, 2},
{"map with refs key", map[string]any{"refs": []any{"branches"}}, 1},
{"map without refs key returns nil", map[string]any{"variables": nil}, -1},
{"unknown type returns nil", 123, -1},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := extractRefList(tc.input)
if tc.wantN == -1 {
if got != nil { t.Errorf("expected nil, got %v", got) }
} else {
if len(got) != tc.wantN { t.Errorf("len: got %d want %d (%v)", len(got), tc.wantN, got) }
}
})
}
}
// ── globMatches / matchGlob ───────────────────────────────────────────────────
func TestGlobMatches(t *testing.T) {
cases := []struct {
pattern string
s string
want bool
}{
{"main", "main", true},
{"main", "develop", false},
{"feat/*", "feat/abc", true},
{"feat/*", "feat/", true},
{"feat/*", "main", false},
{"*", "anything", true},
{"*", "", true},
{"prefix*suffix", "prefixMIDDLEsuffix", true},
{"prefix*suffix", "prefixsuffix", true},
{"prefix*suffix", "prefixNO", false},
{"a*b*c", "aXbYc", true},
{"a*b*c", "abc", true},
}
for _, tc := range cases {
t.Run(tc.pattern+"/"+tc.s, func(t *testing.T) {
got := globMatches(tc.pattern, tc.s)
if got != tc.want {
t.Errorf("globMatches(%q, %q)=%v want %v", tc.pattern, tc.s, got, tc.want)
}
})
}
}
// ── refPatternMatches ─────────────────────────────────────────────────────────
func TestRefPatternMatches(t *testing.T) {
cases := []struct {
pattern string
ref string
want bool
}{
{"/^main$/", "main", true},
{"/^main$/", "develop", false},
{"/feature/", "feature/abc", true},
{"feat/*", "feat/x", true},
{"main", "main", true},
{"main", "develop", false},
{"", "main", false}, // empty ref for pattern with no wildcard
}
for _, tc := range cases {
t.Run(tc.pattern+"@"+tc.ref, func(t *testing.T) {
got := refPatternMatches(tc.pattern, tc.ref)
if got != tc.want {
t.Errorf("refPatternMatches(%q, %q)=%v want %v", tc.pattern, tc.ref, got, tc.want)
}
})
}
}
// TestRefListMatches_Schedule verifies the "schedules" keyword matches when
// CI_PIPELINE_SOURCE is "schedule".
func TestRefListMatches_Schedule(t *testing.T) {
ctx := New("", "", "schedule", nil)
if !refListMatches([]string{"schedules"}, ctx) {
t.Error("schedules keyword should match when source is 'schedule'")
}
}
+243
View File
@@ -0,0 +1,243 @@
package cicontext
import (
"testing"
"git.k3nny.fr/glint/internal/model"
)
func TestEvalWorkflow(t *testing.T) {
makePipeline := func(rules []model.Rule) *model.Pipeline {
return &model.Pipeline{Workflow: &model.Workflow{Rules: rules}}
}
tests := []struct {
name string
rules []model.Rule
branch string
wantRuns bool
wantVars map[string]string
}{
{
name: "no workflow block",
rules: nil,
branch: "main",
wantRuns: true,
wantVars: nil,
},
{
name: "matching rule runs always",
rules: []model.Rule{
{If: `$CI_COMMIT_BRANCH == "main"`, When: "always"},
},
branch: "main",
wantRuns: true,
},
{
name: "matching rule when never",
rules: []model.Rule{
{If: `$CI_COMMIT_BRANCH == "main"`, When: "never"},
},
branch: "main",
wantRuns: false,
},
{
name: "no rule matched",
rules: []model.Rule{
{If: `$CI_COMMIT_BRANCH == "main"`},
},
branch: "develop",
wantRuns: false,
},
{
name: "matching rule with variables",
rules: []model.Rule{
{
If: `$CI_COMMIT_BRANCH == "main"`,
When: "always",
Variables: map[string]any{
"DEPLOY_TARGET": "production",
"ENVIRONMENT": "prod",
},
},
{When: "always"},
},
branch: "main",
wantRuns: true,
wantVars: map[string]string{
"DEPLOY_TARGET": "production",
"ENVIRONMENT": "prod",
},
},
{
name: "fallback rule with different variables",
rules: []model.Rule{
{If: `$CI_COMMIT_BRANCH == "main"`, Variables: map[string]any{"DEPLOY_TARGET": "production"}},
{When: "always", Variables: map[string]any{"DEPLOY_TARGET": "staging"}},
},
branch: "develop",
wantRuns: true,
wantVars: map[string]string{"DEPLOY_TARGET": "staging"},
},
{
name: "empty context always runs",
rules: []model.Rule{
{If: `$CI_COMMIT_BRANCH == "main"`, When: "never"},
},
branch: "", // no context
wantRuns: true,
wantVars: nil,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var p *model.Pipeline
if tc.rules == nil {
p = &model.Pipeline{}
} else {
p = makePipeline(tc.rules)
}
ctx := New(tc.branch, "", "", nil)
runs, vars := EvalWorkflow(p, ctx)
if runs != tc.wantRuns {
t.Errorf("EvalWorkflow runs = %v, want %v", runs, tc.wantRuns)
}
for k, want := range tc.wantVars {
if got := vars[k]; got != want {
t.Errorf("EvalWorkflow vars[%q] = %q, want %q", k, got, want)
}
}
if len(vars) != len(tc.wantVars) {
t.Errorf("EvalWorkflow returned %d vars, want %d; got %v", len(vars), len(tc.wantVars), vars)
}
})
}
}
func TestContextInject(t *testing.T) {
t.Run("inject does not overwrite pinned var", func(t *testing.T) {
ctx := New("main", "", "", []string{"DEPLOY_TARGET=override"})
ctx.Inject("DEPLOY_TARGET", "workflow-value")
if got := ctx.Get("DEPLOY_TARGET"); got != "override" {
t.Errorf("Inject overwrote pinned var: got %q, want %q", got, "override")
}
})
t.Run("inject does not overwrite shortcut var", func(t *testing.T) {
ctx := New("main", "", "", nil)
ctx.Inject("CI_COMMIT_BRANCH", "other")
if got := ctx.Get("CI_COMMIT_BRANCH"); got != "main" {
t.Errorf("Inject overwrote shortcut var: got %q, want %q", got, "main")
}
})
t.Run("inject sets new variable", func(t *testing.T) {
ctx := New("main", "", "", nil)
ctx.Inject("DEPLOY_TARGET", "production")
if got := ctx.Get("DEPLOY_TARGET"); got != "production" {
t.Errorf("Inject did not set variable: got %q", got)
}
})
t.Run("inject later call overrides earlier call", func(t *testing.T) {
ctx := New("main", "", "", nil)
ctx.Inject("DEPLOY_TARGET", "pipeline-default")
ctx.Inject("DEPLOY_TARGET", "workflow-override")
if got := ctx.Get("DEPLOY_TARGET"); got != "workflow-override" {
t.Errorf("second Inject did not win: got %q, want %q", got, "workflow-override")
}
})
}
func TestExtractStringVars(t *testing.T) {
tests := []struct {
name string
in map[string]any
want map[string]string
}{
{
name: "plain strings",
in: map[string]any{"A": "hello", "B": "world"},
want: map[string]string{"A": "hello", "B": "world"},
},
{
name: "extended value form",
in: map[string]any{
"KEY": map[string]any{"value": "extended", "description": "some desc"},
},
want: map[string]string{"KEY": "extended"},
},
{
name: "mixed forms",
in: map[string]any{
"PLAIN": "str",
"COMPLEX": map[string]any{"value": "val"},
},
want: map[string]string{"PLAIN": "str", "COMPLEX": "val"},
},
{
name: "nil map",
in: nil,
want: nil,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := ExtractStringVars(tc.in)
for k, want := range tc.want {
if got[k] != want {
t.Errorf("ExtractStringVars[%q] = %q, want %q", k, got[k], want)
}
}
if len(got) != len(tc.want) {
t.Errorf("ExtractStringVars returned %d entries, want %d", len(got), len(tc.want))
}
})
}
}
// TestWorkflowVarsJobEval verifies the end-to-end flow: workflow rule injects
// DEPLOY_TARGET, which is then used in a job's rules:if: expression.
func TestWorkflowVarsJobEval(t *testing.T) {
rules := []model.Rule{
{If: `$CI_COMMIT_BRANCH == "main"`, Variables: map[string]any{"DEPLOY_TARGET": "production"}},
{When: "always", Variables: map[string]any{"DEPLOY_TARGET": "staging"}},
}
p := &model.Pipeline{
Workflow: &model.Workflow{Rules: rules},
Jobs: map[string]model.Job{
"deploy-prod": {
Rules: []model.Rule{
{If: `$DEPLOY_TARGET == "production"`, When: "on_success"},
{When: "never"},
},
},
"deploy-staging": {
Rules: []model.Rule{
{If: `$DEPLOY_TARGET == "staging"`, When: "on_success"},
{When: "never"},
},
},
},
}
for _, tc := range []struct {
branch string
wantProd JobState
wantStaging JobState
}{
{"main", JobActive, JobSkipped},
{"develop", JobSkipped, JobActive},
} {
ctx := New(tc.branch, "", "", nil)
_, ruleVars := EvalWorkflow(p, ctx)
for k, v := range ruleVars {
ctx.Inject(k, v)
}
if got := EvalJob(p.Jobs["deploy-prod"], ctx); got != tc.wantProd {
t.Errorf("branch=%q deploy-prod = %v, want %v", tc.branch, got, tc.wantProd)
}
if got := EvalJob(p.Jobs["deploy-staging"], ctx); got != tc.wantStaging {
t.Errorf("branch=%q deploy-staging = %v, want %v", tc.branch, got, tc.wantStaging)
}
}
}
+75
View File
@@ -0,0 +1,75 @@
package config
import (
"fmt"
"os"
"path/filepath"
"gopkg.in/yaml.v3"
)
// Filename is the name of the project-level glint configuration file.
const Filename = ".glint.yml"
// Config holds project-level glint configuration loaded from .glint.yml.
type Config struct {
// Ignore lists rule IDs to suppress entirely (e.g. ["GL007", "GL032"]).
Ignore []string `yaml:"ignore"`
// Severity maps rule IDs to overridden severity levels.
// Valid values: "error", "warning", "ignore".
// "ignore" is equivalent to listing the rule in Ignore.
Severity map[string]string `yaml:"severity"`
// Stages lists additional stage names that are considered valid for this
// project, beyond what is declared in the pipeline's own stages: block.
// Jobs in these stages are not flagged by GL004.
Stages []string `yaml:"stages"`
// Token is a default GitLab personal access token used when neither the
// --token flag nor GITLAB_TOKEN (/ CI_JOB_TOKEN / GITLAB_PRIVATE_TOKEN)
// environment variables are set.
Token string `yaml:"token"`
// URL is the default GitLab instance URL. Overridden by --gitlab-url and
// the CI_SERVER_URL / GITLAB_URL environment variables.
URL string `yaml:"url"`
// CacheDir is the default directory for caching fetched remote includes.
// Overridden by the --cache-dir flag.
CacheDir string `yaml:"cache_dir"`
}
// Load searches for a .glint.yml file starting from dir and walking up toward
// the filesystem root. The walk stops at the first .git directory found (the
// repository root) or at the filesystem root. Returns an empty Config (and no
// error) when no config file is found.
func Load(dir string) (Config, error) {
dir = filepath.Clean(dir)
for {
candidate := filepath.Join(dir, Filename)
data, err := os.ReadFile(candidate)
if err == nil {
var cfg Config
if yerr := yaml.Unmarshal(data, &cfg); yerr != nil {
return Config{}, fmt.Errorf("parsing %s: %w", candidate, yerr)
}
return cfg, nil
}
if !os.IsNotExist(err) {
return Config{}, fmt.Errorf("reading %s: %w", candidate, err)
}
// Stop when we reach a git root so we don't wander into parent repos.
if _, serr := os.Stat(filepath.Join(dir, ".git")); serr == nil {
break
}
parent := filepath.Dir(dir)
if parent == dir {
break // filesystem root
}
dir = parent
}
return Config{}, nil
}
+127
View File
@@ -0,0 +1,127 @@
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)
}
}
// TestLoad_ReadError covers the !os.IsNotExist(err) branch (config.go:59-61)
// when the file exists but is not readable.
func TestLoad_ReadError(t *testing.T) {
tmp := t.TempDir()
cfgPath := filepath.Join(tmp, Filename)
if err := os.WriteFile(cfgPath, []byte("ignore: []"), 0o000); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.Chmod(cfgPath, 0o644) })
_, err := Load(tmp)
if err == nil {
t.Error("expected error for unreadable config file")
}
}
func TestLoad_InvalidYAML(t *testing.T) {
tmp := t.TempDir()
if err := os.WriteFile(filepath.Join(tmp, Filename), []byte("ignore: [unclosed\n"), 0o644); err != nil {
t.Fatal(err)
}
_, err := Load(tmp)
if err == nil {
t.Error("expected error for invalid YAML")
}
}
+41
View File
@@ -0,0 +1,41 @@
package fetcher
import (
"crypto/sha256"
"fmt"
"os"
"path/filepath"
)
// cacheRead returns the cached bytes for the given cache key, or (nil, false)
// on a miss (key not present, dir empty, or any read error).
func cacheRead(dir, key string) ([]byte, bool) {
if dir == "" {
return nil, false
}
data, err := os.ReadFile(cachePath(dir, key))
if err != nil {
return nil, false
}
return data, true
}
// cacheWrite stores bytes in the cache for the given key. Write errors are
// silently ignored so cache failures never block the normal fetch path.
func cacheWrite(dir, key string, data []byte) {
if dir == "" {
return
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return
}
_ = os.WriteFile(cachePath(dir, key), data, 0o644)
}
// cachePath returns the filesystem path for a cache entry.
// The filename is the SHA-256 hex digest of the key so arbitrary keys (URLs,
// "project:file@ref" strings) map to safe, stable filenames.
func cachePath(dir, key string) string {
h := sha256.Sum256([]byte(key))
return filepath.Join(dir, fmt.Sprintf("%x.yml", h))
}
+73
View File
@@ -0,0 +1,73 @@
package fetcher
import (
"os"
"path/filepath"
"testing"
)
func TestCachePath(t *testing.T) {
p1 := cachePath("/tmp/cache", "key1")
p2 := cachePath("/tmp/cache", "key2")
if p1 == p2 {
t.Error("different keys should produce different paths")
}
if filepath.Dir(p1) != "/tmp/cache" {
t.Errorf("expected /tmp/cache dir, got %q", filepath.Dir(p1))
}
}
func TestCacheReadMiss(t *testing.T) {
// empty dir → miss
data, ok := cacheRead("", "key")
if ok || data != nil {
t.Error("empty cacheDir should always miss")
}
// nonexistent entry → miss
data, ok = cacheRead(t.TempDir(), "nonexistent-key")
if ok || data != nil {
t.Error("missing cache entry should miss")
}
}
func TestCacheWriteAndRead(t *testing.T) {
dir := t.TempDir()
key := "https://example.com/template.yml"
content := []byte("stages: [build]")
cacheWrite(dir, key, content)
got, ok := cacheRead(dir, key)
if !ok {
t.Fatal("expected cache hit after write")
}
if string(got) != string(content) {
t.Errorf("cached content mismatch: got %q want %q", got, content)
}
}
func TestCacheWrite_EmptyDir(t *testing.T) {
// Should silently do nothing when dir is empty string.
cacheWrite("", "key", []byte("data"))
}
// TestCacheWrite_DirIsFile covers the os.MkdirAll error path (cache.go:29-31)
// when the cache dir path is occupied by a regular file.
func TestCacheWrite_DirIsFile(t *testing.T) {
f := filepath.Join(t.TempDir(), "file")
if err := os.WriteFile(f, []byte("occupied"), 0o644); err != nil {
t.Fatal(err)
}
// MkdirAll(f) fails because f is a file, not a directory.
cacheWrite(f, "key", []byte("data"))
// No panic, no error returned — the function silently returns.
}
func TestCacheWrite_MkdirAll(t *testing.T) {
dir := filepath.Join(t.TempDir(), "sub", "dir")
cacheWrite(dir, "k", []byte("v"))
if _, err := os.Stat(dir); err != nil {
t.Errorf("directory not created: %v", err)
}
}
+49 -6
View File
@@ -25,6 +25,8 @@ type GitLabConfig struct {
BaseURL string BaseURL string
Token string Token string
Source TokenSource Source TokenSource
CacheDir string // local cache directory; empty = caching disabled
Offline bool // when true, return an error instead of making network calls
} }
// AutoConfig builds a GitLabConfig from environment variables. // AutoConfig builds a GitLabConfig from environment variables.
@@ -54,8 +56,11 @@ func AutoConfig() GitLabConfig {
return cfg return cfg
} }
// WithOverrides returns a copy of cfg with non-empty overrides applied. // WithOverrides returns a copy of cfg with the provided overrides applied.
func (cfg GitLabConfig) WithOverrides(baseURL, token string) GitLabConfig { // Non-empty strings overwrite the corresponding field; booleans are always
// applied (so offline: false explicitly clears offline mode).
// cacheDir: empty string disables caching.
func (cfg GitLabConfig) WithOverrides(baseURL, token, cacheDir string, offline bool) GitLabConfig {
if baseURL != "" { if baseURL != "" {
cfg.BaseURL = strings.TrimRight(baseURL, "/") cfg.BaseURL = strings.TrimRight(baseURL, "/")
} }
@@ -63,6 +68,8 @@ func (cfg GitLabConfig) WithOverrides(baseURL, token string) GitLabConfig {
cfg.Token = token cfg.Token = token
cfg.Source = TokenPrivate cfg.Source = TokenPrivate
} }
cfg.CacheDir = cacheDir
cfg.Offline = offline
return cfg return cfg
} }
@@ -84,16 +91,24 @@ func (cfg GitLabConfig) HasToken() bool { return cfg.Token != "" }
// - filePath — repository file path, e.g. "/templates/ci.yml" // - filePath — repository file path, e.g. "/templates/ci.yml"
// - ref — branch, tag, or commit SHA; empty string defaults to HEAD // - ref — branch, tag, or commit SHA; empty string defaults to HEAD
func (cfg GitLabConfig) FetchFile(project, filePath, ref string) ([]byte, error) { func (cfg GitLabConfig) FetchFile(project, filePath, ref string) ([]byte, error) {
if ref == "" {
ref = "HEAD"
}
cKey := cfg.BaseURL + "|" + project + "|" + filePath + "|" + ref
if data, ok := cacheRead(cfg.CacheDir, cKey); ok {
return data, nil
}
if cfg.Offline {
return nil, fmt.Errorf("offline mode: %s:%s@%s is not in the local cache (run without --offline first to populate the cache)", project, filePath, ref)
}
encodedProject := url.PathEscape(project) encodedProject := url.PathEscape(project)
encodedFile := url.PathEscape(strings.TrimPrefix(filePath, "/")) encodedFile := url.PathEscape(strings.TrimPrefix(filePath, "/"))
apiURL := fmt.Sprintf("%s/api/v4/projects/%s/repository/files/%s/raw", apiURL := fmt.Sprintf("%s/api/v4/projects/%s/repository/files/%s/raw",
cfg.BaseURL, encodedProject, encodedFile) cfg.BaseURL, encodedProject, encodedFile)
if ref == "" {
ref = "HEAD"
}
req, err := http.NewRequest(http.MethodGet, apiURL, nil) req, err := http.NewRequest(http.MethodGet, apiURL, nil)
if err != nil { if err != nil {
return nil, fmt.Errorf("building request: %w", err) return nil, fmt.Errorf("building request: %w", err)
@@ -139,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
} }
+341
View File
@@ -0,0 +1,341 @@
package fetcher
import (
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"testing"
)
// roundTripFunc allows constructing a custom http.RoundTripper from a function.
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
// errReader is an io.Reader that always returns an error.
type errReader struct{}
func (e errReader) Read([]byte) (int, error) { return 0, errors.New("read error") }
// replaceTransport temporarily replaces http.DefaultTransport and restores it.
func replaceTransport(t *testing.T, rt http.RoundTripper) {
t.Helper()
orig := http.DefaultTransport
http.DefaultTransport = rt
t.Cleanup(func() { http.DefaultTransport = orig })
}
// ── firstNonEmpty ─────────────────────────────────────────────────────────────
func TestFirstNonEmpty(t *testing.T) {
if firstNonEmpty("", "", "c") != "c" { t.Error("should return first non-empty") }
if firstNonEmpty("a", "b") != "a" { t.Error("should return first") }
if firstNonEmpty("", "") != "" { t.Error("all empty → empty") }
}
// ── AutoConfig ────────────────────────────────────────────────────────────────
func TestAutoConfig(t *testing.T) {
// Clear relevant env vars
for _, k := range []string{"CI_SERVER_URL", "GITLAB_URL", "GITLAB_TOKEN", "CI_JOB_TOKEN", "GITLAB_PRIVATE_TOKEN"} {
os.Unsetenv(k)
}
cfg := AutoConfig()
if cfg.BaseURL != "https://gitlab.com" {
t.Errorf("default BaseURL: %q", cfg.BaseURL)
}
if cfg.Token != "" {
t.Error("expected no token")
}
}
func TestAutoConfig_EnvVars(t *testing.T) {
os.Setenv("CI_SERVER_URL", "https://my.gitlab.example.com/")
os.Setenv("GITLAB_TOKEN", "glpat-test")
defer func() {
os.Unsetenv("CI_SERVER_URL")
os.Unsetenv("GITLAB_TOKEN")
}()
cfg := AutoConfig()
if cfg.BaseURL != "https://my.gitlab.example.com" {
t.Errorf("trailing slash not trimmed: %q", cfg.BaseURL)
}
if cfg.Token != "glpat-test" || cfg.Source != TokenPrivate {
t.Errorf("token: %q source: %v", cfg.Token, cfg.Source)
}
}
func TestAutoConfig_CIJobToken(t *testing.T) {
os.Unsetenv("GITLAB_TOKEN")
os.Setenv("CI_JOB_TOKEN", "job-token-123")
defer os.Unsetenv("CI_JOB_TOKEN")
cfg := AutoConfig()
if cfg.Token != "job-token-123" || cfg.Source != TokenJobToken {
t.Errorf("unexpected: %+v", cfg)
}
}
func TestAutoConfig_PrivateToken(t *testing.T) {
os.Unsetenv("GITLAB_TOKEN")
os.Unsetenv("CI_JOB_TOKEN")
os.Setenv("GITLAB_PRIVATE_TOKEN", "legacy-token")
defer os.Unsetenv("GITLAB_PRIVATE_TOKEN")
cfg := AutoConfig()
if cfg.Token != "legacy-token" || cfg.Source != TokenPrivate {
t.Errorf("unexpected: %+v", cfg)
}
}
func TestAutoConfig_GitlabURL(t *testing.T) {
os.Unsetenv("CI_SERVER_URL")
os.Setenv("GITLAB_URL", "https://gl.local")
defer os.Unsetenv("GITLAB_URL")
cfg := AutoConfig()
if cfg.BaseURL != "https://gl.local" {
t.Errorf("unexpected BaseURL: %q", cfg.BaseURL)
}
}
// ── WithOverrides ─────────────────────────────────────────────────────────────
func TestWithOverrides(t *testing.T) {
base := GitLabConfig{BaseURL: "https://gitlab.com", Token: "old", Source: TokenPrivate}
got := base.WithOverrides("https://other.com/", "new-token", "/cache", true)
if got.BaseURL != "https://other.com" { t.Errorf("BaseURL: %q", got.BaseURL) }
if got.Token != "new-token" { t.Error("Token not overridden") }
if got.CacheDir != "/cache" { t.Error("CacheDir not set") }
if !got.Offline { t.Error("Offline not set") }
// empty string leaves BaseURL alone
got2 := base.WithOverrides("", "", "", false)
if got2.BaseURL != "https://gitlab.com" { t.Error("empty override should not change BaseURL") }
}
// ── ForHost ───────────────────────────────────────────────────────────────────
func TestForHost(t *testing.T) {
cfg := GitLabConfig{BaseURL: "https://gitlab.com"}
got := cfg.ForHost("my-host.example.com")
if got.BaseURL != "https://my-host.example.com" {
t.Errorf("unexpected BaseURL: %q", got.BaseURL)
}
}
// ── HasToken ──────────────────────────────────────────────────────────────────
func TestHasToken(t *testing.T) {
if (GitLabConfig{}).HasToken() { t.Error("empty config should have no token") }
if !(GitLabConfig{Token: "x"}).HasToken() { t.Error("config with token should have token") }
}
// ── FetchFile ─────────────────────────────────────────────────────────────────
func TestFetchFile_FromCache(t *testing.T) {
dir := t.TempDir()
key := "https://gitlab.com|my/project|/templates/ci.yml|HEAD"
cacheWrite(dir, key, []byte("cached: true"))
cfg := GitLabConfig{BaseURL: "https://gitlab.com", CacheDir: dir}
data, err := cfg.FetchFile("my/project", "/templates/ci.yml", "")
if err != nil { t.Fatalf("unexpected error: %v", err) }
if string(data) != "cached: true" { t.Errorf("got %q", data) }
}
func TestFetchFile_Offline_Miss(t *testing.T) {
cfg := GitLabConfig{BaseURL: "https://gitlab.com", Offline: true}
_, err := cfg.FetchFile("p/q", "/f.yml", "main")
if err == nil { t.Fatal("expected error in offline mode") }
}
func TestFetchFile_OK(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("script: echo ok"))
}))
defer srv.Close()
cfg := GitLabConfig{BaseURL: srv.URL, Token: "tok", Source: TokenPrivate}
data, err := cfg.FetchFile("ns/proj", "/ci.yml", "main")
if err != nil { t.Fatalf("unexpected error: %v", err) }
if string(data) != "script: echo ok" { t.Errorf("got %q", data) }
}
func TestFetchFile_Unauthorized_NoToken(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
}))
defer srv.Close()
cfg := GitLabConfig{BaseURL: srv.URL}
_, err := cfg.FetchFile("p/q", "/f.yml", "main")
if err == nil { t.Fatal("expected error") }
}
func TestFetchFile_Unauthorized_WithToken(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
}))
defer srv.Close()
cfg := GitLabConfig{BaseURL: srv.URL, Token: "tok", Source: TokenPrivate}
_, err := cfg.FetchFile("p/q", "/f.yml", "main")
if err == nil { t.Fatal("expected error") }
}
func TestFetchFile_NotFound(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte("not found"))
}))
defer srv.Close()
cfg := GitLabConfig{BaseURL: srv.URL}
_, err := cfg.FetchFile("p/q", "/f.yml", "main")
if err == nil { t.Fatal("expected error") }
}
func TestFetchFile_OtherStatus(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("server error"))
}))
defer srv.Close()
cfg := GitLabConfig{BaseURL: srv.URL}
_, err := cfg.FetchFile("p/q", "/f.yml", "main")
if err == nil { t.Fatal("expected error") }
}
func TestFetchFile_JobToken(t *testing.T) {
var gotHeader string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotHeader = r.Header.Get("JOB-TOKEN")
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
}))
defer srv.Close()
cfg := GitLabConfig{BaseURL: srv.URL, Token: "ci-job-tok", Source: TokenJobToken}
cfg.FetchFile("p/q", "/f.yml", "main")
if gotHeader != "ci-job-tok" {
t.Errorf("JOB-TOKEN header not sent, got %q", gotHeader)
}
}
func TestFetchFile_WritesToCache(t *testing.T) {
dir := t.TempDir()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("stage: build"))
}))
defer srv.Close()
cfg := GitLabConfig{BaseURL: srv.URL, CacheDir: dir}
_, err := cfg.FetchFile("p/q", "/ci.yml", "main")
if err != nil { t.Fatal(err) }
// Second call should hit cache
data, ok := cacheRead(dir, srv.URL+"|p/q|/ci.yml|main")
if !ok { t.Fatal("cache miss after fetch") }
if string(data) != "stage: build" { t.Errorf("unexpected cache content: %q", data) }
}
// ── FetchURL ──────────────────────────────────────────────────────────────────
func TestFetchURL_OK(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("remote: content"))
}))
defer srv.Close()
cfg := GitLabConfig{BaseURL: srv.URL}
data, err := cfg.FetchURL(srv.URL + "/template.yml")
if err != nil { t.Fatalf("unexpected error: %v", err) }
if string(data) != "remote: content" { t.Errorf("got %q", data) }
}
func TestFetchURL_FromCache(t *testing.T) {
dir := t.TempDir()
rawURL := "https://example.com/tmpl.yml"
cacheWrite(dir, rawURL, []byte("cached"))
cfg := GitLabConfig{CacheDir: dir}
data, err := cfg.FetchURL(rawURL)
if err != nil { t.Fatal(err) }
if string(data) != "cached" { t.Errorf("got %q", data) }
}
func TestFetchURL_Offline(t *testing.T) {
cfg := GitLabConfig{Offline: true}
_, err := cfg.FetchURL("https://example.com/tmpl.yml")
if err == nil { t.Fatal("expected error in offline mode") }
}
// TestFetchFile_NewRequestFails covers gitlab.go:113-115 — http.NewRequest error
// when the BaseURL contains a control character (null byte) making the URL invalid.
func TestFetchFile_NewRequestFails(t *testing.T) {
cfg := GitLabConfig{BaseURL: "https://example.com\x00"}
_, err := cfg.FetchFile("p/q", "/f.yml", "main")
if err == nil {
t.Fatal("expected error for URL with null byte")
}
}
// TestFetchFile_DoFails covers gitlab.go:132-134 — http.DefaultClient.Do error.
func TestFetchFile_DoFails(t *testing.T) {
replaceTransport(t, roundTripFunc(func(r *http.Request) (*http.Response, error) {
return nil, errors.New("transport error")
}))
cfg := GitLabConfig{BaseURL: "https://example.com"}
_, err := cfg.FetchFile("p/q", "/f.yml", "main")
if err == nil {
t.Fatal("expected error when transport fails")
}
}
// TestFetchFile_ReadBodyFails covers gitlab.go:138-140 — io.ReadAll error on the
// response body.
func TestFetchFile_ReadBodyFails(t *testing.T) {
replaceTransport(t, roundTripFunc(func(r *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(errReader{}),
Header: make(http.Header),
}, nil
}))
cfg := GitLabConfig{BaseURL: "https://example.com"}
_, err := cfg.FetchFile("p/q", "/f.yml", "main")
if err == nil {
t.Fatal("expected error when body read fails")
}
}
// TestFetchURL_GetFails covers gitlab.go:173-175 — http.Get error.
func TestFetchURL_GetFails(t *testing.T) {
replaceTransport(t, roundTripFunc(func(r *http.Request) (*http.Response, error) {
return nil, errors.New("get failed")
}))
cfg := GitLabConfig{}
_, err := cfg.FetchURL("https://example.com/template.yml")
if err == nil {
t.Fatal("expected error when http.Get fails")
}
}
// TestFetchURL_ReadBodyFails covers gitlab.go:178-180 — io.ReadAll error on the
// FetchURL response body.
func TestFetchURL_ReadBodyFails(t *testing.T) {
replaceTransport(t, roundTripFunc(func(r *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(errReader{}),
Header: make(http.Header),
}, nil
}))
cfg := GitLabConfig{}
_, err := cfg.FetchURL("https://example.com/template.yml")
if err == nil {
t.Fatal("expected error when FetchURL body read fails")
}
}
func TestFetchURL_NotOK(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
}))
defer srv.Close()
cfg := GitLabConfig{}
_, err := cfg.FetchURL(srv.URL)
if err == nil { t.Fatal("expected error for non-200 status") }
}
+282 -69
View File
@@ -2,12 +2,226 @@ package graph
import ( import (
"fmt" "fmt"
"os"
"path/filepath"
"sort"
"strings" "strings"
"git.k3nny.fr/glint/internal/fetcher"
"git.k3nny.fr/glint/internal/model"
) )
// Includes returns a Mermaid flowchart showing include file dependencies. // Includes returns a Mermaid flowchart of the full include dependency tree.
// sourcePath is the path to the main pipeline file; rawIncludes is Pipeline.Include. // Each node shows the jobs defined directly in that file, connected with
func Includes(sourcePath string, rawIncludes []any) string { // dashed arrows (solid arrows represent the include hierarchy itself).
// It recurses into project:, component:, and local: includes to expose
// transitive dependencies. Includes that cannot be fetched are shown but not expanded.
func Includes(sourcePath string, rawIncludes []any, cfg fetcher.GitLabConfig) string {
b := &treeBuilder{
visited: map[string]bool{},
cfg: cfg,
baseDir: filepath.Dir(sourcePath),
}
root := &treeNode{
id: "root",
label: mermaidLabel(sourcePath),
class: "main",
jobs: directJobs(sourcePath),
}
b.buildChildren(root, rawIncludes)
return renderTree(root)
}
type treeNode struct {
id string
label string
class string
children []*treeNode
jobs []string // job names defined directly in this file
}
// treeBuilder accumulates state while recursively traversing include entries.
type treeBuilder struct {
counter int
visited map[string]bool // prevents infinite loops on circular includes
cfg fetcher.GitLabConfig
baseDir string // directory of the current pipeline file; changes for local includes
}
func (b *treeBuilder) nextID() string {
b.counter++
return fmt.Sprintf("inc%d", b.counter)
}
func (b *treeBuilder) buildChildren(parent *treeNode, rawIncludes []any) {
for _, entry := range rawIncludes {
parent.children = append(parent.children, b.parseEntry(entry)...)
}
}
func (b *treeBuilder) parseEntry(entry any) []*treeNode {
switch v := entry.(type) {
case string:
node := &treeNode{id: b.nextID(), label: "local: " + mermaidLabel(v), class: "local"}
b.recurseLocal(node, v)
return []*treeNode{node}
case map[string]any:
return b.parseMap(v)
}
return nil
}
func (b *treeBuilder) parseMap(m map[string]any) []*treeNode {
if comp, ok := m["component"].(string); ok {
node := &treeNode{
id: b.nextID(),
label: "component:<br>" + mermaidLabel(comp),
class: "component",
}
b.recurseComponent(node, comp)
return []*treeNode{node}
}
if proj, ok := m["project"].(string); ok {
ref, _ := m["ref"].(string)
if ref == "" {
ref = "HEAD"
}
files := includeFileList(m["file"])
if len(files) == 0 {
return []*treeNode{{
id: b.nextID(),
label: fmt.Sprintf("project: %s @ %s", mermaidLabel(proj), ref),
class: "project",
}}
}
var nodes []*treeNode
for _, f := range files {
node := &treeNode{
id: b.nextID(),
label: fmt.Sprintf("project: %s<br>%s @ %s", mermaidLabel(proj), mermaidLabel(f), ref),
class: "project",
}
b.recurseProject(node, proj, f, ref)
nodes = append(nodes, node)
}
return nodes
}
if local, ok := m["local"].(string); ok {
node := &treeNode{id: b.nextID(), label: "local: " + mermaidLabel(local), class: "local"}
b.recurseLocal(node, local)
return []*treeNode{node}
}
if remote, ok := m["remote"].(string); ok {
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 {
return []*treeNode{{id: b.nextID(), label: "template:<br>" + mermaidLabel(tmpl), class: "template"}}
}
return nil
}
func (b *treeBuilder) recurseLocal(node *treeNode, path string) {
absPath := filepath.Join(b.baseDir, path)
key := "local:" + absPath
if b.visited[key] {
return
}
b.visited[key] = true
data, err := os.ReadFile(absPath)
if err != nil {
return
}
p, err := model.ParseBytes(data)
if err != nil {
return
}
node.jobs = jobNames(p)
if len(p.Include) == 0 {
return
}
orig := b.baseDir
b.baseDir = filepath.Dir(absPath)
b.buildChildren(node, p.Include)
b.baseDir = orig
}
func (b *treeBuilder) recurseProject(node *treeNode, project, filePath, ref string) {
key := fmt.Sprintf("project:%s:%s@%s", project, filePath, ref)
if b.visited[key] || !b.cfg.HasToken() || filePath == "" {
return
}
b.visited[key] = true
data, err := b.cfg.FetchFile(project, filePath, ref)
if err != nil {
return
}
p, err := model.ParseBytes(data)
if err != nil {
return
}
node.jobs = jobNames(p)
if len(p.Include) == 0 {
return
}
b.buildChildren(node, p.Include)
}
func (b *treeBuilder) 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
}
b.buildChildren(node, p.Include)
}
func (b *treeBuilder) recurseComponent(node *treeNode, ref string) {
if strings.ContainsRune(ref, '$') {
return
}
key := "component:" + ref
if b.visited[key] {
return
}
b.visited[key] = true
host, project, component, version, err := parseComponentRef(ref)
if err != nil {
return
}
data, err := fetchComponentFile(b.cfg.ForHost(host), project, component, version)
if err != nil {
return
}
p, err := model.ParseBytes(data)
if err != nil {
return
}
node.jobs = jobNames(p)
if len(p.Include) == 0 {
return
}
b.buildChildren(node, p.Include)
}
func renderTree(root *treeNode) string {
var sb strings.Builder var sb strings.Builder
w := func(s string) { sb.WriteString(s + "\n") } w := func(s string) { sb.WriteString(s + "\n") }
wf := func(f string, a ...any) { fmt.Fprintf(&sb, f+"\n", a...) } wf := func(f string, a ...any) { fmt.Fprintf(&sb, f+"\n", a...) }
@@ -22,86 +236,85 @@ func Includes(sourcePath string, rawIncludes []any) 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("") w(" classDef job fill:#f5f5ff,stroke:#7175a0,color:#333")
wf(" root[\"%s\"]:::main", mermaidLabel(sourcePath))
if len(rawIncludes) == 0 {
return sb.String()
}
w("") w("")
counter := 0 jobCounter := 0
for _, entry := range rawIncludes { var emit func(n *treeNode)
for _, n := range parseIncludeEntry(entry, &counter) { emit = func(n *treeNode) {
wf(" %s[\"%s\"]:::%s", n.id, n.label, n.class) wf(" %s[\"%s\"]:::%s", n.id, n.label, n.class)
wf(" root --> %s", n.id) 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 {
emit(child)
wf(" %s --> %s", n.id, child.id)
} }
} }
emit(root)
return sb.String() return sb.String()
} }
type incNode struct { // directJobs parses a single pipeline file (without include resolution) and
id, label, class string // returns the sorted list of job names defined directly in it.
} func directJobs(path string) []string {
p, err := model.Parse(path)
func parseIncludeEntry(entry any, counter *int) []incNode { if err != nil {
newID := func() string {
*counter++
return fmt.Sprintf("inc%d", *counter)
}
switch v := entry.(type) {
case string:
return []incNode{{id: newID(), label: mermaidLabel(v), class: "local"}}
case map[string]any:
return parseIncludeMap(v, newID)
}
return nil return nil
} }
return jobNames(p)
}
func parseIncludeMap(m map[string]any, newID func() string) []incNode { // jobNames returns a sorted slice of all job names defined in p.
if comp, ok := m["component"].(string); ok { func jobNames(p *model.Pipeline) []string {
return []incNode{{ if len(p.Jobs) == 0 {
id: newID(),
label: "component:<br>" + mermaidLabel(comp),
class: "component",
}}
}
if proj, ok := m["project"].(string); ok {
ref, _ := m["ref"].(string)
if ref == "" {
ref = "HEAD"
}
files := includeFileList(m["file"])
if len(files) == 0 {
return []incNode{{
id: newID(),
label: fmt.Sprintf("project: %s @ %s", mermaidLabel(proj), ref),
class: "project",
}}
}
var nodes []incNode
for _, f := range files {
nodes = append(nodes, incNode{
id: newID(),
label: fmt.Sprintf("project: %s<br>%s @ %s", mermaidLabel(proj), mermaidLabel(f), ref),
class: "project",
})
}
return nodes
}
if local, ok := m["local"].(string); ok {
return []incNode{{id: newID(), label: "local: " + mermaidLabel(local), class: "local"}}
}
if remote, ok := m["remote"].(string); ok {
return []incNode{{id: newID(), label: "remote:<br>" + mermaidLabel(remote), class: "remote"}}
}
if tmpl, ok := m["template"].(string); ok {
return []incNode{{id: newID(), label: "template:<br>" + mermaidLabel(tmpl), class: "template"}}
}
return nil 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
// <host>/<project-path>/<component-name>@<version>.
func parseComponentRef(ref string) (host, project, component, version string, err error) {
atIdx := strings.LastIndex(ref, "@")
if atIdx < 0 || atIdx == len(ref)-1 {
err = fmt.Errorf("component reference %q must include a version", ref)
return
}
version = ref[atIdx+1:]
path := ref[:atIdx]
parts := strings.Split(path, "/")
if len(parts) < 3 {
err = fmt.Errorf("component reference %q must be <host>/<project>/<component>@<version>", ref)
return
}
host = parts[0]
component = parts[len(parts)-1]
project = strings.Join(parts[1:len(parts)-1], "/")
return
}
// fetchComponentFile fetches a component's template YAML, trying the single-file
// layout first and the directory layout as fallback.
func fetchComponentFile(cfg fetcher.GitLabConfig, project, component, version string) ([]byte, error) {
if data, err := cfg.FetchFile(project, "templates/"+component+".yml", version); err == nil {
return data, nil
}
data, err := cfg.FetchFile(project, "templates/"+component+"/template.yml", version)
if err != nil {
return nil, fmt.Errorf("component %s/%s@%s not found", project, component, version)
}
return data, nil
}
func includeFileList(v any) []string { func includeFileList(v any) []string {
switch f := v.(type) { switch f := v.(type) {
+622
View File
@@ -0,0 +1,622 @@
package graph
import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"git.k3nny.fr/glint/internal/fetcher"
"git.k3nny.fr/glint/internal/model"
)
// ── mermaidLabel ──────────────────────────────────────────────────────────────
func TestMermaidLabel(t *testing.T) {
cases := []struct{ in, want string }{
{"plain", "plain"},
{`has "quotes"`, "has 'quotes'"},
{"has#hash", "has&#35;hash"},
{`"quoted"#both`, "'quoted'&#35;both"},
}
for _, tc := range cases {
got := mermaidLabel(tc.in)
if got != tc.want {
t.Errorf("mermaidLabel(%q)=%q want %q", tc.in, got, tc.want)
}
}
}
// ── includeFileList ───────────────────────────────────────────────────────────
func TestIncludeFileList(t *testing.T) {
if got := includeFileList("file.yml"); len(got) != 1 || got[0] != "file.yml" {
t.Error("string form")
}
if got := includeFileList([]any{"a.yml", "b.yml"}); len(got) != 2 {
t.Error("slice form")
}
if got := includeFileList([]any{"ok.yml", 42}); len(got) != 1 {
t.Error("mixed slice drops non-strings")
}
if got := includeFileList(nil); got != nil {
t.Error("nil should return nil")
}
if got := includeFileList(123); got != nil {
t.Error("unknown type returns nil")
}
}
// ── parseComponentRef ─────────────────────────────────────────────────────────
func TestParseComponentRef(t *testing.T) {
cases := []struct {
ref string
wantHost string
wantProj string
wantComp string
wantVer string
wantErr bool
}{
{
ref: "gitlab.com/group/project/component@v1.0",
wantHost: "gitlab.com", wantProj: "group/project", wantComp: "component", wantVer: "v1.0",
},
{
ref: "gitlab.com/a/b/c/d@main",
wantHost: "gitlab.com", wantProj: "a/b/c", wantComp: "d", wantVer: "main",
},
{ref: "no-at-sign", wantErr: true},
{ref: "no-at-sign@", wantErr: true},
{ref: "host/comp@v1", wantErr: true}, // only 2 parts — need at least 3
}
for _, tc := range cases {
host, proj, comp, ver, err := parseComponentRef(tc.ref)
if tc.wantErr {
if err == nil { t.Errorf("parseComponentRef(%q): expected error", tc.ref) }
continue
}
if err != nil { t.Errorf("parseComponentRef(%q): %v", tc.ref, err) }
if host != tc.wantHost { t.Errorf("host: got %q want %q", host, tc.wantHost) }
if proj != tc.wantProj { t.Errorf("project: got %q want %q", proj, tc.wantProj) }
if comp != tc.wantComp { t.Errorf("component: got %q want %q", comp, tc.wantComp) }
if ver != tc.wantVer { t.Errorf("version: got %q want %q", ver, tc.wantVer) }
}
}
// ── jobNames ──────────────────────────────────────────────────────────────────
func TestJobNames_Empty(t *testing.T) {
p := &model.Pipeline{Jobs: map[string]model.Job{}}
if got := jobNames(p); got != nil {
t.Errorf("empty pipeline: expected nil, got %v", got)
}
}
// ── treeBuilder helpers ───────────────────────────────────────────────────────
func TestNextID(t *testing.T) {
b := &treeBuilder{visited: map[string]bool{}}
if b.nextID() != "inc1" { t.Error("first ID") }
if b.nextID() != "inc2" { t.Error("second ID") }
}
func TestParseEntry_String(t *testing.T) {
b := &treeBuilder{visited: map[string]bool{}, baseDir: "/tmp"}
nodes := b.parseEntry("some/local.yml")
if len(nodes) != 1 { t.Fatalf("expected 1 node, got %d", len(nodes)) }
if !strings.Contains(nodes[0].label, "local:") { t.Error("expected local: label") }
}
func TestParseEntry_Unknown(t *testing.T) {
b := &treeBuilder{visited: map[string]bool{}}
nodes := b.parseEntry(42) // unknown type
if len(nodes) != 0 { t.Error("expected no nodes for unknown type") }
}
func TestParseMap_Template(t *testing.T) {
b := &treeBuilder{visited: map[string]bool{}}
nodes := b.parseMap(map[string]any{"template": "Auto-DevOps"})
if len(nodes) != 1 { t.Fatalf("expected 1 node, got %d", len(nodes)) }
if !strings.Contains(nodes[0].label, "template:") { t.Error("expected template: label") }
}
func TestParseMap_Remote(t *testing.T) {
b := &treeBuilder{visited: map[string]bool{}, cfg: fetcher.GitLabConfig{}}
nodes := b.parseMap(map[string]any{"remote": "https://example.com/ci.yml"})
if len(nodes) != 1 { t.Fatalf("expected 1 node, got %d", len(nodes)) }
if !strings.Contains(nodes[0].label, "remote:") { t.Error("expected remote: label") }
}
func TestParseMap_Project_NoFiles(t *testing.T) {
b := &treeBuilder{visited: map[string]bool{}, cfg: fetcher.GitLabConfig{}}
nodes := b.parseMap(map[string]any{"project": "g/p"})
if len(nodes) != 1 { t.Fatalf("expected 1 node, got %d", len(nodes)) }
if !strings.Contains(nodes[0].label, "project:") { t.Error("expected project: label") }
}
func TestParseMap_Project_WithRef(t *testing.T) {
b := &treeBuilder{visited: map[string]bool{}, cfg: fetcher.GitLabConfig{}}
nodes := b.parseMap(map[string]any{
"project": "g/p",
"ref": "main",
"file": "templates/ci.yml",
})
if len(nodes) != 1 { t.Fatalf("expected 1 node, got %d", len(nodes)) }
}
func TestParseMap_Project_MultipleFiles(t *testing.T) {
b := &treeBuilder{visited: map[string]bool{}, cfg: fetcher.GitLabConfig{}}
nodes := b.parseMap(map[string]any{
"project": "g/p",
"file": []any{"a.yml", "b.yml"},
})
if len(nodes) != 2 { t.Fatalf("expected 2 nodes, got %d", len(nodes)) }
}
func TestParseMap_Component_WithDollar(t *testing.T) {
b := &treeBuilder{visited: map[string]bool{}}
// Component ref with $ — skipped (dynamic, can't resolve)
nodes := b.parseMap(map[string]any{"component": "${CI_SERVER_HOST}/group/project/comp@v1"})
if len(nodes) != 1 { t.Fatalf("expected 1 node, got %d", len(nodes)) }
if nodes[0].jobs != nil { t.Error("jobs should be nil for unresolvable component") }
}
func TestParseMap_Component_BadRef(t *testing.T) {
b := &treeBuilder{visited: map[string]bool{}, cfg: fetcher.GitLabConfig{}}
nodes := b.parseMap(map[string]any{"component": "no-at-sign"})
if len(nodes) != 1 { t.Fatalf("expected 1 node, got %d", len(nodes)) }
}
func TestParseMap_Unknown(t *testing.T) {
b := &treeBuilder{visited: map[string]bool{}}
nodes := b.parseMap(map[string]any{"unknown_key": "value"})
if len(nodes) != 0 { t.Error("expected no nodes for unknown map keys") }
}
// ── recurseLocal ──────────────────────────────────────────────────────────────
func TestRecurseLocal_FileNotFound(t *testing.T) {
b := &treeBuilder{visited: map[string]bool{}, baseDir: "/nonexistent"}
node := &treeNode{id: "n1"}
b.recurseLocal(node, "missing.yml")
if node.jobs != nil { t.Error("jobs should remain nil on missing file") }
}
func TestRecurseLocal_ValidFile(t *testing.T) {
dir := t.TempDir()
ciPath := filepath.Join(dir, "ci.yml")
if err := os.WriteFile(ciPath, []byte("job1:\n script: echo\n"), 0o644); err != nil {
t.Fatal(err)
}
b := &treeBuilder{visited: map[string]bool{}, baseDir: dir}
node := &treeNode{id: "n1"}
b.recurseLocal(node, "ci.yml")
if len(node.jobs) == 0 { t.Error("expected jobs to be populated") }
}
func TestRecurseLocal_VisitedPrevents_Loop(t *testing.T) {
dir := t.TempDir()
ciPath := filepath.Join(dir, "ci.yml")
if err := os.WriteFile(ciPath, []byte("job1:\n script: echo\n"), 0o644); err != nil {
t.Fatal(err)
}
b := &treeBuilder{
visited: map[string]bool{"local:" + ciPath: true},
baseDir: dir,
}
node := &treeNode{id: "n1"}
b.recurseLocal(node, "ci.yml")
if len(node.jobs) != 0 { t.Error("visited node should not be parsed again") }
}
func TestRecurseLocal_WithNestedIncludes(t *testing.T) {
dir := t.TempDir()
// parent.yml includes child.yml
childPath := filepath.Join(dir, "child.yml")
if err := os.WriteFile(childPath, []byte("child-job:\n script: echo\n"), 0o644); err != nil {
t.Fatal(err)
}
parentPath := filepath.Join(dir, "parent.yml")
parentContent := "include:\n - local: child.yml\nparent-job:\n script: echo\n"
if err := os.WriteFile(parentPath, []byte(parentContent), 0o644); err != nil {
t.Fatal(err)
}
b := &treeBuilder{visited: map[string]bool{}, baseDir: dir}
node := &treeNode{id: "n1"}
b.recurseLocal(node, "parent.yml")
if len(node.children) == 0 { t.Error("expected children from nested include") }
}
func TestRecurseLocal_InvalidYAML(t *testing.T) {
dir := t.TempDir()
ciPath := filepath.Join(dir, "bad.yml")
if err := os.WriteFile(ciPath, []byte(":\tbad\tyaml\n"), 0o644); err != nil {
t.Fatal(err)
}
b := &treeBuilder{visited: map[string]bool{}, baseDir: dir}
node := &treeNode{id: "n1"}
b.recurseLocal(node, "bad.yml")
if node.jobs != nil { t.Error("invalid YAML should not set jobs") }
}
// ── directJobs ────────────────────────────────────────────────────────────────
func TestDirectJobs_MissingFile(t *testing.T) {
if jobs := directJobs("/nonexistent/ci.yml"); jobs != nil {
t.Error("expected nil for missing file")
}
}
func TestDirectJobs_ValidFile(t *testing.T) {
dir := t.TempDir()
ciPath := filepath.Join(dir, "ci.yml")
if err := os.WriteFile(ciPath, []byte("build:\n script: make\n"), 0o644); err != nil {
t.Fatal(err)
}
jobs := directJobs(ciPath)
if len(jobs) == 0 { t.Error("expected jobs from valid file") }
}
// ── renderTree ────────────────────────────────────────────────────────────────
func TestRenderTree_Basic(t *testing.T) {
root := &treeNode{
id: "root",
label: "main.yml",
class: "main",
jobs: []string{"job-a", "job-b"},
children: []*treeNode{
{id: "inc1", label: "local: child.yml", class: "local"},
},
}
out := renderTree(root)
if !strings.Contains(out, "main.yml") { t.Error("expected root label") }
if !strings.Contains(out, "job-a") { t.Error("expected job-a") }
if !strings.Contains(out, "child.yml") { t.Error("expected child label") }
if !strings.Contains(out, "root --> inc1") { t.Error("expected edge root→inc1") }
}
// ── Includes (integration) ────────────────────────────────────────────────────
func TestIncludes_LocalFile(t *testing.T) {
dir := t.TempDir()
childPath := filepath.Join(dir, "child.yml")
if err := os.WriteFile(childPath, []byte("child-job:\n script: echo\n"), 0o644); err != nil {
t.Fatal(err)
}
sourcePath := filepath.Join(dir, "pipeline.yml")
rawIncludes := []any{map[string]any{"local": "child.yml"}}
out := Includes(sourcePath, rawIncludes, fetcher.GitLabConfig{})
if !strings.Contains(out, "local:") { t.Error("expected local: node") }
}
func TestIncludes_NoIncludes(t *testing.T) {
dir := t.TempDir()
sourcePath := filepath.Join(dir, "pipeline.yml")
out := Includes(sourcePath, nil, fetcher.GitLabConfig{})
if !strings.Contains(out, "flowchart") { t.Error("expected flowchart") }
}
// ── fetchComponentFile (graph package) ────────────────────────────────────────
func TestGraphFetchComponentFile_PrimarySuccess(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
fmt.Fprintln(w, "comp-job:\n script: echo")
}))
defer srv.Close()
cfg := fetcher.GitLabConfig{BaseURL: srv.URL}
data, err := fetchComponentFile(cfg, "g/p", "mycomp", "v1")
if err != nil {
t.Fatalf("primary success: unexpected error: %v", err)
}
if len(data) == 0 {
t.Error("expected non-empty data")
}
}
// ── recurseRemote ─────────────────────────────────────────────────────────────
func TestRecurseRemote_AlreadyVisited(t *testing.T) {
b := &treeBuilder{
visited: map[string]bool{"remote:http://example.com/ci.yml": true},
cfg: fetcher.GitLabConfig{},
baseDir: "/tmp",
}
node := &treeNode{id: "n1"}
b.recurseRemote(node, "http://example.com/ci.yml")
// Visited: nothing should be fetched or added.
}
func TestRecurseRemote_ValidWithJobs(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
fmt.Fprintln(w, "remote-job:\n script: echo")
}))
defer srv.Close()
b := &treeBuilder{
visited: map[string]bool{},
cfg: fetcher.GitLabConfig{},
baseDir: "/tmp",
}
node := &treeNode{id: "n1"}
b.recurseRemote(node, srv.URL+"/ci.yml")
if len(node.jobs) == 0 {
t.Error("expected job names from remote YAML")
}
}
func TestRecurseRemote_InvalidYAML(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
fmt.Fprintln(w, ":\tbad\tyaml")
}))
defer srv.Close()
b := &treeBuilder{
visited: map[string]bool{},
cfg: fetcher.GitLabConfig{},
baseDir: "/tmp",
}
node := &treeNode{id: "n1"}
b.recurseRemote(node, srv.URL+"/bad.yml")
// ParseBytes fails → early return; no panic.
}
func TestRecurseRemote_WithSubIncludes(t *testing.T) {
// The remote YAML includes a second URL (sub-includes path).
var callCount int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
w.WriteHeader(200)
if callCount == 1 {
// First call: returns YAML with a remote sub-include.
fmt.Fprintln(w, "include:\n - remote: http://127.0.0.1:0/sub.yml\nparent-job:\n script: echo")
} else {
// Sub-include fetch will fail (connection refused) — that's OK; we
// just need the sub-include routing path to be executed.
w.WriteHeader(500)
}
}))
defer srv.Close()
b := &treeBuilder{
visited: map[string]bool{},
cfg: fetcher.GitLabConfig{},
baseDir: "/tmp",
counter: 0,
}
node := &treeNode{id: "n1"}
b.recurseRemote(node, srv.URL+"/ci.yml")
// parent-job should be in node.jobs even if sub-include fails.
if len(node.jobs) == 0 {
t.Error("expected job names from remote YAML")
}
}
// ── recurseComponent ─────────────────────────────────────────────────────────
// tlsHost returns a helper that creates a TLS test server, swaps http.DefaultTransport
// so the test client trusts the self-signed cert, and returns (close, host).
func tlsComponent(t *testing.T, handler http.HandlerFunc) (host string) {
t.Helper()
srv := httptest.NewTLSServer(handler)
t.Cleanup(srv.Close)
orig := http.DefaultTransport
http.DefaultTransport = srv.Client().Transport
t.Cleanup(func() { http.DefaultTransport = orig })
return srv.URL[len("https://"):]
}
func TestRecurseComponent_AlreadyVisited(t *testing.T) {
b := &treeBuilder{
visited: map[string]bool{"component:gitlab.com/g/p/comp@v1": true},
cfg: fetcher.GitLabConfig{},
baseDir: "/tmp",
}
node := &treeNode{id: "n1"}
b.recurseComponent(node, "gitlab.com/g/p/comp@v1")
// Already visited: nothing happens.
}
func TestRecurseComponent_DollarRef(t *testing.T) {
// ref containing $ → early return (line 196-197)
b := &treeBuilder{visited: map[string]bool{}, cfg: fetcher.GitLabConfig{}}
node := &treeNode{id: "n1"}
b.recurseComponent(node, "$CI_SERVER/g/p/comp@v1")
// no panic, node unchanged
}
func TestRecurseComponent_BadRef(t *testing.T) {
// ref with no @ → parseComponentRef fails (line 206-208)
b := &treeBuilder{visited: map[string]bool{}, cfg: fetcher.GitLabConfig{}}
node := &treeNode{id: "n1"}
b.recurseComponent(node, "gitlab.com/g/p/mycomp-no-version")
// no panic
}
func TestRecurseComponent_FetchError(t *testing.T) {
host := tlsComponent(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(404)
})
b := &treeBuilder{
visited: map[string]bool{},
cfg: fetcher.GitLabConfig{},
baseDir: "/tmp",
}
node := &treeNode{id: "n1"}
b.recurseComponent(node, host+"/g/p/mycomp@v1")
// 404 → fetch error → early return; no panic.
}
func TestRecurseComponent_InvalidYAML(t *testing.T) {
host := tlsComponent(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
fmt.Fprintln(w, ":\tbad\tyaml")
})
b := &treeBuilder{
visited: map[string]bool{},
cfg: fetcher.GitLabConfig{},
baseDir: "/tmp",
}
node := &treeNode{id: "n1"}
b.recurseComponent(node, host+"/g/p/mycomp@v1")
// fetch succeeds but ParseBytes fails → early return; no panic.
}
func TestRecurseComponent_ValidYAML(t *testing.T) {
host := tlsComponent(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
fmt.Fprintln(w, "comp-job:\n script: echo component")
})
b := &treeBuilder{
visited: map[string]bool{},
cfg: fetcher.GitLabConfig{},
baseDir: "/tmp",
}
node := &treeNode{id: "n1"}
b.recurseComponent(node, host+"/g/p/mycomp@v1")
if len(node.jobs) == 0 {
t.Error("expected job names from component YAML")
}
}
// ── recurseProject ────────────────────────────────────────────────────────────
func TestRecurseProject_AlreadyVisited(t *testing.T) {
b := &treeBuilder{
visited: map[string]bool{"project:g/p:ci.yml@main": true},
cfg: fetcher.GitLabConfig{Token: "tok"},
baseDir: "/tmp",
}
node := &treeNode{id: "n1"}
b.recurseProject(node, "g/p", "ci.yml", "main")
// Already visited: nothing happens.
}
func TestRecurseProject_WithToken(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
fmt.Fprintln(w, "proj-job:\n script: echo project")
}))
defer srv.Close()
b := &treeBuilder{
visited: map[string]bool{},
cfg: fetcher.GitLabConfig{BaseURL: srv.URL, Token: "tok"},
baseDir: "/tmp",
}
node := &treeNode{id: "n1"}
b.recurseProject(node, "g/p", "ci.yml", "main")
if len(node.jobs) == 0 {
t.Error("expected job names from project YAML")
}
}
func TestRecurseProject_FetchError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(404)
}))
defer srv.Close()
b := &treeBuilder{
visited: map[string]bool{},
cfg: fetcher.GitLabConfig{BaseURL: srv.URL, Token: "tok"},
baseDir: "/tmp",
}
node := &treeNode{id: "n1"}
b.recurseProject(node, "g/p", "ci.yml", "main")
// fetch fails → early return; no panic.
}
func TestRecurseProject_InvalidYAML(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
fmt.Fprintln(w, ":\tbad\tyaml")
}))
defer srv.Close()
b := &treeBuilder{
visited: map[string]bool{},
cfg: fetcher.GitLabConfig{BaseURL: srv.URL, Token: "tok"},
baseDir: "/tmp",
}
node := &treeNode{id: "n1"}
b.recurseProject(node, "g/p", "ci.yml", "main")
// ParseBytes fails → early return; no panic.
}
func TestRecurseProject_WithSubIncludes(t *testing.T) {
// Project YAML includes sub-includes → covers recurseProject line 170 (buildChildren).
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
fmt.Fprintln(w, "include:\n - remote: http://127.0.0.1:0/sub.yml\nproj-job:\n script: echo")
}))
defer srv.Close()
b := &treeBuilder{
visited: map[string]bool{},
cfg: fetcher.GitLabConfig{BaseURL: srv.URL, Token: "tok"},
baseDir: "/tmp",
}
node := &treeNode{id: "n1"}
b.recurseProject(node, "g/p", "ci.yml", "main")
// proj-job should be populated; sub-include fetch fails (port 0) — that's fine.
if len(node.jobs) == 0 {
t.Error("expected job names from project YAML")
}
}
func TestRecurseComponent_WithSubIncludes(t *testing.T) {
// Component YAML includes sub-includes → covers recurseComponent line 221 (buildChildren).
host := tlsComponent(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
fmt.Fprintln(w, "include:\n - remote: http://127.0.0.1:0/sub.yml\ncomp-job:\n script: echo")
})
b := &treeBuilder{
visited: map[string]bool{},
cfg: fetcher.GitLabConfig{},
baseDir: "/tmp",
}
node := &treeNode{id: "n1"}
b.recurseComponent(node, host+"/g/p/mycomp@v1")
if len(node.jobs) == 0 {
t.Error("expected job names from component YAML")
}
}
// ── fetchComponentFile (fallback path) ───────────────────────────────────────
func TestGraphFetchComponentFile_FallbackSuccess(t *testing.T) {
// Primary path (templates/comp.yml) returns 404; fallback (templates/comp/template.yml) returns 200.
var reqCount int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reqCount++
if reqCount == 1 {
w.WriteHeader(404)
} else {
w.WriteHeader(200)
fmt.Fprintln(w, "fallback-job:\n script: echo")
}
}))
defer srv.Close()
cfg := fetcher.GitLabConfig{BaseURL: srv.URL}
data, err := fetchComponentFile(cfg, "g/p", "mycomp", "v1")
if err != nil {
t.Fatalf("fallback success: unexpected error: %v", err)
}
if len(data) == 0 {
t.Error("expected non-empty data from fallback path")
}
}
// Ensure model import is used.
var _ = model.Job{}
+1 -4
View File
@@ -5,7 +5,7 @@ import (
"sort" "sort"
"strings" "strings"
"git.k3nny.fr/gitlab-sim/internal/model" "git.k3nny.fr/glint/internal/model"
) )
// Pipeline returns a Mermaid flowchart of pipeline jobs grouped by stage. // Pipeline returns a Mermaid flowchart of pipeline jobs grouped by stage.
@@ -82,9 +82,6 @@ func Pipeline(p *model.Pipeline) string {
// Emit one subgraph per stage. // Emit one subgraph per stage.
for _, stage := range stages { for _, stage := range stages {
jobs := byStage[stage] jobs := byStage[stage]
if len(jobs) == 0 {
continue
}
sort.Strings(jobs) sort.Strings(jobs)
wf(" subgraph %s[\"%s\"]", stageID(stage), stage) wf(" subgraph %s[\"%s\"]", stageID(stage), stage)
for _, name := range jobs { for _, name := range jobs {
+120
View File
@@ -0,0 +1,120 @@
package graph
import (
"strings"
"testing"
"git.k3nny.fr/glint/internal/model"
)
func TestPipeline_EmptyJobs(t *testing.T) {
p := &model.Pipeline{}
out := Pipeline(p)
if !strings.Contains(out, "no jobs defined") {
t.Errorf("expected 'no jobs defined', got:\n%s", out)
}
}
func TestPipeline_BasicTwoStages(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"build", "test"},
Jobs: map[string]model.Job{
"build-job": {Name: "build-job", Stage: "build"},
"test-job": {Name: "test-job", Stage: "test"},
".tmpl": {Name: ".tmpl", Stage: "build"}, // hidden — excluded
},
}
out := Pipeline(p)
if !strings.Contains(out, "build-job") { t.Error("expected build-job") }
if !strings.Contains(out, "test-job") { t.Error("expected test-job") }
if strings.Contains(out, ".tmpl") { t.Error(".tmpl should be excluded") }
// Classic mode: stage → stage connector
if !strings.Contains(out, "Classic") { t.Error("expected classic mode comment") }
}
func TestPipeline_DAGMode(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"build", "test"},
Jobs: map[string]model.Job{
"build-job": {Name: "build-job", Stage: "build"},
"test-job": {Name: "test-job", Stage: "test", Needs: []any{"build-job"}},
},
}
out := Pipeline(p)
if !strings.Contains(out, "DAG") { t.Error("expected DAG mode comment") }
}
func TestPipeline_DAGMode_CrossPipelineNeed(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"build"},
Jobs: map[string]model.Job{
"build-job": {Name: "build-job", Stage: "build",
Needs: []any{map[string]any{"pipeline": "other", "job": "j"}}},
},
}
out := Pipeline(p)
// cross-pipeline need should not add an arrow (job not in local map)
if !strings.Contains(out, "DAG") { t.Error("expected DAG mode comment") }
}
func TestPipeline_JobNoStage(t *testing.T) {
p := &model.Pipeline{
Jobs: map[string]model.Job{
"myjob": {Name: "myjob"},
},
}
out := Pipeline(p)
if !strings.Contains(out, "myjob") { t.Error("expected myjob") }
}
func TestPipeline_SingleStage(t *testing.T) {
// Single stage → no connector
p := &model.Pipeline{
Stages: []string{"build"},
Jobs: map[string]model.Job{
"a": {Name: "a", Stage: "build"},
},
}
out := Pipeline(p)
if strings.Contains(out, "-->") { t.Error("single stage should not have connectors") }
}
// ── stageID / sanitizeID ──────────────────────────────────────────────────────
func TestStageID(t *testing.T) {
if stageID("build") != "stage_build" { t.Error("plain name") }
if stageID("my-stage") != "stage_my_stage" { t.Error("hyphens replaced") }
}
func TestSanitizeID(t *testing.T) {
cases := []struct{ in, want string }{
{"build", "build"},
{"my-stage", "my_stage"},
{"with space", "with_space"},
{"ABC_123", "ABC_123"},
}
for _, tc := range cases {
if sanitizeID(tc.in) != tc.want {
t.Errorf("sanitizeID(%q)=%q want %q", tc.in, sanitizeID(tc.in), tc.want)
}
}
}
// ── jobClass ──────────────────────────────────────────────────────────────────
func TestJobClass(t *testing.T) {
if jobClass(model.Job{When: "manual"}) != "manual" { t.Error("manual") }
if jobClass(model.Job{When: "delayed"}) != "delayed" { t.Error("delayed") }
if jobClass(model.Job{Trigger: "x"}) != "trigger" { t.Error("trigger") }
if jobClass(model.Job{}) != "regular" { t.Error("regular") }
}
// ── needsJobName ──────────────────────────────────────────────────────────────
func TestNeedsJobName(t *testing.T) {
if needsJobName("job-a") != "job-a" { t.Error("string form") }
if needsJobName(map[string]any{"job": "job-b"}) != "job-b" { t.Error("map form") }
if needsJobName(map[string]any{"pipeline": "other", "job": "j"}) != "" { t.Error("cross-pipeline should return empty") }
if needsJobName(map[string]any{"no-job": true}) != "" { t.Error("map without job key") }
if needsJobName(42) != "" { t.Error("unexpected type") }
}
+16 -21
View File
@@ -10,7 +10,7 @@ import (
"strings" "strings"
"time" "time"
"git.k3nny.fr/gitlab-sim/internal/model" "git.k3nny.fr/glint/internal/model"
) )
// Layout constants (pixels) tuned to resemble GitLab's full pipeline graph view. // Layout constants (pixels) tuned to resemble GitLab's full pipeline graph view.
@@ -37,10 +37,7 @@ func RenderPipeline(p *model.Pipeline, outDir string) (string, error) {
return "", fmt.Errorf("creating output directory %s: %w", outDir, err) return "", fmt.Errorf("creating output directory %s: %w", outDir, err)
} }
svg, err := pipelineSVG(p) svg := pipelineSVG(p)
if err != nil {
return "", err
}
ts := time.Now().Format("20060102-150405") ts := time.Now().Format("20060102-150405")
svgPath := filepath.Join(outDir, "pipeline-"+ts+".svg") svgPath := filepath.Join(outDir, "pipeline-"+ts+".svg")
@@ -58,21 +55,19 @@ func RenderPipeline(p *model.Pipeline, outDir string) (string, error) {
// convertToPNG tries rsvg-convert, Inkscape, magick, and convert (not on Windows). // convertToPNG tries rsvg-convert, Inkscape, magick, and convert (not on Windows).
func convertToPNG(svgPath, pngPath string) bool { func convertToPNG(svgPath, pngPath string) bool {
type cand struct { candidates := [][]string{
args []string {"rsvg-convert", "--output", pngPath, svgPath},
skipWin bool {"inkscape", "--export-filename=" + pngPath, svgPath},
{"magick", svgPath, pngPath},
} }
for _, c := range []cand{ // `convert` is the legacy ImageMagick name; skip on Windows where the name
{[]string{"rsvg-convert", "--output", pngPath, svgPath}, false}, // collides with the built-in FAT→NTFS converter.
{[]string{"inkscape", "--export-filename=" + pngPath, svgPath}, false}, if runtime.GOOS != "windows" {
{[]string{"magick", svgPath, pngPath}, false}, candidates = append(candidates, []string{"convert", svgPath, pngPath})
{[]string{"convert", svgPath, pngPath}, true},
} {
if c.skipWin && runtime.GOOS == "windows" {
continue
} }
if bin, err := exec.LookPath(c.args[0]); err == nil { for _, args := range candidates {
if exec.Command(bin, c.args[1:]...).Run() == nil { if bin, err := exec.LookPath(args[0]); err == nil {
if exec.Command(bin, args[1:]...).Run() == nil {
return true return true
} }
} }
@@ -80,7 +75,7 @@ func convertToPNG(svgPath, pngPath string) bool {
return false return false
} }
func pipelineSVG(p *model.Pipeline) (string, error) { func pipelineSVG(p *model.Pipeline) string {
// Collect visible (non-template) job names in sorted order. // Collect visible (non-template) job names in sorted order.
var visible []string var visible []string
for name := range p.Jobs { for name := range p.Jobs {
@@ -91,7 +86,7 @@ func pipelineSVG(p *model.Pipeline) (string, error) {
sort.Strings(visible) sort.Strings(visible)
if len(visible) == 0 { if len(visible) == 0 {
return svgEmpty(), nil return svgEmpty()
} }
// Group by stage; fall back to "test" (GitLab default) when stage is unset. // Group by stage; fall back to "test" (GitLab default) when stage is unset.
@@ -290,7 +285,7 @@ func pipelineSVG(p *model.Pipeline) (string, error) {
} }
w(`</svg>`) w(`</svg>`)
return sb.String(), nil return sb.String()
} }
// drawChipIcon writes an SVG symbol inside the status circle to help identify // drawChipIcon writes an SVG symbol inside the status circle to help identify
+347
View File
@@ -0,0 +1,347 @@
package graph
import (
"os"
"strings"
"testing"
"git.k3nny.fr/glint/internal/model"
)
// ── svgEsc ────────────────────────────────────────────────────────────────────
func TestSvgEsc(t *testing.T) {
cases := []struct{ in, want string }{
{"hello", "hello"},
{"a&b", "a&amp;b"},
{"<tag>", "&lt;tag&gt;"},
{"a&b<c>d", "a&amp;b&lt;c&gt;d"},
}
for _, tc := range cases {
if got := svgEsc(tc.in); got != tc.want {
t.Errorf("svgEsc(%q)=%q want %q", tc.in, got, tc.want)
}
}
}
// ── svgTrunc ──────────────────────────────────────────────────────────────────
func TestSvgTrunc(t *testing.T) {
cases := []struct {
s string
maxLen int
want string
}{
{"short", 20, "short"},
{"exactly20charslong!", 20, "exactly20charslong!"},
{"this-is-a-very-long-job-name", 20, "this-is-a-very-long…"},
{"αβγδεζηθικλμνξο", 5, "αβγδ…"},
}
for _, tc := range cases {
got := svgTrunc(tc.s, tc.maxLen)
if got != tc.want {
t.Errorf("svgTrunc(%q, %d)=%q want %q", tc.s, tc.maxLen, got, tc.want)
}
}
}
// ── svgEmpty ──────────────────────────────────────────────────────────────────
func TestSvgEmpty(t *testing.T) {
out := svgEmpty()
if !strings.Contains(out, "<svg") { t.Error("expected <svg") }
if !strings.Contains(out, "no jobs defined") { t.Error("expected 'no jobs defined'") }
}
// ── chipColor ─────────────────────────────────────────────────────────────────
func TestChipColor(t *testing.T) {
if chipColor(model.Job{Trigger: "x"}) != "#6b4fbb" { t.Error("trigger color") }
if chipColor(model.Job{When: "manual"}) != "#fc6d26" { t.Error("manual color") }
if chipColor(model.Job{When: "delayed"}) != "#fca326" { t.Error("delayed color") }
if chipColor(model.Job{}) != "#1f75cb" { t.Error("regular color") }
}
// ── drawChipIcon ──────────────────────────────────────────────────────────────
func TestDrawChipIcon(t *testing.T) {
cases := []struct {
job model.Job
wantTag string
}{
{model.Job{Trigger: "x"}, "<polyline"},
{model.Job{When: "manual"}, "<polygon"},
{model.Job{When: "delayed"}, "<circle"},
{model.Job{}, "<polyline"},
}
for _, tc := range cases {
var sb strings.Builder
drawChipIcon(&sb, tc.job, 10, 10)
if !strings.Contains(sb.String(), tc.wantTag) {
t.Errorf("drawChipIcon(%q): want %s, got:\n%s", tc.job.When, tc.wantTag, sb.String())
}
}
}
// ── pipelineSVG ───────────────────────────────────────────────────────────────
func TestPipelineSVG_Empty(t *testing.T) {
svg := pipelineSVG(&model.Pipeline{})
if !strings.Contains(svg, "no jobs defined") {
t.Errorf("expected 'no jobs defined', got:\n%s", svg)
}
}
func TestPipelineSVG_ClassicMode(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"build", "test"},
Jobs: map[string]model.Job{
"build-job": {Name: "build-job", Stage: "build"},
"test-job": {Name: "test-job", Stage: "test"},
},
}
svg := pipelineSVG(p)
if !strings.Contains(svg, "<svg") { t.Error("expected <svg") }
if !strings.Contains(svg, "build-job") { t.Error("expected build-job") }
if !strings.Contains(svg, "test-job") { t.Error("expected test-job") }
// Classic mode connector
if !strings.Contains(svg, "<polyline") && !strings.Contains(svg, "<line") {
t.Error("expected connector in classic mode")
}
}
func TestPipelineSVG_DAGMode(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"build", "test"},
Jobs: map[string]model.Job{
"build-job": {Name: "build-job", Stage: "build"},
"test-job": {Name: "test-job", Stage: "test", Needs: []any{"build-job"}},
},
}
svg := pipelineSVG(p)
// DAG mode draws <path> bezier curves
if !strings.Contains(svg, "<path") {
t.Error("expected <path> connector in DAG mode")
}
}
func TestPipelineSVG_DAGMode_StraightConnector(t *testing.T) {
// Two stages at same height → straight <line> connector in classic mode
p := &model.Pipeline{
Stages: []string{"a", "b"},
Jobs: map[string]model.Job{
"j1": {Name: "j1", Stage: "a"},
"j2": {Name: "j2", Stage: "b"},
},
}
svg := pipelineSVG(p)
if !strings.Contains(svg, "<svg") { t.Error("expected <svg") }
}
func TestPipelineSVG_AllJobTypes(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"deploy"},
Jobs: map[string]model.Job{
"manual-job": {Name: "manual-job", Stage: "deploy", When: "manual"},
"delayed-job": {Name: "delayed-job", Stage: "deploy", When: "delayed"},
"trigger-job": {Name: "trigger-job", Stage: "deploy", Trigger: "other/project"},
},
}
svg := pipelineSVG(p)
// Each type has its own color in the SVG
if !strings.Contains(svg, "#fc6d26") { t.Error("expected manual color") }
if !strings.Contains(svg, "#fca326") { t.Error("expected delayed color") }
if !strings.Contains(svg, "#6b4fbb") { t.Error("expected trigger color") }
}
func TestPipelineSVG_JobNoStage(t *testing.T) {
p := &model.Pipeline{
Jobs: map[string]model.Job{
"myjob": {Name: "myjob"},
},
}
svg := pipelineSVG(p)
if !strings.Contains(svg, "myjob") { t.Error("expected myjob") }
}
func TestPipelineSVG_CrossPipelineNeed(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"build"},
Jobs: map[string]model.Job{
"j": {Name: "j", Stage: "build",
Needs: []any{map[string]any{"pipeline": "other", "job": "x"}}},
},
}
svg := pipelineSVG(p)
if !strings.Contains(svg, "<svg") { t.Error("expected <svg") }
}
// ── RenderPipeline ────────────────────────────────────────────────────────────
func TestRenderPipeline(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"build"},
Jobs: map[string]model.Job{
"build-job": {Name: "build-job", Stage: "build"},
},
}
dir := t.TempDir()
path, err := RenderPipeline(p, dir)
if err != nil {
t.Fatalf("RenderPipeline: %v", err)
}
if _, statErr := os.Stat(path); statErr != nil {
t.Errorf("output file not found: %v", statErr)
}
}
func TestRenderPipeline_InvalidDir(t *testing.T) {
// Writing to a path under a file (not a dir) should error.
dir := t.TempDir()
filePath := dir + "/notadir"
if err := os.WriteFile(filePath, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
_, err := RenderPipeline(&model.Pipeline{}, filePath+"/nested")
if err == nil {
t.Error("expected error writing to path under a file")
}
}
func TestRenderPipeline_WriteFileFails(t *testing.T) {
if os.Getuid() == 0 {
t.Skip("skipping as root — file permissions don't apply")
}
dir := t.TempDir()
if err := os.Chmod(dir, 0o555); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.Chmod(dir, 0o755) })
p := &model.Pipeline{
Stages: []string{"build"},
Jobs: map[string]model.Job{"j": {Name: "j", Stage: "build"}},
}
_, err := RenderPipeline(p, dir)
if err == nil {
t.Error("expected error when writing SVG to read-only directory")
}
}
// ── convertToPNG ─────────────────────────────────────────────────────────────
func TestRenderPipeline_SVGFallback(t *testing.T) {
// With no converters on PATH, RenderPipeline returns the SVG path (line 53).
origPath := os.Getenv("PATH")
os.Setenv("PATH", "")
t.Cleanup(func() { os.Setenv("PATH", origPath) })
p := &model.Pipeline{
Stages: []string{"build"},
Jobs: map[string]model.Job{"j": {Name: "j", Stage: "build"}},
}
dir := t.TempDir()
path, err := RenderPipeline(p, dir)
if err != nil {
t.Fatalf("RenderPipeline: %v", err)
}
if !strings.HasSuffix(path, ".svg") {
t.Errorf("expected .svg output when no converter available, got %q", path)
}
}
func TestConvertToPNG_NoConverter(t *testing.T) {
// In a test environment without rsvg-convert/inkscape/magick, returns false.
// We can't assert false because the CI machine might have one of them,
// but we can assert it doesn't panic.
dir := t.TempDir()
svgPath := dir + "/test.svg"
pngPath := dir + "/test.png"
_ = os.WriteFile(svgPath, []byte(`<svg xmlns="http://www.w3.org/2000/svg"/>`), 0o644)
// result is either true (converter found) or false (no converter) — just no panic
convertToPNG(svgPath, pngPath)
}
func TestConvertToPNG_FakeConverter(t *testing.T) {
// Install a fake rsvg-convert that just creates the output file and exits 0.
// This exercises the "return true" branch (line 72) and os.Remove in RenderPipeline.
binDir := t.TempDir()
script := "#!/bin/sh\n# rsvg-convert --output <out> <in>\ncp \"$3\" \"$2\"\n"
fakeBin := binDir + "/rsvg-convert"
if err := os.WriteFile(fakeBin, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
origPath := os.Getenv("PATH")
os.Setenv("PATH", binDir+":"+origPath)
t.Cleanup(func() { os.Setenv("PATH", origPath) })
svgDir := t.TempDir()
svgPath := svgDir + "/test.svg"
pngPath := svgDir + "/test.png"
if err := os.WriteFile(svgPath, []byte(`<svg xmlns="http://www.w3.org/2000/svg"/>`), 0o644); err != nil {
t.Fatal(err)
}
ok := convertToPNG(svgPath, pngPath)
if !ok {
t.Error("expected convertToPNG to return true with fake rsvg-convert")
}
}
func TestRenderPipeline_PNGConversion(t *testing.T) {
// Verify the PNG path is returned (and SVG removed) when converter succeeds.
binDir := t.TempDir()
script := "#!/bin/sh\ncp \"$3\" \"$2\"\n"
if err := os.WriteFile(binDir+"/rsvg-convert", []byte(script), 0o755); err != nil {
t.Fatal(err)
}
origPath := os.Getenv("PATH")
os.Setenv("PATH", binDir+":"+origPath)
t.Cleanup(func() { os.Setenv("PATH", origPath) })
p := &model.Pipeline{
Stages: []string{"build"},
Jobs: map[string]model.Job{"j": {Name: "j", Stage: "build"}},
}
dir := t.TempDir()
path, err := RenderPipeline(p, dir)
if err != nil {
t.Fatalf("RenderPipeline: %v", err)
}
if !strings.HasSuffix(path, ".png") {
t.Errorf("expected .png output, got %q", path)
}
}
// ── pipelineSVG coverage gaps ─────────────────────────────────────────────────
func TestPipelineSVG_LShapedElbow(t *testing.T) {
// Classic mode with unequal stage sizes → different column heights → L-shaped elbow
p := &model.Pipeline{
Stages: []string{"build", "test"},
Jobs: map[string]model.Job{
"j1": {Name: "j1", Stage: "build"},
"j2": {Name: "j2", Stage: "test"},
"j3": {Name: "j3", Stage: "test"},
},
}
svg := pipelineSVG(p)
if !strings.Contains(svg, "<polyline") {
t.Error("expected <polyline> for L-shaped elbow connector")
}
}
func TestPipelineSVG_DAGNeedNotInPositionMap(t *testing.T) {
// DAG mode: job needs a template job (starts with .) → dep not in position maps → continue
p := &model.Pipeline{
Stages: []string{"build"},
Jobs: map[string]model.Job{
"j": {Name: "j", Stage: "build", Needs: []any{".hidden-template"}},
".hidden-template": {Name: ".hidden-template", Stage: "build"},
},
}
svg := pipelineSVG(p)
// Should render without panic; .hidden-template is not visible so no connector drawn.
if !strings.Contains(svg, "<svg") {
t.Error("expected valid SVG output")
}
}
+113
View File
@@ -0,0 +1,113 @@
package graph
import (
"sort"
"strings"
"git.k3nny.fr/glint/internal/cicontext"
"git.k3nny.fr/glint/internal/model"
)
// Tree returns a tree-command-style text representation of pipeline jobs
// grouped by stage.
//
// Hidden template jobs (names starting with ".") are excluded.
// When ctx is non-nil and non-empty, each job is annotated with its evaluated
// state ([skipped] or [manual]); active jobs carry no annotation.
// Without a context, job-type annotations ([manual], [delayed], [trigger]) are
// shown instead.
func Tree(p *model.Pipeline, ctx *cicontext.Context) string {
var sb strings.Builder
// Collect visible jobs.
var visible []string
for name := range p.Jobs {
if !strings.HasPrefix(name, ".") {
visible = append(visible, name)
}
}
sort.Strings(visible)
// Group by stage.
byStage := make(map[string][]string)
for _, name := range visible {
stage := p.Jobs[name].Stage
if stage == "" {
stage = "test"
}
byStage[stage] = append(byStage[stage], name)
}
// Build ordered stage list: declared first, then extras.
seen := make(map[string]bool)
var stages []string
for _, s := range p.Stages {
if len(byStage[s]) > 0 && !seen[s] {
stages = append(stages, s)
seen[s] = true
}
}
for _, name := range visible {
stage := p.Jobs[name].Stage
if stage == "" {
stage = "test"
}
if !seen[stage] {
stages = append(stages, stage)
seen[stage] = true
}
}
sb.WriteString("pipeline\n")
for si, stage := range stages {
lastStage := si == len(stages)-1
stagePrefix, childPrefix := branchChars(lastStage)
sb.WriteString(stagePrefix + stage + "\n")
jobs := byStage[stage]
sort.Strings(jobs)
for ji, name := range jobs {
lastJob := ji == len(jobs)-1
jobBranch, _ := branchChars(lastJob)
sb.WriteString(childPrefix + jobBranch + jobLabel(p.Jobs[name], name, ctx) + "\n")
}
}
return sb.String()
}
func branchChars(last bool) (branch, continuation string) {
if last {
return "└── ", " "
}
return "├── ", "│ "
}
func jobLabel(job model.Job, name string, ctx *cicontext.Context) string {
if ctx != nil && !ctx.IsEmpty() {
switch cicontext.EvalJob(job, ctx) {
case cicontext.JobSkipped:
return name + " [skipped]"
case cicontext.JobManual:
return name + " [manual]"
}
return name
}
// No context: annotate by job type.
var tags []string
if job.When == "manual" {
tags = append(tags, "manual")
}
if job.When == "delayed" {
tags = append(tags, "delayed")
}
if job.Trigger != nil {
tags = append(tags, "trigger")
}
if len(tags) == 0 {
return name
}
return name + " [" + strings.Join(tags, ", ") + "]"
}
+154
View File
@@ -0,0 +1,154 @@
package graph
import (
"strings"
"testing"
"git.k3nny.fr/glint/internal/cicontext"
"git.k3nny.fr/glint/internal/model"
)
func makeSimplePipeline() *model.Pipeline {
return &model.Pipeline{
Stages: []string{"build", "test"},
Jobs: map[string]model.Job{
"build-job": {Name: "build-job", Stage: "build"},
"test-job": {Name: "test-job", Stage: "test"},
".template": {Name: ".template", Stage: "build"},
},
}
}
func TestTree_Basic(t *testing.T) {
p := makeSimplePipeline()
out := Tree(p, nil)
if !strings.Contains(out, "pipeline") {
t.Error("expected 'pipeline' root node")
}
if !strings.Contains(out, "build-job") {
t.Error("expected build-job in tree")
}
if !strings.Contains(out, "test-job") {
t.Error("expected test-job in tree")
}
// hidden template jobs should not appear
if strings.Contains(out, ".template") {
t.Error(".template job should be excluded from tree")
}
}
func TestTree_WithContext_Skipped(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"build"},
Jobs: map[string]model.Job{
"run-on-tag": {Name: "run-on-tag", Stage: "build", Rules: []model.Rule{
{If: `$CI_COMMIT_TAG != ""`, When: "on_success"},
}},
},
}
ctx := cicontext.New("main", "", "", nil)
out := Tree(p, ctx)
if !strings.Contains(out, "[skipped]") {
t.Errorf("expected [skipped] annotation, got:\n%s", out)
}
}
func TestTree_WithContext_Manual(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"deploy"},
Jobs: map[string]model.Job{
"deploy": {Name: "deploy", Stage: "deploy", When: "manual"},
},
}
ctx := cicontext.New("main", "", "", nil)
out := Tree(p, ctx)
if !strings.Contains(out, "[manual]") {
t.Errorf("expected [manual] annotation, got:\n%s", out)
}
}
func TestTree_NoContext_Annotations(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"build"},
Jobs: map[string]model.Job{
"manual-job": {Name: "manual-job", Stage: "build", When: "manual"},
"delayed-job": {Name: "delayed-job", Stage: "build", When: "delayed"},
"trigger-job": {Name: "trigger-job", Stage: "build", Trigger: "other/project"},
},
}
out := Tree(p, nil)
if !strings.Contains(out, "[manual]") { t.Error("expected [manual]") }
if !strings.Contains(out, "[delayed]") { t.Error("expected [delayed]") }
if !strings.Contains(out, "[trigger]") { t.Error("expected [trigger]") }
}
func TestTree_JobWithNoStage(t *testing.T) {
// Jobs without a stage should default to "test"
p := &model.Pipeline{
Stages: []string{"test"},
Jobs: map[string]model.Job{
"nostage": {Name: "nostage"},
},
}
out := Tree(p, nil)
if !strings.Contains(out, "test") { t.Error("expected default stage 'test'") }
}
func TestTree_UndeclaredStage(t *testing.T) {
// Job in a stage not listed in p.Stages — should still appear
p := &model.Pipeline{
Stages: []string{},
Jobs: map[string]model.Job{
"myjob": {Name: "myjob", Stage: "custom"},
},
}
out := Tree(p, nil)
if !strings.Contains(out, "myjob") { t.Error("expected myjob in output") }
}
// ── branchChars ───────────────────────────────────────────────────────────────
func TestBranchChars(t *testing.T) {
b, c := branchChars(true)
if b != "└── " || c != " " {
t.Errorf("last: branch=%q cont=%q", b, c)
}
b, c = branchChars(false)
if b != "├── " || c != "│ " {
t.Errorf("not-last: branch=%q cont=%q", b, c)
}
}
// ── jobLabel ─────────────────────────────────────────────────────────────────
func TestJobLabel(t *testing.T) {
job := model.Job{Name: "j"}
if jobLabel(job, "j", nil) != "j" {
t.Error("plain job should have plain label")
}
// Multiple tags
job2 := model.Job{Name: "j2", When: "manual", Trigger: "other"}
label := jobLabel(job2, "j2", nil)
if !strings.Contains(label, "manual") || !strings.Contains(label, "trigger") {
t.Errorf("expected manual+trigger in label: %q", label)
}
// With context — active job has no annotation
emptyCtx := &cicontext.Context{}
if jobLabel(model.Job{}, "j", emptyCtx) != "j" {
t.Error("active job with context should have no annotation")
}
}
// TestJobLabel_ActiveWithContext covers the 'return name' path when a job runs
// normally (neither skipped nor manual) with a non-empty context.
func TestJobLabel_ActiveWithContext(t *testing.T) {
// Branch context with a job that has no rules → the job is active.
ctx := cicontext.New("main", "", "", nil)
activeJob := model.Job{Name: "build", Script: []any{"make"}}
label := jobLabel(activeJob, "build", ctx)
if label != "build" {
t.Errorf("active job with context: expected 'build', got %q", label)
}
}
+90
View File
@@ -0,0 +1,90 @@
package linter
import (
"testing"
"git.k3nny.fr/glint/internal/model"
)
func TestCheckDeadRules(t *testing.T) {
cases := []struct {
name string
rules []model.Rule
wantHit bool // whether GL033 should fire
}{
{
name: "no rules — not dead",
rules: nil,
wantHit: false,
},
{
name: "single bare when:never — dead",
rules: []model.Rule{{When: "never"}},
wantHit: true,
},
{
name: "all rules when:never with if — dead",
rules: []model.Rule{
{If: `$CI_COMMIT_BRANCH == "main"`, When: "never"},
{If: `$CI_COMMIT_BRANCH == "develop"`, When: "never"},
{When: "never"},
},
wantHit: true,
},
{
name: "first rule on_success — not dead",
rules: []model.Rule{
{If: `$CI_COMMIT_BRANCH == "main"`, When: "on_success"},
{When: "never"},
},
wantHit: false,
},
{
name: "rule with empty when (defaults to on_success) — not dead",
rules: []model.Rule{
{If: `$CI_COMMIT_BRANCH == "main"`},
{When: "never"},
},
wantHit: false,
},
{
name: "when:manual — not dead",
rules: []model.Rule{{When: "manual"}},
wantHit: false,
},
{
name: "when:always — not dead",
rules: []model.Rule{{When: "always"}},
wantHit: false,
},
{
name: "when:on_failure — not dead",
rules: []model.Rule{{When: "on_failure"}},
wantHit: false,
},
{
name: "mixed never and manual — not dead",
rules: []model.Rule{
{If: `$CI_COMMIT_BRANCH == "main"`, When: "never"},
{When: "manual"},
},
wantHit: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
job := model.Job{Rules: tc.rules}
findings := checkDeadRules("test-job", job)
hit := false
for _, f := range findings {
if f.Rule == RuleDeadRules {
hit = true
}
}
if hit != tc.wantHit {
t.Errorf("checkDeadRules: got hit=%v, want hit=%v; findings=%v", hit, tc.wantHit, findings)
}
})
}
}
+7 -1
View File
@@ -3,7 +3,7 @@ package linter
import ( import (
"fmt" "fmt"
"git.k3nny.fr/gitlab-sim/internal/model" "git.k3nny.fr/glint/internal/model"
) )
func checkDependencies(p *model.Pipeline) []Finding { func checkDependencies(p *model.Pipeline) []Finding {
@@ -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),
}) })
} }
+819
View File
@@ -0,0 +1,819 @@
package linter
// RuleEntry holds the human-readable documentation for a single lint rule,
// shown by 'glint explain <RULE>'.
type RuleEntry struct {
Title string // short title (one line)
Severity Severity // Error or Warning
Description string // what triggers the rule and why it matters
Example string // YAML snippet that triggers the rule
Fix string // corrected YAML
}
// RuleCatalog maps stable rule IDs to their documentation entries.
var RuleCatalog = map[string]RuleEntry{
RuleNoStages: {
Title: "no stages defined",
Severity: Warning,
Description: "The pipeline has no 'stages:' block. GitLab falls back to the " +
"built-in default stages (build, test, deploy), which may not match your " +
"intended job ordering.",
Example: `# No stages: block
build-job:
script: make build
test-job:
script: make test`,
Fix: `stages:
- build
- test
build-job:
stage: build
script: make build
test-job:
stage: test
script: make test`,
},
RuleWorkflowWhen: {
Title: "workflow.rules.when invalid value",
Severity: Error,
Description: "'workflow.rules[n].when' only accepts 'always' or 'never'. Any " +
"other value (such as 'on_success' or 'manual') is invalid and will cause " +
"the pipeline to fail to create.",
Example: `workflow:
rules:
- if: $CI_COMMIT_BRANCH
when: on_success # invalid here`,
Fix: `workflow:
rules:
- if: $CI_COMMIT_BRANCH
when: always`,
},
RuleMissingScript: {
Title: "missing required 'script' field",
Severity: Error,
Description: "Every non-trigger, non-pages job must define 'script:' (or 'run:' " +
"for CI Steps). A job with no script will fail immediately when GitLab tries " +
"to run it.",
Example: `my-job:
stage: test
image: python:3.12
# Missing script:`,
Fix: `my-job:
stage: test
image: python:3.12
script: pytest`,
},
RuleUnknownStage: {
Title: "job references undeclared stage",
Severity: Error,
Description: "The job's 'stage:' value does not match any name in the pipeline's " +
"'stages:' list. GitLab will reject the pipeline configuration.",
Example: `stages:
- build
- test
deploy-job:
stage: production # not in stages:
script: ./deploy.sh`,
Fix: `stages:
- build
- test
- production
deploy-job:
stage: production
script: ./deploy.sh`,
},
RuleOnlyRulesConflict: {
Title: "'only:' and 'rules:' used together",
Severity: Error,
Description: "'only:' and 'rules:' are mutually exclusive. Using both on the " +
"same job is a configuration error that prevents the pipeline from being " +
"created.",
Example: `my-job:
only:
- main
rules:
- if: $CI_COMMIT_BRANCH
script: echo conflict`,
Fix: `# Remove only: and use rules: exclusively
my-job:
rules:
- if: $CI_COMMIT_BRANCH == "main"
script: echo ok`,
},
RuleExceptRulesConflict: {
Title: "'except:' and 'rules:' used together",
Severity: Error,
Description: "'except:' and 'rules:' are mutually exclusive. Using both on the " +
"same job is a configuration error that prevents the pipeline from being " +
"created.",
Example: `my-job:
except:
- tags
rules:
- if: $CI_COMMIT_BRANCH
script: echo conflict`,
Fix: `my-job:
rules:
- if: $CI_COMMIT_TAG
when: never
- if: $CI_COMMIT_BRANCH
script: echo ok`,
},
RuleDeprecatedOnly: {
Title: "'only:' / 'except:' deprecated",
Severity: Warning,
Description: "'only:' and 'except:' are deprecated in favour of 'rules:'. " +
"GitLab may remove support for these keywords in a future major version. " +
"'rules:' is more expressive and supports variable references, merge-request " +
"conditions, and fine-grained 'when:' control.",
Example: `my-job:
only:
- main
script: ./deploy.sh`,
Fix: `my-job:
rules:
- if: $CI_COMMIT_BRANCH == "main"
script: ./deploy.sh`,
},
RuleInvalidWhen: {
Title: "'when:' has invalid value",
Severity: Error,
Description: "Job-level 'when:' accepts: on_success, on_failure, always, " +
"manual, delayed, never. Any other value is rejected by GitLab.",
Example: `my-job:
when: sometimes # invalid
script: echo hi`,
Fix: `my-job:
when: on_success
script: echo hi`,
},
RuleDelayedNoStartIn: {
Title: "'when: delayed' without 'start_in:'",
Severity: Error,
Description: "'when: delayed' requires a 'start_in:' duration (e.g. " +
"'30 minutes', '1 hour'). Without it GitLab rejects the job configuration.",
Example: `my-job:
when: delayed
script: echo hi`,
Fix: `my-job:
when: delayed
start_in: 30 minutes
script: echo hi`,
},
RuleStartInNoDelayed: {
Title: "'start_in:' without 'when: delayed'",
Severity: Error,
Description: "'start_in:' is only meaningful when 'when: delayed'. Setting it " +
"alongside any other 'when:' value is a configuration error.",
Example: `my-job:
when: on_success
start_in: 30 minutes # only valid with delayed
script: echo hi`,
Fix: `my-job:
when: delayed
start_in: 30 minutes
script: echo hi`,
},
RuleInvalidParallel: {
Title: "'parallel:' value invalid",
Severity: Error,
Description: "'parallel:' must be an integer between 2 and 200 (inclusive), " +
"or a map with a 'matrix:' key for matrix jobs. Values outside this range " +
"or the wrong type are rejected by GitLab.",
Example: `my-job:
parallel: 1 # must be 2200
script: echo hi`,
Fix: `my-job:
parallel: 5
script: echo hi`,
},
RuleInvalidRetry: {
Title: "'retry:' value invalid",
Severity: Error,
Description: "'retry:' accepts an integer 02 or a map with optional 'max:' " +
"(02) and 'when:' keys. Values outside this range are rejected.",
Example: `my-job:
retry: 5 # max is 2
script: echo hi`,
Fix: `my-job:
retry: 2
script: echo hi`,
},
RuleInvalidRetryWhen: {
Title: "'retry.when:' has invalid failure type",
Severity: Error,
Description: "'retry.when:' must be a recognised GitLab CI failure type " +
"(e.g. script_failure, runner_system_failure) or 'always'. Unknown " +
"values are rejected.",
Example: `my-job:
retry:
max: 2
when: disk_full # not a valid failure type
script: echo hi`,
Fix: `my-job:
retry:
max: 2
when: runner_system_failure
script: echo hi`,
},
RuleInvalidAllowFailure: {
Title: "'allow_failure:' invalid value",
Severity: Error,
Description: "'allow_failure:' must be a boolean (true/false) or a map with " +
"an 'exit_codes:' key. Other forms are rejected by GitLab.",
Example: `my-job:
allow_failure: maybe # must be true/false or map
script: echo hi`,
Fix: `my-job:
allow_failure: true
script: echo hi`,
},
RuleInvalidInterruptible: {
Title: "'interruptible:' is not a boolean",
Severity: Error,
Description: "'interruptible:' must be a boolean (true or false). Other types " +
"are rejected.",
Example: `my-job:
interruptible: yes # must be a YAML boolean true/false
script: echo hi`,
Fix: `my-job:
interruptible: true
script: echo hi`,
},
RuleTriggerWithScript: {
Title: "trigger job also defines 'script:'",
Severity: Error,
Description: "Jobs with a 'trigger:' key (downstream pipeline triggers) cannot " +
"use 'script:'. These are mutually exclusive — a trigger job has no runner " +
"environment to execute scripts in.",
Example: `trigger-job:
trigger:
project: mygroup/myproject
script: echo this will fail`,
Fix: `trigger-job:
trigger:
project: mygroup/myproject`,
},
RuleInvalidTrigger: {
Title: "trigger: map missing 'project:' or 'include:'",
Severity: Error,
Description: "When 'trigger:' is a map it must specify either 'project:' " +
"(downstream project trigger) or 'include:' (dynamic child pipeline). " +
"Without one of these keys GitLab cannot determine what to trigger.",
Example: `trigger-job:
trigger:
strategy: depend # missing project: or include:`,
Fix: `trigger-job:
trigger:
project: mygroup/downstream
strategy: depend`,
},
RuleInvalidCoverage: {
Title: "'coverage:' is not a regex pattern",
Severity: Error,
Description: "'coverage:' must be a regex pattern wrapped in forward slashes " +
"(e.g. '/Coverage: \\d+\\.?\\d*%/'). GitLab uses this pattern to extract " +
"the coverage percentage from job output.",
Example: `my-job:
coverage: "\\d+%" # missing surrounding //
script: pytest --cov`,
Fix: `my-job:
coverage: '/Coverage: \d+\.?\d*%/'
script: pytest --cov`,
},
RuleInvalidRelease: {
Title: "'release:' missing required 'tag_name:'",
Severity: Error,
Description: "'release:' must be a map and must include a 'tag_name:' key. " +
"Without it GitLab cannot create the release.",
Example: `release-job:
script: echo releasing
release:
name: My Release # missing tag_name:`,
Fix: `release-job:
script: echo releasing
release:
tag_name: $CI_COMMIT_TAG
name: My Release`,
},
RuleInvalidEnvironment: {
Title: "'environment:' invalid url or action",
Severity: Error,
Description: "'environment.url' requires 'environment.name' to be set. " +
"'environment.action' must be one of: start, stop, prepare, verify, access.",
Example: `my-job:
script: ./deploy.sh
environment:
url: https://prod.example.com # name: is required`,
Fix: `my-job:
script: ./deploy.sh
environment:
name: production
url: https://prod.example.com`,
},
RuleInvalidArtifacts: {
Title: "'artifacts:' invalid configuration",
Severity: Error,
Description: "'artifacts.when' must be on_success, on_failure, or always. " +
"'artifacts.expose_as' requires 'artifacts.paths' to be set.",
Example: `my-job:
script: make
artifacts:
when: sometimes # invalid value`,
Fix: `my-job:
script: make
artifacts:
when: on_failure
paths:
- build/logs/`,
},
RulePagesPublic: {
Title: "pages job missing 'public' in artifacts.paths",
Severity: Warning,
Description: "The 'pages' job must include 'public' (or a path starting with " +
"'public/') in 'artifacts.paths' for GitLab Pages to deploy the site.",
Example: `pages:
script: hugo
artifacts:
paths:
- dist/ # should include public/`,
Fix: `pages:
script: hugo --destination public
artifacts:
paths:
- public/`,
},
RuleInvalidCache: {
Title: "'cache:' invalid when or policy",
Severity: Error,
Description: "'cache.when' must be on_success, on_failure, or always. " +
"'cache.policy' must be pull, push, or pull-push.",
Example: `my-job:
script: npm ci
cache:
paths: [node_modules/]
policy: read-only # invalid; use pull`,
Fix: `my-job:
script: npm ci
cache:
paths: [node_modules/]
policy: pull`,
},
RuleInvalidRulesWhen: {
Title: "'rules[n].when:' has invalid value",
Severity: Error,
Description: "Inside a 'rules:' block, 'when:' must be one of: on_success, " +
"on_failure, always, manual, delayed, never. Other values are rejected.",
Example: `my-job:
rules:
- if: $CI_COMMIT_BRANCH
when: conditional # not valid here
script: echo hi`,
Fix: `my-job:
rules:
- if: $CI_COMMIT_BRANCH
when: on_success
script: echo hi`,
},
RuleInvalidImage: {
Title: "'image:' map form missing 'name:'",
Severity: Error,
Description: "When 'image:' is a map, it must include a 'name:' key specifying " +
"the Docker image. Without it GitLab cannot resolve which image to pull.",
Example: `my-job:
image:
entrypoint: ["/bin/sh"] # missing name:
script: echo hi`,
Fix: `my-job:
image:
name: alpine:3.19
entrypoint: ["/bin/sh"]
script: echo hi`,
},
RuleInvalidInherit: {
Title: "'inherit.default' or 'inherit.variables' invalid type",
Severity: Error,
Description: "'inherit.default' and 'inherit.variables' must each be a boolean " +
"(true/false) or a list of field/variable names. Other types are rejected.",
Example: `my-job:
inherit:
default: "no" # must be true/false or a list
script: echo hi`,
Fix: `my-job:
inherit:
default: false
script: echo hi`,
},
RuleNeedsUnknown: {
Title: "'needs:' references unknown job",
Severity: Error,
Description: "A job in the 'needs:' list does not exist in the pipeline. " +
"GitLab will refuse to create the pipeline.",
Example: `build:
stage: build
script: make
test:
stage: test
needs: [build, lint] # lint does not exist
script: make test`,
Fix: `lint:
stage: build
script: make lint
build:
stage: build
script: make
test:
stage: test
needs: [build, lint]
script: make test`,
},
RuleNeedsStageOrder: {
Title: "'needs:' job is in a later stage",
Severity: Error,
Description: "A job listed in 'needs:' is in a later stage than the current " +
"job. GitLab does not allow needs: to reference future stages.",
Example: `stages: [build, test, deploy]
test:
stage: test
needs: [deploy-job] # deploy-job is in a later stage
script: make test
deploy-job:
stage: deploy
script: ./deploy.sh`,
Fix: `stages: [build, test, deploy]
test:
stage: test
needs: [build-job]
script: make test
build-job:
stage: build
script: make`,
},
RuleNeedsCycle: {
Title: "circular dependency in 'needs:' graph",
Severity: Error,
Description: "Two or more jobs in the 'needs:' graph form a cycle — A needs B " +
"and B needs A (directly or transitively). GitLab will reject the pipeline.",
Example: `job-a:
stage: test
needs: [job-b]
script: echo a
job-b:
stage: test
needs: [job-a]
script: echo b`,
Fix: `job-a:
stage: test
script: echo a
job-b:
stage: test
needs: [job-a]
script: echo b`,
},
RuleUnknownDependency: {
Title: "'dependencies:' references unknown job",
Severity: Error,
Description: "A job listed in 'dependencies:' does not exist in the pipeline. " +
"GitLab will refuse to create the pipeline.",
Example: `test:
stage: test
dependencies: [build, missing-job]
script: make test`,
Fix: `build:
stage: build
script: make
artifacts:
paths: [dist/]
test:
stage: test
dependencies: [build]
script: make test`,
},
RuleDependencyStage: {
Title: "'dependencies:' job in same or later stage",
Severity: Error,
Description: "'dependencies:' can only reference jobs in earlier stages. " +
"Referencing a job in the same or a later stage is not allowed because " +
"artifacts would not yet be available.",
Example: `stages: [test, deploy]
test:
stage: test
dependencies: [deploy-job] # deploy is a later stage
script: make test
deploy-job:
stage: deploy
script: ./deploy.sh`,
Fix: `stages: [build, test, deploy]
build:
stage: build
script: make
artifacts:
paths: [dist/]
test:
stage: test
dependencies: [build]
script: make test`,
},
RuleUndeclaredVariable: {
Title: "'rules:if:' references undeclared variable",
Severity: Warning,
Description: "A 'rules:if:' expression references a variable that is not " +
"declared in any 'variables:' block in the pipeline YAML. This may be a " +
"typo, or the variable may be set in GitLab project settings (invisible to " +
"glint). Predefined CI_* and GITLAB_* variables are always exempt.",
Example: `variables:
APP_ENV: staging
my-job:
rules:
- if: $DEPLOY_ENV == "prod" # DEPLOY_ENV not declared
script: ./deploy.sh`,
Fix: `variables:
APP_ENV: staging
DEPLOY_ENV: staging # declare it, or set it in GitLab project settings
my-job:
rules:
- if: $DEPLOY_ENV == "prod"
script: ./deploy.sh`,
},
RuleDeadRules: {
Title: "rules: block has all 'when: never' — job permanently excluded",
Severity: Warning,
Description: "Every rule in the job's 'rules:' block has an explicit " +
"'when: never'. No matter which conditions match, the job will never " +
"be included in any pipeline run.",
Example: `my-job:
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: never
- when: never
script: echo unreachable`,
Fix: `# Option 1: remove the job entirely
# Option 2: give at least one rule a non-never when:
my-job:
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: on_success
- when: never
script: echo deploy`,
},
RuleInvalidService: {
Title: "'services:' map form missing 'name:' or invalid 'alias:'",
Severity: Error,
Description: "When a service entry is a map it must include a 'name:' key. " +
"'alias:' (if set) must be a valid DNS label: alphanumeric characters, " +
"hyphens, and dots only; must start and end with alphanumeric.",
Example: `my-job:
script: pytest
services:
- alias: my_db # underscore is not valid in a DNS label`,
Fix: `my-job:
script: pytest
services:
- name: postgres:15
alias: my-db`,
},
RuleAbsoluteGlobPath: {
Title: "'rules:changes' or 'rules:exists' uses absolute path",
Severity: Warning,
Description: "Paths in 'rules:changes' and 'rules:exists' are always relative " +
"to the repository root. An absolute path starting with '/' will never " +
"match any file and will silently disable the rule.",
Example: `my-job:
rules:
- changes:
- /src/**/*.go # absolute will never match
script: make`,
Fix: `my-job:
rules:
- changes:
- src/**/*.go
script: make`,
},
RuleInvalidTimeout: {
Title: "'timeout:' is not a valid duration string",
Severity: Error,
Description: "'timeout:' must be a GitLab CI duration string composed of " +
"recognised time units: weeks (w), days (d), hours (h), minutes (m/min), " +
"seconds (s). Examples: '1h 30m', '90 minutes', '2 hours'.",
Example: `my-job:
timeout: "1:30:00" # colon-separated format not supported
script: long-running-task.sh`,
Fix: `my-job:
timeout: 1h 30m
script: long-running-task.sh`,
},
RuleInvalidIDToken: {
Title: "'id_tokens:' entry missing required 'aud:' key",
Severity: Error,
Description: "Each entry under 'id_tokens:' must be a map with an 'aud:' " +
"(audience) key. Without 'aud:' GitLab cannot generate the ID token " +
"and the job will fail.",
Example: `my-job:
id_tokens:
MY_TOKEN:
# missing aud:
script: vault-login.sh`,
Fix: `my-job:
id_tokens:
MY_TOKEN:
aud: https://vault.example.com
script: vault-login.sh`,
},
RuleInvalidSecret: {
Title: "'secrets:' entry missing a provider key",
Severity: Error,
Description: "Each entry under 'secrets:' must specify a provider: one of " +
"'vault:', 'gcp_secret_manager:', or 'azure_key_vault:'. Without a " +
"provider GitLab cannot retrieve the secret.",
Example: `my-job:
secrets:
DB_PASSWORD:
path: secret/db/password # no provider key
script: run.sh`,
Fix: `my-job:
secrets:
DB_PASSWORD:
vault:
engine:
name: kv-v2
path: secret
path: db/password
field: password
script: run.sh`,
},
RulePagesPublish: {
Title: "'pages:' keyword publish directory missing from 'artifacts.paths'",
Severity: Warning,
Description: "A job using the 'pages:' keyword must include its publish " +
"directory in 'artifacts.paths'. Without this, GitLab Pages will not " +
"deploy the site.",
Example: `my-pages-job:
script: hugo
pages:
publish: public
artifacts:
paths:
- dist/ # missing public/`,
Fix: `my-pages-job:
script: hugo
pages:
publish: public
artifacts:
paths:
- public/`,
},
RuleDuplicateStage: {
Title: "duplicate stage name in 'stages:'",
Severity: Warning,
Description: "A stage name appears more than once in the 'stages:' list. " +
"GitLab silently merges duplicate stage entries, which can make the " +
"pipeline order confusing.",
Example: `stages:
- build
- test
- test # duplicate`,
Fix: `stages:
- build
- test`,
},
RuleInvalidCacheKeyFiles: {
Title: "'cache.key.files' contains a glob pattern",
Severity: Warning,
Description: "'cache.key.files' must be a list of exact file paths. Glob " +
"patterns (*, ?, [...]) are not expanded — the literal glob string is " +
"used as the cache key, which probably doesn't match your intent.",
Example: `my-job:
script: npm ci
cache:
key:
files:
- package*.json # glob use exact path`,
Fix: `my-job:
script: npm ci
cache:
key:
files:
- package.json
- package-lock.json`,
},
RuleStaticDeadRules: {
Title: "rules:if: block statically never activates",
Severity: Warning,
Description: "Every 'rules:if:' condition in this job evaluates to false " +
"using the variable values declared in the pipeline YAML. The job will " +
"never be included in any pipeline run. This check only fires when ALL " +
"referenced variables are declared in the YAML (predefined CI_* variables " +
"are not evaluated to avoid false positives).",
Example: `variables:
ENABLE_DEPLOY: "false"
deploy:
rules:
- if: '$ENABLE_DEPLOY == "true"' # always false: ENABLE_DEPLOY is "false"
script: ./deploy.sh`,
Fix: `variables:
ENABLE_DEPLOY: "true" # fix the variable value
deploy:
rules:
- if: '$ENABLE_DEPLOY == "true"'
script: ./deploy.sh
# Or add an unconditional fallback:
deploy:
rules:
- if: '$ENABLE_DEPLOY == "true"'
- when: manual # allow manual trigger as a fallback
script: ./deploy.sh`,
},
RuleInheritNoDefault: {
Title: "'inherit: default:' declared but no 'default:' block",
Severity: Warning,
Description: "A job declares 'inherit: default:' but the pipeline has no " +
"'default:' block, so the declaration has no effect. This can also fire " +
"when 'inherit: default: [list]' names fields that are not set in the " +
"'default:' block.",
Example: `# No default: block exists
my-job:
inherit:
default: false # no-op: nothing to opt out of
script: echo hi`,
Fix: `# Either add a default: block...
default:
before_script:
- source venv/bin/activate
my-job:
inherit:
default: false
script: echo hi
# ...or remove the pointless inherit: declaration
my-job:
script: echo hi`,
},
}
+104
View File
@@ -0,0 +1,104 @@
package linter
import (
"git.k3nny.fr/glint/internal/cicontext"
"git.k3nny.fr/glint/internal/model"
)
// checkRulesIfReachability (GL042): warn when every rule in a job's rules: block
// can be statically proven to never activate given the values of variables
// declared in the pipeline YAML. Only fires when ALL variables referenced by the
// rules:if: expressions are declared in the pipeline or job variables: blocks —
// predefined CI_* / GITLAB_* variables or variables not declared in YAML are
// treated as unknown (could have any runtime value) so the check is skipped
// conservatively to avoid false positives.
func checkRulesIfReachability(p *model.Pipeline) []Finding {
if len(p.Jobs) == 0 {
return nil
}
// Collect scalar string values from pipeline-level variables.
pipelineVars := make(map[string]string, len(p.Variables))
for k, v := range p.Variables {
if s, ok := cicontext.ScalarString(v); ok {
pipelineVars[k] = s
}
}
var findings []Finding
for name, job := range p.Jobs {
if len(job.Rules) == 0 {
continue
}
// Merge pipeline vars with job-level variable overrides.
jobVars := make(map[string]string, len(pipelineVars)+len(job.Variables))
for k, v := range pipelineVars {
jobVars[k] = v
}
for k, v := range job.Variables {
if s, ok := cicontext.ScalarString(v); ok {
jobVars[k] = s
}
}
if f := evalRulesReachability(name, job, jobVars); f != nil {
findings = append(findings, *f)
}
}
return findings
}
// evalRulesReachability returns a GL042 finding when the job's rules: block
// can never activate given the provided variable map, or nil if the job
// might activate (or the check cannot be applied conservatively).
func evalRulesReachability(name string, job model.Job, jobVars map[string]string) *Finding {
lookup := func(k string) string { return jobVars[k] }
for _, rule := range job.Rules {
// Explicit when: never — this rule is provably dead; continue.
if rule.When == "never" {
continue
}
// No if: condition → this rule always matches → job CAN activate.
if rule.If == "" {
return nil
}
// Check whether all variables referenced in the if: expression are
// declared in the pipeline YAML. If any are not declared (predefined
// CI vars, project settings, etc.), we cannot evaluate the expression
// and must conservatively assume the job might activate.
refs := extractIfVars(rule.If)
allDeclared := true
for _, ref := range refs {
if _, ok := jobVars[ref]; !ok {
// Variable not in YAML (predefined or unknown) → cannot evaluate.
allDeclared = false
break
}
}
if !allDeclared {
return nil // conservative: job might activate
}
// All referenced variables are declared; evaluate the expression.
// Use strict mode (unparse → false) to avoid matching unparseable expressions.
if cicontext.EvalIfStrict(rule.If, lookup) {
// This rule's condition evaluates to true → job CAN activate.
return nil
}
// Expression evaluated to false → this rule cannot activate; continue.
}
// No rule could activate the job.
return &Finding{
Severity: Warning,
Rule: RuleStaticDeadRules,
Job: name,
File: job.File,
Line: job.Line,
Message: "rules: block can never activate: all if: conditions evaluate to false given the declared pipeline variables",
}
}
+161
View File
@@ -0,0 +1,161 @@
package linter
import (
"testing"
"git.k3nny.fr/glint/internal/model"
)
func TestCheckRulesIfReachability(t *testing.T) {
makeJob := func(vars map[string]any, rules []model.Rule) model.Job {
return model.Job{Variables: vars, Rules: rules}
}
cases := []struct {
name string
pipelineVars map[string]any
jobs map[string]model.Job
wantHit []string // job names that should produce GL042
wantMiss []string // job names that should NOT produce GL042
}{
{
name: "declared var always false — GL042 fires",
pipelineVars: map[string]any{"DEPLOY": "false"},
jobs: map[string]model.Job{
"deploy": makeJob(nil, []model.Rule{
{If: `$DEPLOY == "true"`, When: "on_success"},
}),
},
wantHit: []string{"deploy"},
wantMiss: nil,
},
{
name: "declared var matches — no finding",
pipelineVars: map[string]any{"DEPLOY": "true"},
jobs: map[string]model.Job{
"deploy": makeJob(nil, []model.Rule{
{If: `$DEPLOY == "true"`, When: "on_success"},
}),
},
wantHit: nil,
wantMiss: []string{"deploy"},
},
{
name: "predefined CI_ var — skip check conservatively",
pipelineVars: nil,
jobs: map[string]model.Job{
"branch-job": makeJob(nil, []model.Rule{
{If: `$CI_COMMIT_BRANCH == "never-exists"`, When: "on_success"},
}),
},
wantHit: nil,
wantMiss: []string{"branch-job"},
},
{
name: "undeclared var — skip check conservatively",
pipelineVars: nil,
jobs: map[string]model.Job{
"my-job": makeJob(nil, []model.Rule{
{If: `$UNKNOWN_VAR == "x"`, When: "on_success"},
}),
},
wantHit: nil,
wantMiss: []string{"my-job"},
},
{
name: "unconditional rule — job can always activate",
pipelineVars: map[string]any{"DEPLOY": "false"},
jobs: map[string]model.Job{
"my-job": makeJob(nil, []model.Rule{
{If: `$DEPLOY == "true"`, When: "never"},
{When: "on_success"},
}),
},
wantHit: nil,
wantMiss: []string{"my-job"},
},
{
name: "all rules when:never — GL042 fires (not GL033 territory overlap)",
pipelineVars: map[string]any{"DEPLOY": "false"},
jobs: map[string]model.Job{
"my-job": makeJob(nil, []model.Rule{
{If: `$DEPLOY == "true"`, When: "on_success"},
{When: "never"},
}),
},
// Rule 1 evaluates to false; Rule 2 is explicit never → all dead.
wantHit: []string{"my-job"},
wantMiss: nil,
},
{
name: "job-level var overrides pipeline var",
pipelineVars: map[string]any{"FLAG": "false"},
jobs: map[string]model.Job{
"my-job": makeJob(map[string]any{"FLAG": "true"}, []model.Rule{
{If: `$FLAG == "true"`, When: "on_success"},
}),
},
// Job-level FLAG="true" makes the if: evaluate to true → no finding.
wantHit: nil,
wantMiss: []string{"my-job"},
},
{
name: "no rules — nothing to check",
pipelineVars: map[string]any{"DEPLOY": "false"},
jobs: map[string]model.Job{
"my-job": makeJob(nil, nil),
},
wantHit: nil,
wantMiss: []string{"my-job"},
},
{
name: "boolean variable evaluates correctly",
pipelineVars: map[string]any{"FLAG": false}, // bool false
jobs: map[string]model.Job{
"my-job": makeJob(nil, []model.Rule{
{If: `$FLAG == "true"`, When: "on_success"},
}),
},
// ScalarString(false) → "false"; "false" == "true" → false → GL042 fires.
wantHit: []string{"my-job"},
wantMiss: nil,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
p := &model.Pipeline{
Variables: tc.pipelineVars,
Jobs: tc.jobs,
}
findings := checkRulesIfReachability(p)
hitSet := make(map[string]bool)
for _, f := range findings {
if f.Rule == RuleStaticDeadRules {
hitSet[f.Job] = true
}
}
for _, want := range tc.wantHit {
if !hitSet[want] {
t.Errorf("expected GL042 for job %q but it was not reported; findings: %v", want, findings)
}
}
for _, notWant := range tc.wantMiss {
if hitSet[notWant] {
t.Errorf("unexpected GL042 for job %q", notWant)
}
}
})
}
}
// TestCheckRulesIfReachability_EmptyPipeline covers the early return (line 16-18)
// when the pipeline has no jobs.
func TestCheckRulesIfReachability_EmptyPipeline(t *testing.T) {
findings := checkRulesIfReachability(&model.Pipeline{Jobs: map[string]model.Job{}})
if len(findings) != 0 {
t.Errorf("empty pipeline: expected no findings, got %v", findings)
}
}
+112
View File
@@ -0,0 +1,112 @@
package linter
import (
"fmt"
"strings"
"git.k3nny.fr/glint/internal/model"
)
// checkInheritCompleteness (GL043): warn when a job's 'inherit: default:'
// declaration is dead — either because there is no 'default:' block in the
// pipeline, or because the list form references fields that are not defined
// in the 'default:' block.
func checkInheritCompleteness(p *model.Pipeline) []Finding {
var findings []Finding
for name, job := range p.Jobs {
findings = append(findings, checkJobInheritCompleteness(p, name, job)...)
}
return findings
}
func checkJobInheritCompleteness(p *model.Pipeline, name string, job model.Job) []Finding {
if job.Inherit == nil {
return nil
}
m, ok := job.Inherit.(map[string]any)
if !ok {
return nil
}
defaultVal, hasDefault := m["default"]
if !hasDefault {
return nil
}
// Case 1: no default: block at all — the entire declaration is a no-op.
if p.Default == nil {
return []Finding{{
Severity: Warning,
Rule: RuleInheritNoDefault,
Job: name,
File: job.File,
Line: job.Line,
Message: "'inherit: default:' is declared but the pipeline has no 'default:' block — declaration has no effect",
}}
}
// Case 2: list form — check for field names not set in default:.
list, ok := defaultVal.([]any)
if !ok {
// bool form (true/false) with a non-nil default: block is always valid.
return nil
}
var dead []string
for _, item := range list {
field, ok := item.(string)
if !ok {
continue
}
if !defaultBlockHasField(p.Default, field) {
dead = append(dead, field)
}
}
if len(dead) == 0 {
return nil
}
return []Finding{{
Severity: Warning,
Rule: RuleInheritNoDefault,
Job: name,
File: job.File,
Line: job.Line,
Message: fmt.Sprintf(
"'inherit: default: [%s]': %s not defined in the 'default:' block — %s",
strings.Join(dead, ", "),
pluralIs(len(dead)),
"these entries have no effect",
),
}}
}
// defaultBlockHasField reports whether the given field name has a non-zero value
// in the default: block. Unknown field names return true (conservative).
func defaultBlockHasField(d *model.DefaultConfig, field string) bool {
switch field {
case "image":
return d.Image != nil
case "before_script":
return d.BeforeScript != nil
case "after_script":
return d.AfterScript != nil
case "cache":
return d.Cache != nil
case "artifacts":
return d.Artifacts != nil
case "retry":
return d.Retry != nil
case "timeout":
return d.Timeout != ""
case "tags":
return len(d.Tags) > 0
}
// Unknown field name — conservatively assume it might be defined.
return true
}
func pluralIs(n int) string {
if n == 1 {
return "this field is"
}
return "these fields are"
}
@@ -0,0 +1,165 @@
package linter
import (
"testing"
"git.k3nny.fr/glint/internal/model"
)
func TestCheckInheritCompleteness(t *testing.T) {
cases := []struct {
name string
default_ *model.DefaultConfig
inherit any // job.Inherit value
wantHit bool
}{
{
name: "no inherit — no finding",
inherit: nil,
wantHit: false,
},
{
name: "inherit: default: false, no default block — GL043",
inherit: map[string]any{"default": false},
wantHit: true,
},
{
name: "inherit: default: true, no default block — GL043",
inherit: map[string]any{"default": true},
wantHit: true,
},
{
name: "inherit: default: [image], no default block — GL043",
inherit: map[string]any{"default": []any{"image"}},
wantHit: true,
},
{
name: "inherit: default: false, default block exists — no finding",
default_: &model.DefaultConfig{Image: "node:20"},
inherit: map[string]any{"default": false},
wantHit: false,
},
{
name: "inherit: default: [image], image in default — no finding",
default_: &model.DefaultConfig{Image: "node:20"},
inherit: map[string]any{"default": []any{"image"}},
wantHit: false,
},
{
name: "inherit: default: [before_script], before_script NOT in default — GL043",
default_: &model.DefaultConfig{Image: "node:20"},
inherit: map[string]any{"default": []any{"before_script"}},
wantHit: true,
},
{
name: "inherit: default: [image, before_script], only image in default — GL043 for before_script",
default_: &model.DefaultConfig{Image: "node:20"},
inherit: map[string]any{"default": []any{"image", "before_script"}},
wantHit: true,
},
{
name: "inherit: variables only — no default check",
default_: nil,
inherit: map[string]any{"variables": false},
wantHit: false,
},
{
name: "inherit: unknown field in list — conservative, no finding",
default_: &model.DefaultConfig{Image: "node:20"},
inherit: map[string]any{"default": []any{"nonexistent_field"}},
wantHit: false, // unknown fields return true from defaultBlockHasField
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
job := model.Job{Inherit: tc.inherit}
p := &model.Pipeline{
Default: tc.default_,
Jobs: map[string]model.Job{"test-job": job},
}
findings := checkInheritCompleteness(p)
hit := false
for _, f := range findings {
if f.Rule == RuleInheritNoDefault {
hit = true
}
}
if hit != tc.wantHit {
t.Errorf("GL043 hit=%v, want=%v; findings: %v", hit, tc.wantHit, findings)
}
})
}
}
// TestCheckInheritCompleteness_BoolInherit verifies that a scalar (non-map)
// inherit value (e.g. inherit: true) is silently ignored.
func TestCheckInheritCompleteness_BoolInherit(t *testing.T) {
job := model.Job{Inherit: true}
p := &model.Pipeline{
Default: nil,
Jobs: map[string]model.Job{"job": job},
}
if findings := checkInheritCompleteness(p); len(findings) != 0 {
t.Errorf("bool inherit should produce no findings; got %v", findings)
}
}
// TestCheckInheritCompleteness_ListItemNotString verifies that non-string items
// in the inherit: default: list are skipped.
func TestCheckInheritCompleteness_ListItemNotString(t *testing.T) {
// The list contains 42 (int) and "image" (string). Only "image" should be checked.
job := model.Job{Inherit: map[string]any{"default": []any{42, "image"}}}
p := &model.Pipeline{
Default: &model.DefaultConfig{Image: "node:20"},
Jobs: map[string]model.Job{"job": job},
}
// "image" IS set in default → no findings.
if findings := checkInheritCompleteness(p); len(findings) != 0 {
t.Errorf("non-string item should be skipped; got %v", findings)
}
}
// TestDefaultBlockHasField_AllFields exercises every field branch in
// defaultBlockHasField, including the ones not covered by the main table test.
func TestDefaultBlockHasField_AllFields(t *testing.T) {
cases := []struct {
name string
d *model.DefaultConfig
field string
want bool
}{
{"after_script set", &model.DefaultConfig{AfterScript: []any{"echo"}}, "after_script", true},
{"after_script nil", &model.DefaultConfig{}, "after_script", false},
{"cache set", &model.DefaultConfig{Cache: map[string]any{"key": "main"}}, "cache", true},
{"cache nil", &model.DefaultConfig{}, "cache", false},
{"artifacts set", &model.DefaultConfig{Artifacts: map[string]any{"paths": []any{"dist/"}}}, "artifacts", true},
{"artifacts nil", &model.DefaultConfig{}, "artifacts", false},
{"retry set", &model.DefaultConfig{Retry: 2}, "retry", true},
{"retry nil", &model.DefaultConfig{}, "retry", false},
{"timeout set", &model.DefaultConfig{Timeout: "30m"}, "timeout", true},
{"timeout empty", &model.DefaultConfig{}, "timeout", false},
{"tags set", &model.DefaultConfig{Tags: []string{"docker"}}, "tags", true},
{"tags empty", &model.DefaultConfig{}, "tags", false},
{"unknown field", &model.DefaultConfig{}, "unknown_field", true}, // conservative
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := defaultBlockHasField(tc.d, tc.field)
if got != tc.want {
t.Errorf("defaultBlockHasField(%q) = %v, want %v", tc.field, got, tc.want)
}
})
}
}
// TestPluralIs covers both branches of pluralIs.
func TestPluralIs(t *testing.T) {
if pluralIs(1) != "this field is" {
t.Errorf("pluralIs(1) = %q, want 'this field is'", pluralIs(1))
}
if pluralIs(2) != "these fields are" {
t.Errorf("pluralIs(2) = %q, want 'these fields are'", pluralIs(2))
}
}
+361 -7
View File
@@ -5,9 +5,18 @@ import (
"regexp" "regexp"
"strings" "strings"
"git.k3nny.fr/gitlab-sim/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 (2200) or a map with 'matrix'", Message: "'parallel' must be an integer (2200) 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 (02) or a map with 'max'/'when'", Message: "'retry' must be an integer (02) 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",
}} }}
@@ -264,9 +294,10 @@ func checkTrigger(name string, job model.Job) []Finding {
return nil return nil
} }
var findings []Finding var findings []Finding
if len(job.Script) > 0 { 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
}
+597
View File
@@ -0,0 +1,597 @@
package linter
import (
"testing"
"git.k3nny.fr/glint/internal/model"
)
// ── checkWhen ─────────────────────────────────────────────────────────────────
func TestCheckWhen(t *testing.T) {
cases := []struct {
name string
job model.Job
wantN int
wantRule string
}{
{"valid when", model.Job{When: "on_success"}, 0, ""},
{"invalid when", model.Job{When: "bad_value"}, 1, RuleInvalidWhen},
{"delayed requires start_in", model.Job{When: "delayed"}, 1, RuleDelayedNoStartIn},
{"delayed with start_in", model.Job{When: "delayed", StartIn: "30 minutes"}, 0, ""},
{"start_in without delayed", model.Job{When: "on_success", StartIn: "5m"}, 1, RuleStartInNoDelayed},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := checkWhen("j", tc.job)
if len(got) != tc.wantN {
t.Errorf("got %d findings want %d: %v", len(got), tc.wantN, got)
}
if tc.wantRule != "" && len(got) > 0 && got[0].Rule != tc.wantRule {
t.Errorf("rule: got %q want %q", got[0].Rule, tc.wantRule)
}
})
}
}
// ── checkParallel ─────────────────────────────────────────────────────────────
func TestCheckParallel(t *testing.T) {
cases := []struct {
name string
job model.Job
wantN int
}{
{"nil parallel", model.Job{}, 0},
{"valid int", model.Job{Parallel: 4}, 0},
{"int too low", model.Job{Parallel: 1}, 1},
{"int too high", model.Job{Parallel: 201}, 1},
{"map with matrix", model.Job{Parallel: map[string]any{"matrix": []any{}}}, 0},
{"map without matrix", model.Job{Parallel: map[string]any{"other": true}}, 1},
{"invalid type", model.Job{Parallel: "string"}, 1},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := checkParallel("j", tc.job)
if len(got) != tc.wantN {
t.Errorf("got %d findings want %d", len(got), tc.wantN)
}
})
}
}
// ── checkRetry ────────────────────────────────────────────────────────────────
func TestCheckRetry(t *testing.T) {
cases := []struct {
name string
job model.Job
wantN int
}{
{"nil retry", model.Job{}, 0},
{"valid int 0", model.Job{Retry: 0}, 0},
{"valid int 2", model.Job{Retry: 2}, 0},
{"invalid int -1", model.Job{Retry: -1}, 1},
{"invalid int 3", model.Job{Retry: 3}, 1},
{"map with valid max", model.Job{Retry: map[string]any{"max": 1}}, 0},
{"map with invalid max", model.Job{Retry: map[string]any{"max": 5}}, 1},
{"map with valid when string", model.Job{Retry: map[string]any{"when": "always"}}, 0},
{"map with invalid when string", model.Job{Retry: map[string]any{"when": "bad_reason"}}, 1},
{"map with when slice", model.Job{Retry: map[string]any{"when": []any{"always", "script_failure"}}}, 0},
{"map with when slice invalid", model.Job{Retry: map[string]any{"when": []any{"bad_reason"}}}, 1},
{"invalid type", model.Job{Retry: "string"}, 1},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := checkRetry("j", tc.job)
if len(got) != tc.wantN {
t.Errorf("got %d findings want %d: %v", len(got), tc.wantN, got)
}
})
}
}
// ── checkAllowFailure ─────────────────────────────────────────────────────────
func TestCheckAllowFailure(t *testing.T) {
cases := []struct {
name string
job model.Job
wantN int
}{
{"nil", model.Job{}, 0},
{"bool true", model.Job{Allow: true}, 0},
{"bool false", model.Job{Allow: false}, 0},
{"map with exit_codes", model.Job{Allow: map[string]any{"exit_codes": []any{1, 2}}}, 0},
{"map without exit_codes", model.Job{Allow: map[string]any{"other": true}}, 1},
{"invalid type", model.Job{Allow: "yes"}, 1},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := checkAllowFailure("j", tc.job)
if len(got) != tc.wantN {
t.Errorf("got %d findings want %d", len(got), tc.wantN)
}
})
}
}
// ── checkInterruptible ────────────────────────────────────────────────────────
func TestCheckInterruptible(t *testing.T) {
if len(checkInterruptible("j", model.Job{})) != 0 { t.Error("nil: expected clean") }
if len(checkInterruptible("j", model.Job{Interruptible: true})) != 0 { t.Error("bool true: expected clean") }
if len(checkInterruptible("j", model.Job{Interruptible: false})) != 0 { t.Error("bool false: expected clean") }
if len(checkInterruptible("j", model.Job{Interruptible: "yes"})) != 1 { t.Error("string: expected error") }
}
// ── checkTrigger ──────────────────────────────────────────────────────────────
func TestCheckTrigger(t *testing.T) {
cases := []struct {
name string
job model.Job
wantN int
}{
{"nil trigger", model.Job{}, 0},
{"string trigger", model.Job{Trigger: "other/project"}, 0},
{"trigger with script", model.Job{Trigger: "x", Script: []any{"echo"}}, 1},
{"map trigger with project", model.Job{Trigger: map[string]any{"project": "g/p"}}, 0},
{"map trigger with include", model.Job{Trigger: map[string]any{"include": "ci.yml"}}, 0},
{"map trigger missing both", model.Job{Trigger: map[string]any{"strategy": "depend"}}, 1},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := checkTrigger("j", tc.job)
if len(got) != tc.wantN {
t.Errorf("got %d findings want %d: %v", len(got), tc.wantN, got)
}
})
}
}
// ── checkCoverage ─────────────────────────────────────────────────────────────
func TestCheckCoverage(t *testing.T) {
if len(checkCoverage("j", model.Job{})) != 0 { t.Error("empty: clean") }
if len(checkCoverage("j", model.Job{Coverage: `/\d+%/`})) != 0 { t.Error("valid: clean") }
if len(checkCoverage("j", model.Job{Coverage: "bad"})) != 1 { t.Error("missing slashes: error") }
}
// ── checkRelease ──────────────────────────────────────────────────────────────
func TestCheckRelease(t *testing.T) {
if len(checkRelease("j", model.Job{})) != 0 { t.Error("nil: clean") }
if len(checkRelease("j", model.Job{Release: "string"})) != 1 { t.Error("string: error") }
if len(checkRelease("j", model.Job{Release: map[string]any{"tag_name": "v1"}})) != 0 { t.Error("valid map: clean") }
if len(checkRelease("j", model.Job{Release: map[string]any{"description": "x"}})) != 1 { t.Error("no tag_name: error") }
if len(checkRelease("j", model.Job{Release: map[string]any{"tag_name": ""}})) != 1 { t.Error("empty tag_name: error") }
if len(checkRelease("j", model.Job{Release: map[string]any{"tag_name": nil}})) != 1 { t.Error("nil tag_name: error") }
}
// ── checkEnvironment ──────────────────────────────────────────────────────────
func TestCheckEnvironment(t *testing.T) {
if len(checkEnvironment("j", model.Job{})) != 0 { t.Error("nil: clean") }
if len(checkEnvironment("j", model.Job{Environment: "production"})) != 0 { t.Error("string: clean") }
if len(checkEnvironment("j", model.Job{Environment: map[string]any{"name": "prod"}})) != 0 { t.Error("map with name: clean") }
// url without name
if len(checkEnvironment("j", model.Job{Environment: map[string]any{"url": "https://x.com"}})) != 1 { t.Error("url no name: error") }
// invalid action
if len(checkEnvironment("j", model.Job{Environment: map[string]any{"name": "prod", "action": "badaction"}})) != 1 { t.Error("bad action: error") }
// valid action
if len(checkEnvironment("j", model.Job{Environment: map[string]any{"name": "prod", "action": "stop"}})) != 0 { t.Error("stop action: clean") }
}
// ── checkArtifacts ────────────────────────────────────────────────────────────
func TestCheckArtifacts(t *testing.T) {
if len(checkArtifacts("j", model.Job{})) != 0 { t.Error("nil: clean") }
// non-map type: no-op
if len(checkArtifacts("j", model.Job{Artifacts: "string"})) != 0 { t.Error("string: clean") }
// valid when
if len(checkArtifacts("j", model.Job{Artifacts: map[string]any{"when": "on_failure"}})) != 0 { t.Error("valid when: clean") }
// invalid when
if len(checkArtifacts("j", model.Job{Artifacts: map[string]any{"when": "bad_when"}})) != 1 { t.Error("bad when: error") }
// expose_as without paths
if len(checkArtifacts("j", model.Job{Artifacts: map[string]any{"expose_as": "Coverage"}})) != 1 { t.Error("expose_as no paths: error") }
// expose_as with paths
if len(checkArtifacts("j", model.Job{Artifacts: map[string]any{"expose_as": "X", "paths": []any{"out/"}}})) != 0 { t.Error("expose_as with paths: clean") }
// pages job without public in paths
if len(checkArtifacts("pages", model.Job{Name: "pages", Artifacts: map[string]any{"paths": []any{"dist/"}}})) != 1 { t.Error("pages without public: warning") }
// pages job with public in paths
if len(checkArtifacts("pages", model.Job{Name: "pages", Artifacts: map[string]any{"paths": []any{"public"}}})) != 0 { t.Error("pages with public: clean") }
// pages job with pages: keyword set — GL033 check skipped
if len(checkArtifacts("pages", model.Job{Pages: map[string]any{}, Artifacts: map[string]any{"paths": []any{"dist/"}}})) != 0 { t.Error("pages with pages keyword: clean") }
}
// ── checkCache ────────────────────────────────────────────────────────────────
func TestCheckCache(t *testing.T) {
if len(checkCache("j", model.Job{})) != 0 { t.Error("nil: clean") }
// map form valid
if len(checkCache("j", model.Job{Cache: map[string]any{"key": "abc"}})) != 0 { t.Error("map valid: clean") }
// map form invalid when
if len(checkCache("j", model.Job{Cache: map[string]any{"when": "bad"}})) != 1 { t.Error("invalid when: error") }
// map form invalid policy
if len(checkCache("j", model.Job{Cache: map[string]any{"policy": "bad_policy"}})) != 1 { t.Error("invalid policy: error") }
// slice form
if len(checkCache("j", model.Job{Cache: []any{
map[string]any{"when": "bad"},
map[string]any{"policy": "pull"},
}})) != 1 { t.Error("slice with one bad: error") }
}
// ── checkRules ────────────────────────────────────────────────────────────────
func TestCheckRules(t *testing.T) {
if len(checkRules("j", model.Job{})) != 0 { t.Error("no rules: clean") }
if len(checkRules("j", model.Job{Rules: []model.Rule{{When: "on_success"}}})) != 0 { t.Error("valid when: clean") }
if len(checkRules("j", model.Job{Rules: []model.Rule{{When: "bad_when"}}})) != 1 { t.Error("bad when: error") }
}
// ── checkImage ────────────────────────────────────────────────────────────────
func TestCheckImage(t *testing.T) {
if len(checkImage("j", model.Job{})) != 0 { t.Error("nil: clean") }
if len(checkImage("j", model.Job{Image: "alpine"})) != 0 { t.Error("string: clean") }
if len(checkImage("j", model.Job{Image: map[string]any{"name": "alpine"}})) != 0 { t.Error("map with name: clean") }
if len(checkImage("j", model.Job{Image: map[string]any{"pull_policy": "always"}})) != 1 { t.Error("map without name: error") }
}
// ── checkInherit ──────────────────────────────────────────────────────────────
func TestCheckInherit(t *testing.T) {
if len(checkInherit("j", model.Job{})) != 0 { t.Error("nil: clean") }
if len(checkInherit("j", model.Job{Inherit: "string"})) != 0 { t.Error("non-map: clean") }
if len(checkInherit("j", model.Job{Inherit: map[string]any{"default": true}})) != 0 { t.Error("bool: clean") }
if len(checkInherit("j", model.Job{Inherit: map[string]any{"default": []any{"image"}}})) != 0 { t.Error("list: clean") }
if len(checkInherit("j", model.Job{Inherit: map[string]any{"variables": "string"}})) != 1 { t.Error("invalid type: error") }
}
// ── checkServices ─────────────────────────────────────────────────────────────
func TestCheckServices(t *testing.T) {
if len(checkServices("j", model.Job{})) != 0 { t.Error("nil: clean") }
if len(checkServices("j", model.Job{Services: []any{"postgres"}})) != 0 { t.Error("string: clean") }
if len(checkServices("j", model.Job{Services: []any{map[string]any{"name": "pg"}}})) != 0 { t.Error("valid map: clean") }
if len(checkServices("j", model.Job{Services: []any{map[string]any{"port": 5432}}})) != 1 { t.Error("no name: error") }
if len(checkServices("j", model.Job{Services: []any{map[string]any{"name": "pg", "alias": "invalid alias!"}}})) != 1 { t.Error("bad alias: error") }
if len(checkServices("j", model.Job{Services: []any{map[string]any{"name": "pg", "alias": "valid-alias"}}})) != 0 { t.Error("good alias: clean") }
}
// ── checkRulesGlobs ───────────────────────────────────────────────────────────
func TestCheckRulesGlobs(t *testing.T) {
if len(checkRulesGlobs("j", model.Job{})) != 0 { t.Error("no rules: clean") }
// Relative path: clean
relRule := model.Rule{Changes: []any{"src/**/*.go"}}
if len(checkRulesGlobs("j", model.Job{Rules: []model.Rule{relRule}})) != 0 { t.Error("relative: clean") }
// Absolute path: warning
absRule := model.Rule{Changes: []any{"/absolute/path"}}
if len(checkRulesGlobs("j", model.Job{Rules: []model.Rule{absRule}})) != 1 { t.Error("absolute: warning") }
// Map form with paths
mapRule := model.Rule{Changes: map[string]any{"paths": []any{"/abs"}}}
if len(checkRulesGlobs("j", model.Job{Rules: []model.Rule{mapRule}})) != 1 { t.Error("map absolute: warning") }
// exists
existsRule := model.Rule{Exists: []any{"/bad"}}
if len(checkRulesGlobs("j", model.Job{Rules: []model.Rule{existsRule}})) != 1 { t.Error("exists absolute: warning") }
}
// ── checkTimeout ─────────────────────────────────────────────────────────────
func TestCheckTimeout(t *testing.T) {
if len(checkTimeout("j", model.Job{})) != 0 { t.Error("empty: clean") }
if len(checkTimeout("j", model.Job{Timeout: "1h 30m"})) != 0 { t.Error("valid: clean") }
if len(checkTimeout("j", model.Job{Timeout: "90 minutes"})) != 0 { t.Error("valid2: clean") }
if len(checkTimeout("j", model.Job{Timeout: "not-a-duration"})) != 1 { t.Error("invalid: error") }
}
func TestCheckDefaultTimeout(t *testing.T) {
if len(checkDefaultTimeout("", "f.yml")) != 0 { t.Error("empty: clean") }
if len(checkDefaultTimeout("2 hours", "f.yml")) != 0 { t.Error("valid: clean") }
if len(checkDefaultTimeout("bad", "f.yml")) != 1 { t.Error("invalid: error") }
}
// ── checkIDTokens ─────────────────────────────────────────────────────────────
func TestCheckIDTokens(t *testing.T) {
if len(checkIDTokens("j", model.Job{})) != 0 { t.Error("nil: clean") }
if len(checkIDTokens("j", model.Job{IDTokens: "string"})) != 0 { t.Error("non-map: clean") }
// valid token with aud
if len(checkIDTokens("j", model.Job{IDTokens: map[string]any{
"MY_TOKEN": map[string]any{"aud": "https://example.com"},
}})) != 0 { t.Error("valid: clean") }
// missing aud
if len(checkIDTokens("j", model.Job{IDTokens: map[string]any{
"MY_TOKEN": map[string]any{"other": "x"},
}})) != 1 { t.Error("missing aud: error") }
// not a map
if len(checkIDTokens("j", model.Job{IDTokens: map[string]any{
"MY_TOKEN": "string",
}})) != 1 { t.Error("token not a map: error") }
}
// ── checkSecrets ─────────────────────────────────────────────────────────────
func TestCheckSecrets(t *testing.T) {
if len(checkSecrets("j", model.Job{})) != 0 { t.Error("nil: clean") }
if len(checkSecrets("j", model.Job{Secrets: "string"})) != 0 { t.Error("non-map: clean") }
// valid vault
if len(checkSecrets("j", model.Job{Secrets: map[string]any{
"MY_SECRET": map[string]any{"vault": map[string]any{"engine": map[string]any{"name": "kv", "path": "x"}, "path": "y", "field": "z"}},
}})) != 0 { t.Error("vault: clean") }
// missing provider
if len(checkSecrets("j", model.Job{Secrets: map[string]any{
"MY_SECRET": map[string]any{"other": "x"},
}})) != 1 { t.Error("missing provider: error") }
// not a map
if len(checkSecrets("j", model.Job{Secrets: map[string]any{
"MY_SECRET": "string",
}})) != 1 { t.Error("not a map: error") }
}
// ── checkPagesKeyword ─────────────────────────────────────────────────────────
func TestCheckPagesKeyword(t *testing.T) {
if len(checkPagesKeyword("j", model.Job{})) != 0 { t.Error("nil pages: clean") }
// pages keyword but no artifacts
if len(checkPagesKeyword("j", model.Job{Pages: map[string]any{}})) != 1 { t.Error("no artifacts: warning") }
// pages keyword with artifacts but no paths
if len(checkPagesKeyword("j", model.Job{
Pages: map[string]any{},
Artifacts: map[string]any{},
})) != 1 { t.Error("no paths: warning") }
// pages with public in paths
if len(checkPagesKeyword("j", model.Job{
Pages: map[string]any{},
Artifacts: map[string]any{"paths": []any{"public"}},
})) != 0 { t.Error("public in paths: clean") }
// pages with custom publish dir and matching path
if len(checkPagesKeyword("j", model.Job{
Pages: map[string]any{"publish": "dist"},
Artifacts: map[string]any{"paths": []any{"dist"}},
})) != 0 { t.Error("custom publish match: clean") }
// pages with custom publish dir missing from paths
if len(checkPagesKeyword("j", model.Job{
Pages: map[string]any{"publish": "dist"},
Artifacts: map[string]any{"paths": []any{"public"}},
})) != 1 { t.Error("custom publish missing: warning") }
// artifacts is a non-map (unexpected)
if len(checkPagesKeyword("j", model.Job{
Pages: map[string]any{},
Artifacts: "string",
})) != 0 { t.Error("non-map artifacts: clean") }
}
// ── checkCacheKeyFiles ────────────────────────────────────────────────────────
func TestCheckCacheKeyFiles(t *testing.T) {
if len(checkCacheKeyFiles("j", model.Job{})) != 0 { t.Error("nil: clean") }
// valid key.files
if len(checkCacheKeyFiles("j", model.Job{Cache: map[string]any{
"key": map[string]any{"files": []any{"Gemfile.lock"}},
}})) != 0 { t.Error("valid: clean") }
// glob pattern in key.files
if len(checkCacheKeyFiles("j", model.Job{Cache: map[string]any{
"key": map[string]any{"files": []any{"*.lock"}},
}})) != 1 { t.Error("glob: warning") }
// files is not a list
if len(checkCacheKeyFiles("j", model.Job{Cache: map[string]any{
"key": map[string]any{"files": "Gemfile.lock"},
}})) != 1 { t.Error("non-list: error") }
// no key
if len(checkCacheKeyFiles("j", model.Job{Cache: map[string]any{}})) != 0 { t.Error("no key: clean") }
// key is not a map
if len(checkCacheKeyFiles("j", model.Job{Cache: map[string]any{"key": "string"}})) != 0 { t.Error("key string: clean") }
// slice cache form
if len(checkCacheKeyFiles("j", model.Job{Cache: []any{
map[string]any{"key": map[string]any{"files": []any{"*.lock"}}},
}})) != 1 { t.Error("slice cache with glob: warning") }
}
// ── linter.go level checks ────────────────────────────────────────────────────
func TestCheckStages(t *testing.T) {
// no stages → warning
p := &model.Pipeline{}
if len(checkStages(p)) != 1 { t.Error("no stages: warning") }
// with stages → clean
p2 := &model.Pipeline{Stages: []string{"build"}}
if len(checkStages(p2)) != 0 { t.Error("with stages: clean") }
}
func TestCheckDuplicateStages(t *testing.T) {
p := &model.Pipeline{Stages: []string{"build", "test", "build"}}
if len(checkDuplicateStages(p)) != 1 { t.Error("duplicate: warning") }
p2 := &model.Pipeline{Stages: []string{"build", "test"}}
if len(checkDuplicateStages(p2)) != 0 { t.Error("no duplicate: clean") }
}
func TestCheckWorkflow(t *testing.T) {
p := &model.Pipeline{Workflow: &model.Workflow{Rules: []model.Rule{{When: "always"}}}}
if len(checkWorkflow(p)) != 0 { t.Error("valid when: clean") }
p2 := &model.Pipeline{Workflow: &model.Workflow{Rules: []model.Rule{{When: "bad"}}}}
if len(checkWorkflow(p2)) != 1 { t.Error("invalid when: error") }
p3 := &model.Pipeline{}
if len(checkWorkflow(p3)) != 0 { t.Error("nil workflow: clean") }
}
func TestFindingString(t *testing.T) {
f := Finding{
Severity: Error,
Rule: "GL001",
Job: "build",
File: "ci.yml",
Line: 10,
Message: "missing script",
}
s := f.String()
if s == "" { t.Error("expected non-empty string") }
// Without file/line/job
f2 := Finding{Severity: Warning, Rule: "GL002", Message: "test"}
s2 := f2.String()
if s2 == "" { t.Error("expected non-empty string") }
// With file but no line
f3 := Finding{Severity: Error, Rule: "GL003", File: "ci.yml", Message: "x"}
if f3.String() == "" { t.Error("expected non-empty string") }
}
func TestScriptNonEmpty(t *testing.T) {
if scriptNonEmpty(nil) { t.Error("nil") }
if scriptNonEmpty([]any{}) { t.Error("empty slice") }
if !scriptNonEmpty([]any{"echo"}) { t.Error("non-empty slice") }
if !scriptNonEmpty("echo") { t.Error("string") }
if scriptNonEmpty("") { t.Error("empty string") }
}
// ── checkNeeds ────────────────────────────────────────────────────────────────
func TestCheckNeeds(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"build", "test"},
Jobs: map[string]model.Job{
"build-job": {Name: "build-job", Stage: "build", Script: []any{"make"}},
"test-job": {Name: "test-job", Stage: "test", Script: []any{"test"},
Needs: []any{"build-job"}},
},
}
if len(checkNeeds(p)) != 0 { t.Error("valid needs: clean") }
// needs unknown job
p2 := &model.Pipeline{
Jobs: map[string]model.Job{
"test-job": {Name: "test-job", Stage: "test", Needs: []any{"nonexistent"}},
},
}
if len(checkNeeds(p2)) != 1 { t.Error("unknown needs: error") }
// optional: true for unknown job → warning not error
p3 := &model.Pipeline{
Jobs: map[string]model.Job{
"test-job": {Name: "test-job", Stage: "test",
Needs: []any{map[string]any{"job": "ghost", "optional": true}}},
},
}
findings := checkNeeds(p3)
if len(findings) != 1 || findings[0].Severity != Warning {
t.Error("optional unknown needs: warning")
}
// cross-pipeline need (ignored)
p4 := &model.Pipeline{
Jobs: map[string]model.Job{
"test-job": {Name: "test-job",
Needs: []any{map[string]any{"pipeline": "other", "job": "j"}}},
},
}
if len(checkNeeds(p4)) != 0 { t.Error("cross-pipeline: clean") }
// needs later stage → error
p5 := &model.Pipeline{
Stages: []string{"build", "test"},
Jobs: map[string]model.Job{
"build-job": {Name: "build-job", Stage: "build", Script: []any{"make"}},
"early-job": {Name: "early-job", Stage: "build",
Needs: []any{"build-job"}, Script: []any{"echo"}},
"test-job": {Name: "test-job", Stage: "test", Script: []any{"test"},
Needs: []any{"build-job"}}, // ok
},
}
// Only cross-stage ordering violations should be found
_ = checkNeeds(p5)
}
func TestCheckNeeds_Cycle(t *testing.T) {
p := &model.Pipeline{
Jobs: map[string]model.Job{
"a": {Name: "a", Needs: []any{"b"}},
"b": {Name: "b", Needs: []any{"a"}},
},
}
findings := checkNeeds(p)
for _, f := range findings {
if f.Rule == RuleNeedsCycle { return }
}
t.Error("expected cycle detection finding")
}
// ── checkDependencies ─────────────────────────────────────────────────────────
func TestCheckDependencies(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"build", "test"},
Jobs: map[string]model.Job{
"build-job": {Name: "build-job", Stage: "build", Script: []any{"make"}},
"test-job": {Name: "test-job", Stage: "test", Script: []any{"test"},
Dependencies: []string{"build-job"}},
},
}
if len(checkDependencies(p)) != 0 { t.Error("valid deps: clean") }
// unknown dep
p2 := &model.Pipeline{
Jobs: map[string]model.Job{
"test-job": {Name: "test-job", Stage: "test", Dependencies: []string{"ghost"}},
},
}
if len(checkDependencies(p2)) != 1 { t.Error("unknown dep: error") }
// dep in same or later stage → error
p3 := &model.Pipeline{
Stages: []string{"build", "test"},
Jobs: map[string]model.Job{
"test-job1": {Name: "test-job1", Stage: "test"},
"test-job2": {Name: "test-job2", Stage: "test", Dependencies: []string{"test-job1"}},
},
}
if len(checkDependencies(p3)) != 1 { t.Error("same stage dep: error") }
}
// ── checkCacheKeyFiles ────────────────────────────────────────────────────────
func TestCheckCacheKeyFiles_NoFilesKey(t *testing.T) {
// cache.key is a map but has no "files" key → continue (line 823-824).
job := model.Job{
Cache: map[string]any{
"key": map[string]any{"prefix": "v1"},
},
}
if got := checkCacheKeyFiles("j", job); len(got) != 0 {
t.Errorf("key map without 'files': expected no findings, got %v", got)
}
}
func TestCheckCacheKeyFiles_FilesNotList(t *testing.T) {
// cache.key.files is a scalar (not a list) → error finding (line 827-834).
job := model.Job{
Cache: map[string]any{
"key": map[string]any{"files": "single-file.lock"},
},
}
got := checkCacheKeyFiles("j", job)
if len(got) == 0 {
t.Error("files as scalar: expected error finding")
return
}
if got[0].Rule != RuleInvalidCacheKeyFiles || got[0].Severity != Error {
t.Errorf("unexpected finding: %v", got[0])
}
}
func TestCheckCacheKeyFiles_NonStringItem(t *testing.T) {
// cache.key.files list has a non-string item → skip it (line 838-839).
job := model.Job{
Cache: map[string]any{
"key": map[string]any{
"files": []any{42, "go.sum"}, // 42 is non-string, go.sum is valid
},
},
}
// 42 is skipped; "go.sum" has no glob chars → no findings.
if got := checkCacheKeyFiles("j", job); len(got) != 0 {
t.Errorf("non-string item: expected no findings, got %v", got)
}
}
+105 -7
View File
@@ -1,10 +1,12 @@
package linter package linter
import ( import (
"cmp"
"fmt" "fmt"
"slices"
"strings" "strings"
"git.k3nny.fr/gitlab-sim/internal/model" "git.k3nny.fr/glint/internal/model"
) )
type Severity string type Severity string
@@ -16,25 +18,61 @@ 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)...)
findings = append(findings, checkRulesIfReachability(p)...)
findings = append(findings, checkInheritCompleteness(p)...)
slices.SortStableFunc(findings, func(a, b Finding) int {
if c := cmp.Compare(a.File, b.File); c != 0 {
return c
}
if c := cmp.Compare(a.Line, b.Line); c != 0 {
return c
}
return cmp.Compare(a.Rule, b.Rule)
})
return findings return findings
} }
@@ -43,12 +81,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 +124,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 +156,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.
hasScript := len(job.Script) > 0 || job.Run != nil // 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
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 +178,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 +188,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 +198,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 +208,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,5 +216,24 @@ 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
} }
// scriptNonEmpty reports whether a script/before_script/after_script field
// (which may be a []any list or a plain string) is non-empty.
func scriptNonEmpty(v any) bool {
switch s := v.(type) {
case []any:
return len(s) > 0
case string:
return s != ""
}
return false
}
+142
View File
@@ -0,0 +1,142 @@
package linter
import (
"testing"
"git.k3nny.fr/glint/internal/model"
)
// TestCheckDefault exercises the non-nil default block code path.
func TestCheckDefault(t *testing.T) {
// nil default → nil return (already covered implicitly; included for clarity)
if got := checkDefault(&model.Pipeline{}); got != nil {
t.Errorf("nil default: want nil findings, got %v", got)
}
// non-nil default with empty timeout → no findings
if got := checkDefault(&model.Pipeline{Default: &model.DefaultConfig{}}); got != nil {
t.Errorf("empty default: want no findings, got %v", got)
}
// non-nil default with invalid timeout → finding produced
findings := checkDefault(&model.Pipeline{Default: &model.DefaultConfig{Timeout: "bad-value"}})
if len(findings) == 0 {
t.Error("invalid timeout in default block should produce a finding")
}
}
// TestCheckJob_MissingScript covers the error/warning paths for jobs without a
// script field.
func TestCheckJob_MissingScript(t *testing.T) {
stageSet := map[string]bool{"build": true}
// No script, no extends → Error.
findings := checkJob("my-job", model.Job{Stage: "build"}, stageSet)
var gotErr bool
for _, f := range findings {
if f.Rule == RuleMissingScript && f.Severity == Error {
gotErr = true
}
}
if !gotErr {
t.Error("job with no script and no extends: expected Error finding GL003")
}
}
// TestCheckJob_ExtendsNoScript covers the Warning variant: a job that extends
// a base template but has no script (the script may come from the base, which
// could not be fetched).
func TestCheckJob_ExtendsNoScript(t *testing.T) {
stageSet := map[string]bool{"build": true}
job := model.Job{Stage: "build", Extends: ".base-template"}
findings := checkJob("my-job", job, stageSet)
var gotWarn bool
for _, f := range findings {
if f.Rule == RuleMissingScript && f.Severity == Warning {
gotWarn = true
}
}
if !gotWarn {
t.Error("job with extends but no script: expected Warning finding GL003")
}
}
// TestCheckJob_StageInputPlaceholder verifies that a stage value containing
// "$[[" (a component input placeholder) skips the unknown-stage check.
func TestCheckJob_StageInputPlaceholder(t *testing.T) {
stageSet := map[string]bool{"build": true}
job := model.Job{Stage: "$[[inputs.stage]]", Script: []any{"echo"}}
for _, f := range checkJob("my-job", job, stageSet) {
if f.Rule == RuleUnknownStage {
t.Error("stage with $[[ placeholder should not produce GL004")
}
}
}
// TestCheckJob_OnlyAndRules covers the only+rules conflict (GL005).
func TestCheckJob_OnlyAndRules(t *testing.T) {
stageSet := map[string]bool{"build": true}
job := model.Job{
Stage: "build",
Script: []any{"echo"},
Only: []any{"branches"},
Rules: []model.Rule{{When: "on_success"}},
}
var gotConflict bool
for _, f := range checkJob("my-job", job, stageSet) {
if f.Rule == RuleOnlyRulesConflict {
gotConflict = true
}
}
if !gotConflict {
t.Error("only+rules: expected GL005 conflict finding")
}
}
// TestCheckJob_ExceptAndRules covers the except+rules conflict (GL006).
func TestCheckJob_ExceptAndRules(t *testing.T) {
stageSet := map[string]bool{"build": true}
job := model.Job{
Stage: "build",
Script: []any{"echo"},
Except: []any{"tags"},
Rules: []model.Rule{{When: "on_success"}},
}
var gotConflict bool
for _, f := range checkJob("my-job", job, stageSet) {
if f.Rule == RuleExceptRulesConflict {
gotConflict = true
}
}
if !gotConflict {
t.Error("except+rules: expected GL006 conflict finding")
}
}
// TestCheckJob_RunField verifies that a job with a 'run:' block (instead of
// 'script:') is not flagged for missing script.
func TestCheckJob_RunField(t *testing.T) {
stageSet := map[string]bool{"build": true}
job := model.Job{Stage: "build", Run: map[string]any{"steps": []any{"echo hi"}}}
for _, f := range checkJob("my-job", job, stageSet) {
if f.Rule == RuleMissingScript {
t.Error("job with run: should not produce GL003 (missing script)")
}
}
}
// TestCheckJob_UnknownStage verifies that a job with a declared stage value that
// is not in the stageSet produces a GL004 finding (line 178-185).
func TestCheckJob_UnknownStage(t *testing.T) {
stageSet := map[string]bool{"build": true, "test": true}
job := model.Job{Stage: "unknown-stage", Script: []any{"echo"}}
var gotGL004 bool
for _, f := range checkJob("my-job", job, stageSet) {
if f.Rule == RuleUnknownStage {
gotGL004 = true
}
}
if !gotGL004 {
t.Error("job with undeclared stage: expected GL004 finding")
}
}
+46 -20
View File
@@ -3,9 +3,15 @@ package linter
import ( import (
"fmt" "fmt"
"git.k3nny.fr/gitlab-sim/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),
}) })
} }
+38
View File
@@ -0,0 +1,38 @@
package linter
import (
"testing"
"git.k3nny.fr/glint/internal/model"
)
// TestCheckNeeds_StageOrder verifies that a job needing a job in a later stage
// produces RuleNeedsStageOrder (line 64-76 in needs.go).
func TestCheckNeeds_StageOrder(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"build", "test", "deploy"},
Jobs: map[string]model.Job{
"build-job": {
Name: "build-job",
Stage: "build",
Script: []any{"make"},
Needs: []any{"deploy-job"},
},
"deploy-job": {
Name: "deploy-job",
Stage: "deploy",
Script: []any{"make deploy"},
},
},
}
findings := checkNeeds(p)
var gotStageOrder bool
for _, f := range findings {
if f.Rule == RuleNeedsStageOrder {
gotStageOrder = true
}
}
if !gotStageOrder {
t.Errorf("build-job needing deploy-job (later stage): expected GL025 finding; got: %v", findings)
}
}
+157
View File
@@ -0,0 +1,157 @@
package linter
// Rule ID constants. Each ID is stable across versions and uniquely identifies
// one lint check. Use these when filtering output, writing suppression rules,
// or referencing a check in documentation.
//
// Prefix GL = Glint / GitLab CI lint.
// Numbering is sequential by category; gaps may appear as rules are added.
const (
// ── Pipeline-level ──────────────────────────────────────────────────────
// GL001: no stages: block defined; GitLab falls back to default stages.
RuleNoStages = "GL001"
// GL002: workflow.rules[n].when has an invalid value (only always/never allowed).
RuleWorkflowWhen = "GL002"
// ── Job structure ────────────────────────────────────────────────────────
// GL003: job is missing a required script: (or run:) field.
RuleMissingScript = "GL003"
// GL004: job references a stage not declared in stages:.
RuleUnknownStage = "GL004"
// GL005: only: and rules: used together on the same job.
RuleOnlyRulesConflict = "GL005"
// GL006: except: and rules: used together on the same job.
RuleExceptRulesConflict = "GL006"
// GL007: only:/except: used (deprecated; prefer rules:).
RuleDeprecatedOnly = "GL007"
// ── Keyword constraints ──────────────────────────────────────────────────
// GL008: when: has an invalid value.
RuleInvalidWhen = "GL008"
// GL009: when: delayed without start_in:.
RuleDelayedNoStartIn = "GL009"
// GL010: start_in: set when when: is not delayed.
RuleStartInNoDelayed = "GL010"
// GL011: parallel: value is invalid (integer out of range or map missing matrix:).
RuleInvalidParallel = "GL011"
// GL012: retry: integer is out of range 02, or retry: is neither int nor map.
RuleInvalidRetry = "GL012"
// GL013: retry.when: contains an unrecognised failure type.
RuleInvalidRetryWhen = "GL013"
// GL014: allow_failure: is not a boolean or a map with exit_codes:.
RuleInvalidAllowFailure = "GL014"
// GL015: interruptible: is not a boolean.
RuleInvalidInterruptible = "GL015"
// GL016: trigger: job also defines script: (mutually exclusive).
RuleTriggerWithScript = "GL016"
// GL017: trigger: map does not specify project: or include:.
RuleInvalidTrigger = "GL017"
// GL018: coverage: is not a regex pattern wrapped in /.
RuleInvalidCoverage = "GL018"
// GL019: release: is missing required tag_name:, or is not a map.
RuleInvalidRelease = "GL019"
// GL020: environment: has an invalid url/action configuration.
RuleInvalidEnvironment = "GL020"
// GL021: artifacts: has an invalid when/expose_as configuration.
RuleInvalidArtifacts = "GL021"
// GL022: pages job artifacts.paths does not include public/.
RulePagesPublic = "GL022"
// GL023: cache: has an invalid when/policy value.
RuleInvalidCache = "GL023"
// GL024: rules[n].when has an invalid value.
RuleInvalidRulesWhen = "GL024"
// GL025: image: map form is missing a name: key.
RuleInvalidImage = "GL025"
// GL026: inherit.default or inherit.variables is not a boolean or list.
RuleInvalidInherit = "GL026"
// ── Cross-job graph ──────────────────────────────────────────────────────
// GL027: needs: references a job that does not exist in the pipeline.
RuleNeedsUnknown = "GL027"
// GL028: needs: references a job in a later stage than the current job.
RuleNeedsStageOrder = "GL028"
// GL029: circular dependency detected in the needs: graph.
RuleNeedsCycle = "GL029"
// GL030: dependencies: references a job that does not exist.
RuleUnknownDependency = "GL030"
// GL031: dependencies: references a job in the same or a later stage.
RuleDependencyStage = "GL031"
// ── Expression validation ────────────────────────────────────────────────
// GL032: rules:if: references a variable not declared in pipeline variables:,
// the job's own variables:, or any workflow:rules:variables: block.
// May be a false positive for variables set in GitLab CI/CD project settings.
RuleUndeclaredVariable = "GL032"
// GL033: every rule in a job's rules: block has when: never, so the job
// can never be included in any pipeline run.
RuleDeadRules = "GL033"
// GL034: services: map form is missing 'name', or 'alias' is not a valid DNS label.
RuleInvalidService = "GL034"
// GL035: rules:changes or rules:exists contains an absolute path (starts with /);
// GitLab CI paths are relative to the repository root and absolute paths never match.
RuleAbsoluteGlobPath = "GL035"
// GL036: timeout: is not a valid GitLab CI duration string (e.g. '1h 30m', '90 minutes').
RuleInvalidTimeout = "GL036"
// GL037: id_tokens: entry is missing the required 'aud' key.
RuleInvalidIDToken = "GL037"
// GL038: secrets: entry is missing a provider key (vault, gcp_secret_manager, or azure_key_vault).
RuleInvalidSecret = "GL038"
// GL039: a job has the pages: keyword but artifacts.paths does not include the publish directory.
RulePagesPublish = "GL039"
// GL040: a stage name appears more than once in stages:; GitLab silently merges duplicates.
RuleDuplicateStage = "GL040"
// GL041: cache.key.files contains a glob pattern; it must be a list of exact file paths.
RuleInvalidCacheKeyFiles = "GL041"
// GL042: every rules:if: condition in a job's rules: block evaluates to false
// given the values of variables declared in the pipeline YAML, so the job can
// never be active. Only fires when all referenced variables are declared
// (predefined CI_* / GITLAB_* vars are not evaluated to avoid false positives).
RuleStaticDeadRules = "GL042"
// GL043: a job declares 'inherit: default:' (true, false, or list) but the
// pipeline has no 'default:' block, making the declaration a no-op. Also fires
// when 'inherit: default: [list]' names fields not set in the default: block.
RuleInheritNoDefault = "GL043"
)
+89
View File
@@ -0,0 +1,89 @@
package linter_test
import (
"path/filepath"
"testing"
"git.k3nny.fr/glint/internal/fetcher"
"git.k3nny.fr/glint/internal/linter"
"git.k3nny.fr/glint/internal/model"
"git.k3nny.fr/glint/internal/resolver"
)
// TestSambaCI verifies that the Samba project's .gitlab-ci.yml (a real-world
// pipeline that is valid on GitLab) produces no Error findings.
// These files exercise local include resolution and multi-level extends chains.
func TestSambaCI(t *testing.T) {
entryPoint := "../../testdata/samba/.gitlab-ci.yml"
p, err := model.Parse(entryPoint)
if err != nil {
t.Fatalf("Parse: %v", err)
}
rootDir := filepath.Dir(filepath.Clean(entryPoint))
incWarnings, _ := resolver.ResolveIncludes(p, fetcher.GitLabConfig{}, rootDir)
for _, w := range incWarnings {
t.Logf("include warning: %s", w)
}
extWarnings, err := resolver.Resolve(p)
if err != nil {
t.Fatalf("Resolve: %v", err)
}
for _, w := range extWarnings {
t.Logf("extends warning: job %q extends unknown %q", w.Job, w.Base)
}
findings := linter.Lint(p)
for _, f := range findings {
if f.Severity == linter.Error {
t.Errorf("unexpected error finding on valid Samba CI: %s", f)
}
}
}
// TestSambaCIEntryFiles verifies all of the Samba entry-point files
// (files that can each act as the top-level CI file) lint without errors.
func TestSambaCIEntryFiles(t *testing.T) {
entryPoints := []struct {
name string
path string
}{
{"default", "../../testdata/samba/.gitlab-ci.yml"},
{"coverage", "../../testdata/samba/.gitlab-ci-coverage.yml"},
{"private", "../../testdata/samba/.gitlab-ci-private.yml"},
}
for _, tc := range entryPoints {
tc := tc
t.Run(tc.name, func(t *testing.T) {
p, err := model.Parse(tc.path)
if err != nil {
t.Fatalf("Parse: %v", err)
}
rootDir := filepath.Dir(filepath.Clean(tc.path))
incWarnings, _ := resolver.ResolveIncludes(p, fetcher.GitLabConfig{}, rootDir)
for _, w := range incWarnings {
t.Logf("include warning: %s", w)
}
extWarnings, err := resolver.Resolve(p)
if err != nil {
t.Fatalf("Resolve: %v", err)
}
for _, w := range extWarnings {
t.Logf("extends warning: job %q extends unknown %q", w.Job, w.Base)
}
findings := linter.Lint(p)
for _, f := range findings {
if f.Severity == linter.Error {
t.Errorf("unexpected error finding: %s", f)
}
}
})
}
}
+158
View File
@@ -0,0 +1,158 @@
package linter
import (
"fmt"
"strings"
"git.k3nny.fr/glint/internal/model"
)
// predefinedVarPrefixes lists GitLab-maintained variable namespaces that are
// always available without an explicit declaration in variables: blocks.
var predefinedVarPrefixes = []string{
"CI_", // most predefined CI variables (CI_COMMIT_BRANCH, CI_JOB_ID, …)
"GITLAB_", // user/project metadata (GITLAB_USER_ID, GITLAB_FEATURES, …)
"FF_", // GitLab feature flags
"RUNNER_", // runner-level variables
"TRIGGER_", // trigger token variables passed from upstream pipelines
"CHAT_", // ChatOps variables
}
func isPredefinedVar(name string) bool {
for _, prefix := range predefinedVarPrefixes {
if strings.HasPrefix(name, prefix) {
return true
}
}
return false
}
// extractIfVars returns every variable name referenced in a rules:if: expression.
// String literals are skipped so that dollar signs inside quoted values are
// not mistaken for variable references.
func extractIfVars(expr string) []string {
var names []string
i := 0
for i < len(expr) {
switch expr[i] {
case '"', '\'':
quote := expr[i]
i++
for i < len(expr) && expr[i] != quote {
if expr[i] == '\\' {
i++ // skip escaped character
}
i++
}
if i < len(expr) {
i++ // consume closing quote
}
case '$':
i++ // consume '$'
if i < len(expr) && expr[i] == '{' {
i++ // consume '{'
start := i
for i < len(expr) && isVarNameByte(expr[i]) {
i++
}
if i < len(expr) && expr[i] == '}' && i > start {
names = append(names, expr[start:i])
i++ // consume '}'
}
} else {
start := i
for i < len(expr) && isVarNameByte(expr[i]) {
i++
}
if i > start {
names = append(names, expr[start:i])
}
}
default:
i++
}
}
return names
}
func isVarNameByte(b byte) bool {
return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') || b == '_'
}
// checkVariableRefs warns when a rules:if: expression references a variable that
// is not declared in pipeline variables:, the job's own variables:, or any
// workflow:rules:variables: block. Predefined GitLab CI variables (CI_*, GITLAB_*,
// …) are always exempt. Variables set in GitLab CI/CD project settings are
// invisible to glint, so the finding is a WARNING rather than an error.
func checkVariableRefs(p *model.Pipeline) []Finding {
pipelineVars := make(map[string]bool, len(p.Variables))
for k := range p.Variables {
pipelineVars[k] = true
}
// Union of all variables any workflow rule might inject into the context.
workflowRuleVars := make(map[string]bool)
if p.Workflow != nil {
for _, rule := range p.Workflow.Rules {
for k := range rule.Variables {
workflowRuleVars[k] = true
}
}
}
var findings []Finding
// Check workflow rules:if: expressions.
if p.Workflow != nil {
seen := make(map[string]bool)
for i, rule := range p.Workflow.Rules {
if rule.If == "" {
continue
}
for _, varName := range extractIfVars(rule.If) {
if isPredefinedVar(varName) || pipelineVars[varName] || seen[varName] {
continue
}
seen[varName] = true
findings = append(findings, Finding{
Severity: Warning,
Rule: RuleUndeclaredVariable,
File: p.SourceFile,
Message: fmt.Sprintf("workflow.rules[%d].if: $%s is not declared in pipeline variables:", i, varName),
})
}
}
}
// Check each job's rules:if: expressions.
for name, job := range p.Jobs {
jobVars := make(map[string]bool, len(job.Variables))
for k := range job.Variables {
jobVars[k] = true
}
// Deduplicate per (job, varName): report each undeclared variable once per job.
seen := make(map[string]bool)
for i, rule := range job.Rules {
if rule.If == "" {
continue
}
for _, varName := range extractIfVars(rule.If) {
if isPredefinedVar(varName) || pipelineVars[varName] || jobVars[varName] || workflowRuleVars[varName] || seen[varName] {
continue
}
seen[varName] = true
findings = append(findings, Finding{
Severity: Warning,
Rule: RuleUndeclaredVariable,
Job: name,
File: job.File,
Line: job.Line,
Message: fmt.Sprintf("rules[%d].if: $%s is not declared in pipeline or job variables:", i, varName),
})
}
}
}
return findings
}
+249
View File
@@ -0,0 +1,249 @@
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)
}
})
}
}
// TestCheckVariableRefs_WorkflowRuleEmptyIf covers the `if rule.If == ""` early
// continue at variables.go line 109-110 (workflow rule with no if: expression).
func TestCheckVariableRefs_WorkflowRuleEmptyIf(t *testing.T) {
p := &model.Pipeline{
Workflow: &model.Workflow{
Rules: []model.Rule{
{When: "always"}, // no If → triggers the continue
{If: `$UNDECLARED == "yes"`}, // undeclared → warning
},
},
Jobs: map[string]model.Job{},
}
findings := checkVariableRefs(p)
count := 0
for _, f := range findings {
if f.Rule == RuleUndeclaredVariable {
count++
}
}
if count != 1 {
t.Errorf("expected 1 GL032 warning for UNDECLARED, got %d; findings: %v", count, findings)
}
}
// TestExtractIfVars_StringEscape covers the backslash-escape branch (line 42-44)
// in extractIfVars when scanning a quoted string literal.
func TestExtractIfVars_StringEscape(t *testing.T) {
// `$BRANCH == "de\velop"` — the `\v` inside the string literal triggers the
// escape-character skip in the scanning loop.
got := extractIfVars(`$BRANCH == "de\velop"`)
if len(got) != 1 || got[0] != "BRANCH" {
t.Errorf("extractIfVars with escape in string: got %v, want [BRANCH]", got)
}
}
+134 -7
View File
@@ -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
}
+125
View File
@@ -0,0 +1,125 @@
package model
import "testing"
func TestParseSuppressComment(t *testing.T) {
cases := []struct {
name string
head string
line string
wantRules []string
wantNil bool
}{
{
name: "no comment",
head: "",
line: "",
wantNil: true,
},
{
name: "single rule head comment",
head: "# glint: ignore GL007",
wantRules: []string{"GL007"},
},
{
name: "multiple rules comma-separated",
head: "# glint: ignore GL007, GL032",
wantRules: []string{"GL007", "GL032"},
},
{
name: "multiple rules space-separated",
head: "# glint: ignore GL007 GL032",
wantRules: []string{"GL007", "GL032"},
},
{
name: "ignore all",
head: "# glint: ignore all",
wantRules: []string{"*"},
},
{
name: "ignore all bare",
head: "# glint: ignore",
wantRules: []string{"*"},
},
{
name: "lowercase rule ID is uppercased",
head: "# glint: ignore gl007",
wantRules: []string{"GL007"},
},
{
name: "line comment",
head: "",
line: "# glint: ignore GL004",
wantRules: []string{"GL004"},
},
{
name: "unrelated comment",
head: "# This job deploys to production",
wantNil: true,
},
{
name: "partial match - not a glint directive",
head: "# hint: ignore this",
wantNil: true,
},
{
name: "multi-line head comment with directive on second line",
head: "# Some description\n# glint: ignore GL007",
wantRules: []string{"GL007"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := parseSuppressComment(tc.head, tc.line)
if tc.wantNil {
if got != nil {
t.Errorf("expected nil, got %v", got)
}
return
}
if len(got) != len(tc.wantRules) {
t.Fatalf("got %v, want %v", got, tc.wantRules)
}
for i, r := range tc.wantRules {
if got[i] != r {
t.Errorf("[%d] got %q, want %q", i, got[i], r)
}
}
})
}
}
func TestParseBytes_Suppressions(t *testing.T) {
yaml := `
stages:
- build
# glint: ignore GL007
deprecated-job:
stage: build
only:
- main
normal-job:
stage: build
script: echo ok
`
p, err := ParseBytes([]byte(yaml))
if err != nil {
t.Fatalf("ParseBytes: %v", err)
}
if p.Suppressions == nil {
t.Fatal("Suppressions is nil")
}
rules, ok := p.Suppressions["deprecated-job"]
if !ok {
t.Fatal("no suppression for deprecated-job")
}
if len(rules) != 1 || rules[0] != "GL007" {
t.Errorf("rules = %v, want [GL007]", rules)
}
if _, ok := p.Suppressions["normal-job"]; ok {
t.Error("normal-job should have no suppressions")
}
}
+168
View File
@@ -0,0 +1,168 @@
package model
import (
"os"
"path/filepath"
"testing"
)
// ── Parse (file) ──────────────────────────────────────────────────────────────
func TestParse(t *testing.T) {
t.Run("valid file", func(t *testing.T) {
content := `
stages: [build]
build-job:
stage: build
script: [make]
`
dir := t.TempDir()
path := filepath.Join(dir, ".gitlab-ci.yml")
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
p, err := Parse(path)
if err != nil {
t.Fatalf("Parse: %v", err)
}
if p.SourceFile != path {
t.Errorf("SourceFile: got %q want %q", p.SourceFile, path)
}
if _, ok := p.Jobs["build-job"]; !ok {
t.Error("build-job not found")
}
if p.Jobs["build-job"].File != path {
t.Errorf("job File not set: %q", p.Jobs["build-job"].File)
}
})
t.Run("missing file", func(t *testing.T) {
_, err := Parse("/nonexistent/path/ci.yml")
if err == nil {
t.Fatal("expected error for missing file")
}
})
}
// ── ParseBytes edge cases ─────────────────────────────────────────────────────
func TestParseBytes_EdgeCases(t *testing.T) {
// Empty YAML: doc.Kind is not DocumentNode → return empty Pipeline.
p, err := ParseBytes([]byte(""))
if err != nil {
t.Fatalf("empty YAML: unexpected error: %v", err)
}
if p == nil || len(p.Jobs) != 0 {
t.Error("empty YAML: expected empty pipeline")
}
// YAML null: second pass succeeds, doc root is ScalarNode → return empty Pipeline (line 51-53).
p2, err := ParseBytes([]byte("null"))
if err != nil {
t.Fatalf("null YAML: unexpected error: %v", err)
}
if p2 == nil || len(p2.Jobs) != 0 {
t.Error("null YAML: expected empty pipeline")
}
// Invalid YAML (undefined alias): first-pass Unmarshal fails (line 34-36).
_, err = ParseBytes([]byte("*undefined_anchor"))
if err == nil {
t.Error("undefined alias: expected error from ParseBytes")
}
// Sequence YAML: second-pass Unmarshal fails (cannot decode !!seq into Pipeline).
_, err = ParseBytes([]byte("- item1\n- item2\n"))
if err == nil {
t.Error("sequence YAML: expected error from ParseBytes")
}
// Job value that cannot be decoded as map[string]any (scalar job value).
_, err = ParseBytes([]byte("my-job: \"just a string\"\n"))
if err == nil {
t.Error("scalar job value: expected error from ParseBytes")
}
// Job with wrong field type: rawMap decode succeeds, Job decode fails (line 79-81).
_, err = ParseBytes([]byte("my-job:\n stage: [build, test]\n"))
if err == nil {
t.Error("wrong field type: expected error from ParseBytes (stage must be string)")
}
}
// TestParse_ParseBytesError exercises the Parse → ParseBytes error path (line 18).
func TestParse_ParseBytesError(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "bad.yml")
if err := os.WriteFile(path, []byte("my-job: \"scalar\"\n"), 0o644); err != nil {
t.Fatal(err)
}
_, err := Parse(path)
if err == nil {
t.Error("scalar job value: expected error from Parse")
}
}
// TestParseSuppressComment_NonIgnore ensures that a "glint:" directive that is
// not "ignore" is silently skipped.
func TestParseSuppressComment_NonIgnore(t *testing.T) {
result := parseSuppressComment("# glint: refresh GL001", "")
if result != nil {
t.Errorf("non-ignore glint directive should return nil, got %v", result)
}
}
// ── sanitizeYAMLEscapes ───────────────────────────────────────────────────────
func TestSanitizeYAMLEscapes(t *testing.T) {
cases := []struct {
name string
input string
want string
}{
{
name: "no double quotes — unchanged",
input: "stage: build",
want: "stage: build",
},
{
name: "double-quoted string without backslash",
input: `if: "$CI_BRANCH == \"main\""`,
want: `if: "$CI_BRANCH == \"main\""`,
},
{
// \/ inside double-quoted YAML string becomes \\/ (yaml.v3 does not recognise \/).
name: "slash escape rewritten in larger context",
input: `if: "$CI_BRANCH =~ /^us\//"`,
want: `if: "$CI_BRANCH =~ /^us\\//"`,
},
{
name: "backslash-slash inside double quotes rewritten",
input: `"pattern: /^us\//"` ,
want: `"pattern: /^us\\//"`,
},
{
name: "single-quoted string unchanged",
input: `'hello \/ world'`,
want: `'hello \/ world'`,
},
{
name: "escaped single quote inside single-quoted string",
input: `'it''s fine'`,
want: `'it''s fine'`,
},
{
name: "other backslash escapes unchanged",
input: `"\n\t\r"`,
want: `"\n\t\r"`,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := string(sanitizeYAMLEscapes([]byte(tc.input)))
if got != tc.want {
t.Errorf("got %q\nwant %q", got, tc.want)
}
})
}
}
+29 -10
View File
@@ -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,14 +46,16 @@ 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 []string `yaml:"script"` 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)
BeforeScript []string `yaml:"before_script"` BeforeScript any `yaml:"before_script"` // []string or string
AfterScript []string `yaml:"after_script"` 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.
+22
View File
@@ -0,0 +1,22 @@
package model
import (
"testing"
)
func TestSetJobOrigin(t *testing.T) {
p := &Pipeline{
Jobs: map[string]Job{
"with-file": {Name: "with-file", File: "existing.yml"},
"no-file": {Name: "no-file"},
},
}
p.SetJobOrigin("default.yml")
if p.Jobs["with-file"].File != "existing.yml" {
t.Error("job with existing File should not be overwritten")
}
if p.Jobs["no-file"].File != "default.yml" {
t.Errorf("job without File should be set: got %q", p.Jobs["no-file"].File)
}
}
+26 -9
View File
@@ -3,40 +3,52 @@ package resolver
import ( import (
"fmt" "fmt"
"git.k3nny.fr/gitlab-sim/internal/model" "git.k3nny.fr/glint/internal/model"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
// Resolve resolves all extends: references in p.Jobs in place. // Resolve resolves all extends: references in p.Jobs in place.
// Jobs are merged depth-first so that base definitions are resolved before // Jobs are merged depth-first so that base definitions are resolved before
// derived ones. Mutates p.Jobs with the fully merged Job structs. // derived ones. Mutates p.Jobs with the fully merged Job structs.
func Resolve(p *model.Pipeline) error { //
// The first return value lists extends references whose base job could not be
// found (e.g. it lives in a remote include that was not fetched). Those jobs
// are left unmerged but still passed to the linter. A non-nil error is only
// returned for unrecoverable situations such as circular dependencies.
func Resolve(p *model.Pipeline) ([]ExtendWarning, error) {
var extWarnings []ExtendWarning
// Build extends graph: jobName -> ordered list of base job names. // Build extends graph: jobName -> ordered list of base job names.
extendsGraph := make(map[string][]string, len(p.Jobs)) extendsGraph := make(map[string][]string, len(p.Jobs))
for name, job := range p.Jobs { for name, job := range p.Jobs {
bases, err := parseExtends(job.Extends) bases, err := parseExtends(job.Extends)
if err != nil { if err != nil {
return fmt.Errorf("job %q: invalid extends: %w", name, err) return extWarnings, fmt.Errorf("job %q: invalid extends: %w", name, err)
} }
if len(bases) == 0 { if len(bases) == 0 {
continue continue
} }
skip := false
for _, base := range bases { for _, base := range bases {
if _, ok := p.RawJobs[base]; !ok { if _, ok := p.RawJobs[base]; !ok {
return fmt.Errorf("job %q extends unknown job %q", name, base) extWarnings = append(extWarnings, ExtendWarning{Job: name, Base: base})
skip = true
} }
} }
if skip {
continue // leave this job unmerged; still linted with its own fields
}
extendsGraph[name] = bases extendsGraph[name] = bases
} }
if len(extendsGraph) == 0 { if len(extendsGraph) == 0 {
return nil return extWarnings, nil
} }
// Topological sort — bases must be resolved before derived jobs. // Topological sort — bases must be resolved before derived jobs.
order, err := topoSort(extendsGraph) order, err := topoSort(extendsGraph)
if err != nil { if err != nil {
return err return extWarnings, err
} }
// resolved holds the final merged raw map for each processed job. // resolved holds the final merged raw map for each processed job.
@@ -61,17 +73,22 @@ func Resolve(p *model.Pipeline) error {
// Re-decode the merged map into a Job struct. // Re-decode the merged map into a Job struct.
data, err := yaml.Marshal(merged) data, err := yaml.Marshal(merged)
if err != nil { if err != nil {
return fmt.Errorf("job %q: re-encoding merged definition: %w", name, err) return extWarnings, fmt.Errorf("job %q: re-encoding merged definition: %w", name, err)
} }
var j model.Job var j model.Job
if err := yaml.Unmarshal(data, &j); err != nil { if err := yaml.Unmarshal(data, &j); err != nil {
return 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
} }
return nil return extWarnings, nil
} }
// parseExtends normalises the extends field (string or []any) into []string. // parseExtends normalises the extends field (string or []any) into []string.
+293
View File
@@ -0,0 +1,293 @@
package resolver
import (
"fmt"
"testing"
"git.k3nny.fr/glint/internal/model"
)
// errorYAMLMarshaler implements yaml.Marshaler and always returns an error,
// allowing tests to trigger the yaml.Marshal failure path in Resolve.
type errorYAMLMarshaler struct{}
func (e errorYAMLMarshaler) MarshalYAML() (interface{}, error) {
return nil, fmt.Errorf("forced marshal error for test")
}
// ── parseExtends ──────────────────────────────────────────────────────────────
func TestParseExtends(t *testing.T) {
cases := []struct {
name string
input any
want []string
wantErr bool
}{
{name: "nil", input: nil, want: nil},
{name: "single string", input: "base", want: []string{"base"}},
{name: "slice of strings", input: []any{"base1", "base2"}, want: []string{"base1", "base2"}},
{name: "empty slice", input: []any{}, want: []string{}},
{name: "invalid item in slice", input: []any{42}, wantErr: true},
{name: "unexpected type", input: 123, wantErr: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := parseExtends(tc.input)
if (err != nil) != tc.wantErr {
t.Fatalf("wantErr=%v got err=%v", tc.wantErr, err)
}
if !tc.wantErr {
if len(got) != len(tc.want) {
t.Fatalf("len mismatch: got %v want %v", got, tc.want)
}
for i := range got {
if got[i] != tc.want[i] {
t.Errorf("[%d] got %q want %q", i, got[i], tc.want[i])
}
}
}
})
}
}
// ── topoSort ──────────────────────────────────────────────────────────────────
func TestTopoSort(t *testing.T) {
t.Run("simple chain", func(t *testing.T) {
// a → b (b must come before a)
graph := map[string][]string{"a": {"b"}, "b": {}}
order, err := topoSort(graph)
if err != nil {
t.Fatal(err)
}
// b must appear before a
pos := func(s string) int {
for i, v := range order { if v == s { return i } }
return -1
}
if pos("b") > pos("a") {
t.Errorf("b should come before a, got %v", order)
}
})
t.Run("no deps", func(t *testing.T) {
graph := map[string][]string{"a": {}, "b": {}}
order, err := topoSort(graph)
if err != nil {
t.Fatal(err)
}
if len(order) != 2 {
t.Fatalf("expected 2 items, got %v", order)
}
})
t.Run("cycle detected", func(t *testing.T) {
graph := map[string][]string{"a": {"b"}, "b": {"a"}}
_, err := topoSort(graph)
if err == nil {
t.Fatal("expected cycle error")
}
})
t.Run("already visited node skipped", func(t *testing.T) {
// diamond: a→b, a→c, b→d, c→d
graph := map[string][]string{
"a": {"b", "c"},
"b": {"d"},
"c": {"d"},
"d": {},
}
order, err := topoSort(graph)
if err != nil {
t.Fatal(err)
}
// d must come first (or at least before b, c, a)
pos := func(s string) int {
for i, v := range order { if v == s { return i } }
return -1
}
if pos("d") > pos("b") || pos("d") > pos("c") {
t.Errorf("d should come before b and c, got %v", order)
}
})
}
// ── deepMerge ─────────────────────────────────────────────────────────────────
func TestDeepMerge(t *testing.T) {
t.Run("override wins on scalar", func(t *testing.T) {
base := map[string]any{"a": "base", "b": "keep"}
over := map[string]any{"a": "new"}
got := deepMerge(base, over)
if got["a"] != "new" || got["b"] != "keep" {
t.Errorf("got %v", got)
}
})
t.Run("nested maps merged recursively", func(t *testing.T) {
base := map[string]any{"m": map[string]any{"x": 1, "y": 2}}
over := map[string]any{"m": map[string]any{"y": 99, "z": 3}}
got := deepMerge(base, over)
m := got["m"].(map[string]any)
if m["x"] != 1 || m["y"] != 99 || m["z"] != 3 {
t.Errorf("nested merge wrong: %v", m)
}
})
t.Run("override scalar wins over base map", func(t *testing.T) {
base := map[string]any{"m": map[string]any{"x": 1}}
over := map[string]any{"m": "scalar"}
got := deepMerge(base, over)
if got["m"] != "scalar" {
t.Errorf("expected scalar to win, got %v", got["m"])
}
})
t.Run("base map + override scalar - base is map, override is scalar", func(t *testing.T) {
base := map[string]any{"k": map[string]any{"a": 1}}
over := map[string]any{"k": "replaced"}
got := deepMerge(base, over)
if got["k"] != "replaced" {
t.Errorf("got %v", got["k"])
}
})
t.Run("empty maps", func(t *testing.T) {
got := deepMerge(map[string]any{}, map[string]any{})
if len(got) != 0 {
t.Errorf("expected empty, got %v", got)
}
})
}
// ── Resolve ───────────────────────────────────────────────────────────────────
func buildPipeline(rawJobs map[string]map[string]any) *model.Pipeline {
p := &model.Pipeline{
Jobs: make(map[string]model.Job),
RawJobs: rawJobs,
}
for name, raw := range rawJobs {
ext := raw["extends"]
p.Jobs[name] = model.Job{Name: name, Extends: ext}
}
return p
}
func TestResolve(t *testing.T) {
t.Run("no extends — noop", func(t *testing.T) {
p := buildPipeline(map[string]map[string]any{
"build": {"script": []any{"make"}},
})
warnings, err := Resolve(p)
if err != nil || len(warnings) != 0 {
t.Fatalf("unexpected: err=%v warnings=%v", err, warnings)
}
})
t.Run("single extends merges fields", func(t *testing.T) {
p := buildPipeline(map[string]map[string]any{
".base": {"image": "alpine", "script": []any{"echo base"}},
"child": {"extends": ".base", "stage": "test"},
})
warnings, err := Resolve(p)
if err != nil || len(warnings) != 0 {
t.Fatalf("unexpected: err=%v warnings=%v", err, warnings)
}
child := p.Jobs["child"]
if child.Stage != "test" {
t.Errorf("stage not preserved: %q", child.Stage)
}
})
t.Run("unknown base produces warning, not error", func(t *testing.T) {
p := buildPipeline(map[string]map[string]any{
"child": {"extends": ".missing", "script": []any{"echo x"}},
})
warnings, err := Resolve(p)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(warnings) != 1 || warnings[0].Job != "child" || warnings[0].Base != ".missing" {
t.Errorf("expected warning about .missing, got %v", warnings)
}
})
t.Run("multi-extend list", func(t *testing.T) {
p := buildPipeline(map[string]map[string]any{
".a": {"image": "alpine"},
".b": {"tags": []any{"docker"}},
"child": {"extends": []any{".a", ".b"}, "script": []any{"echo x"}},
})
warnings, err := Resolve(p)
if err != nil || len(warnings) != 0 {
t.Fatalf("unexpected: %v %v", err, warnings)
}
})
t.Run("cycle returns error", func(t *testing.T) {
p := buildPipeline(map[string]map[string]any{
"a": {"extends": "b"},
"b": {"extends": "a"},
})
_, err := Resolve(p)
if err == nil {
t.Fatal("expected cycle error")
}
})
t.Run("file and line preserved after merge", func(t *testing.T) {
p := buildPipeline(map[string]map[string]any{
".base": {"script": []any{"echo base"}},
"child": {"extends": ".base"},
})
p.Jobs["child"] = model.Job{Name: "child", Extends: ".base", File: "ci.yml", Line: 10}
_, err := Resolve(p)
if err != nil {
t.Fatal(err)
}
if p.Jobs["child"].File != "ci.yml" || p.Jobs["child"].Line != 10 {
t.Errorf("file/line lost after merge: %+v", p.Jobs["child"])
}
})
t.Run("parseExtends invalid type returns error", func(t *testing.T) {
p := buildPipeline(map[string]map[string]any{
"job": {"extends": 123},
})
p.Jobs["job"] = model.Job{Name: "job", Extends: 123}
_, err := Resolve(p)
if err == nil {
t.Fatal("expected error for invalid extends type")
}
})
t.Run("yaml.Marshal fails — errorYAMLMarshaler injected into merged map", func(t *testing.T) {
// Inject a value whose MarshalYAML() returns an error so yaml.Marshal
// fails at extends.go:74-77.
p := buildPipeline(map[string]map[string]any{
".base": {"script": []any{"echo"}},
"child": {"extends": ".base", "bad": errorYAMLMarshaler{}},
})
_, err := Resolve(p)
if err == nil {
t.Fatal("expected error when yaml.Marshal fails")
}
})
t.Run("yaml.Unmarshal fails — map injected where []string expected", func(t *testing.T) {
// Marshal succeeds (maps are valid YAML), but Unmarshal into model.Job
// fails because Dependencies []string cannot hold a mapping.
p := buildPipeline(map[string]map[string]any{
".base": {
"dependencies": map[string]any{"invalid": "not-a-list"},
},
"child": {"extends": ".base", "stage": "build"},
})
_, err := Resolve(p)
if err == nil {
t.Fatal("expected error when yaml.Unmarshal fails on incompatible type")
}
})
}
+242 -47
View File
@@ -2,18 +2,33 @@ package resolver
import ( import (
"fmt" "fmt"
"os"
"path/filepath"
"regexp"
"strings" "strings"
"git.k3nny.fr/gitlab-sim/internal/fetcher" "git.k3nny.fr/glint/internal/fetcher"
"git.k3nny.fr/gitlab-sim/internal/model" "git.k3nny.fr/glint/internal/model"
) )
// IncludeWarning describes a remote include entry that could not be resolved. // 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. // These are surfaced to the user as [WARNING] lines before the lint findings.
type IncludeWarning struct { type IncludeWarning struct {
// Label is a short human-readable identifier shown in the warning message, // Label is a short human-readable identifier shown in the warning message.
// e.g. "project my-group/templates:/ci.yml@main" or
// "component gitlab.com/components/golang/build@v1.0".
Label string Label string
Err error // nil when Skipped is true Err error // nil when Skipped is true
Skipped bool // no token available — skipped without attempting a network call Skipped bool // no token available — skipped without attempting a network call
@@ -30,46 +45,163 @@ func (w IncludeWarning) String() string {
w.Label, w.Err) w.Label, w.Err)
} }
// ExtendWarning describes an extends: reference whose base job could not be
// found. This typically means the base is in a remote include that was not
// fetched (e.g. no token). The job's extends chain is skipped; the job itself
// is still linted with whatever fields it directly defines.
type ExtendWarning struct {
Job string // job that declares the extends
Base string // the unknown base job name
}
// ResolveIncludes processes the pipeline's include: block. // ResolveIncludes processes the pipeline's include: block.
// //
// Project includes (include: project: ...) require authentication and are // rootDir is the repository root directory used to resolve local: includes.
// skipped with a warning when no token is configured. // In practice this is the directory of the top-level pipeline file being linted.
// //
// Component includes (include: component: ...) attempt the fetch // Local includes are read from disk and merged recursively.
// unauthenticated first, so public CI/CD catalog components work without a // Project includes require authentication and are skipped with a warning when
// token. A warning is emitted when the fetch fails. // no token is configured. Component includes are attempted unauthenticated.
// Remote and template includes are silently skipped (resolved by GitLab at runtime).
// //
// Non-resolvable include forms (local, remote, template) are silently skipped. // The second return value carries extends warnings discovered while recursively
func ResolveIncludes(p *model.Pipeline, cfg fetcher.GitLabConfig) []IncludeWarning { // 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, 0)
}
// resolveIncludes is the recursive core of ResolveIncludes.
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
for _, inc := range p.Include { var extWarnings []ExtendWarning
for _, inc := range includes {
entry, ok := normaliseInclude(inc) entry, ok := normaliseInclude(inc)
if !ok { if !ok {
continue continue
} }
if project, _ := entry["project"].(string); project != "" { if project, _ := entry["project"].(string); project != "" {
warnings = append(warnings, resolveProjectInclude(p, entry, project, cfg)...) w, ew := resolveProjectInclude(p, entry, project, cfg, rootDir, visited, depth)
warnings = append(warnings, w...)
extWarnings = append(extWarnings, ew...)
continue continue
} }
if compRef, _ := entry["component"].(string); compRef != "" { if compRef, _ := entry["component"].(string); compRef != "" {
if w, ok := resolveComponentInclude(p, compRef, cfg); ok { inputs := extractInputs(entry)
w, ew, hadErr := resolveComponentInclude(p, compRef, inputs, cfg, rootDir, visited, depth)
if hadErr {
warnings = append(warnings, w) warnings = append(warnings, w)
} }
extWarnings = append(extWarnings, ew...)
continue continue
} }
// local, remote, template — resolved by GitLab at runtime, skip silently. if local, _ := entry["local"].(string); local != "" {
w, ew := resolveLocalInclude(p, local, cfg, rootDir, visited, depth)
warnings = append(warnings, w...)
extWarnings = append(extWarnings, ew...)
continue
} }
return warnings
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
}
// 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, depth int) ([]IncludeWarning, []ExtendWarning) {
relPath := strings.TrimPrefix(rawPath, "/")
absPath := filepath.Join(rootDir, relPath)
label := "local " + rawPath
if visited[absPath] {
return nil, nil
}
visited[absPath] = true
data, err := os.ReadFile(absPath)
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(absPath)
// Recursively resolve the included file's own includes first, merging
// everything into `included` before we merge it into the parent `p`.
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...)
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...)
extWarnings = append(extWarnings, ew...)
}
mergeIncluded(p, included)
return warnings, extWarnings
} }
// resolveProjectInclude fetches all files listed under a single project: entry // resolveProjectInclude fetches all files listed under a single project: entry
// and merges them into p. // and merges them into p.
func resolveProjectInclude(p *model.Pipeline, entry map[string]any, project string, cfg fetcher.GitLabConfig) []IncludeWarning { 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
for _, filePath := range includeFiles(entry) { for _, filePath := range includeFiles(entry) {
label := fmt.Sprintf("project %s:%s", project, filePath) label := fmt.Sprintf("project %s:%s", project, filePath)
@@ -77,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
@@ -93,57 +231,108 @@ 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 {
w, ew := resolveIncludes(included, included.Include, cfg, rootDir, visited, depth+1)
warnings = append(warnings, w...)
extWarnings = append(extWarnings, ew...)
}
mergeIncluded(p, included) mergeIncluded(p, included)
} }
return warnings return warnings, extWarnings
} }
// resolveComponentInclude fetches a CI/CD catalog component and merges it into p. // resolveComponentInclude fetches a CI/CD catalog component, substitutes any
// Returns (warning, true) if something went wrong; (zero, false) on success. // $[[ inputs.KEY ]] placeholders with values from `inputs` (the include's
func resolveComponentInclude(p *model.Pipeline, ref string, cfg fetcher.GitLabConfig) (IncludeWarning, bool) { // 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, inputs map[string]any, cfg fetcher.GitLabConfig, rootDir string, visited map[string]bool, depth int) (IncludeWarning, []ExtendWarning, bool) {
label := "component " + ref label := "component " + ref
// Variable interpolation (e.g. $CI_SERVER_FQDN) cannot be resolved locally.
if strings.ContainsRune(ref, '$') { if strings.ContainsRune(ref, '$') {
return IncludeWarning{ return IncludeWarning{
Label: label, Label: label,
Err: fmt.Errorf("component reference contains a CI variable that cannot be resolved at lint time"), Err: fmt.Errorf("component reference contains a CI variable that cannot be resolved at lint time"),
}, 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}, true return IncludeWarning{Label: label, Err: err}, nil, true
} }
hostCfg := cfg.ForHost(host) hostCfg := cfg.ForHost(host)
data, err := fetchComponentFile(hostCfg, project, component, version) data, err := fetchComponentFile(hostCfg, project, component, version)
if err != nil { if err != nil {
return IncludeWarning{Label: label, Err: err}, 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)}, true return IncludeWarning{Label: label, Err: fmt.Errorf("parsing component YAML: %w", err)}, nil, true
}
included.SetJobOrigin(label)
var extWarnings []ExtendWarning
if len(included.Include) > 0 {
_, ew := resolveIncludes(included, included.Include, cfg, rootDir, visited, depth+1)
extWarnings = append(extWarnings, ew...)
} }
mergeIncluded(p, included) mergeIncluded(p, included)
return IncludeWarning{}, 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 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>
//
// The host is the GitLab instance FQDN. The last path segment before @ is the
// component name; everything between host and component name is the project path.
//
// Examples:
//
// gitlab.com/components/golang/build@v1.0.0
// gitlab.example.com/my-org/ci-templates/lint@main
// gitlab.com/components/secret-detection/secret-detection@~latest
func parseComponentRef(ref string) (host, project, component, version string, err error) { func parseComponentRef(ref string) (host, project, component, version string, err error) {
atIdx := strings.LastIndex(ref, "@") atIdx := strings.LastIndex(ref, "@")
if atIdx < 0 || atIdx == len(ref)-1 { if atIdx < 0 || atIdx == len(ref)-1 {
@@ -154,7 +343,6 @@ func parseComponentRef(ref string) (host, project, component, version string, er
path := ref[:atIdx] path := ref[:atIdx]
parts := strings.Split(path, "/") parts := strings.Split(path, "/")
// Minimum: host + at least one project segment + component = 3 parts.
if len(parts) < 3 { if len(parts) < 3 {
err = fmt.Errorf("component reference %q must be <host>/<project>/<component>@<version>", ref) err = fmt.Errorf("component reference %q must be <host>/<project>/<component>@<version>", ref)
return return
@@ -167,9 +355,6 @@ func parseComponentRef(ref string) (host, project, component, version string, er
} }
// fetchComponentFile fetches a component's template YAML from a GitLab project. // fetchComponentFile fetches a component's template YAML from a GitLab project.
// GitLab supports two layouts; both are attempted:
// - templates/<component>.yml (single-file component)
// - templates/<component>/template.yml (directory component)
func fetchComponentFile(cfg fetcher.GitLabConfig, project, component, version string) ([]byte, error) { func fetchComponentFile(cfg fetcher.GitLabConfig, project, component, version string) ([]byte, error) {
primary := "templates/" + component + ".yml" primary := "templates/" + component + ".yml"
data, err := cfg.FetchFile(project, primary, version) data, err := cfg.FetchFile(project, primary, version)
@@ -180,14 +365,12 @@ func fetchComponentFile(cfg fetcher.GitLabConfig, project, component, version st
fallback := "templates/" + component + "/template.yml" fallback := "templates/" + component + "/template.yml"
data2, err2 := cfg.FetchFile(project, fallback, version) data2, err2 := cfg.FetchFile(project, fallback, version)
if err2 != nil { if err2 != nil {
// Return the primary error — it refers to the canonical path.
return nil, fmt.Errorf("%v (also tried %s: %v)", err, fallback, err2) return nil, fmt.Errorf("%v (also tried %s: %v)", err, fallback, err2)
} }
return data2, nil return data2, nil
} }
// normaliseInclude converts a raw include: list element to a string-keyed map. // normaliseInclude converts a raw include: list element to a string-keyed map.
// An include: value can be a plain string (shorthand local) or a map.
func normaliseInclude(raw any) (map[string]any, bool) { func normaliseInclude(raw any) (map[string]any, bool) {
switch v := raw.(type) { switch v := raw.(type) {
case map[string]any: case map[string]any:
@@ -199,7 +382,6 @@ func normaliseInclude(raw any) (map[string]any, bool) {
} }
// includeFiles returns the list of file paths from an include entry. // includeFiles returns the list of file paths from an include entry.
// The file: key may be a single string or a list of strings.
func includeFiles(entry map[string]any) []string { func includeFiles(entry map[string]any) []string {
raw, ok := entry["file"] raw, ok := entry["file"]
if !ok { if !ok {
@@ -220,8 +402,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 {
@@ -242,4 +425,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
}
}
}
} }
+847
View File
@@ -0,0 +1,847 @@
package resolver
import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"git.k3nny.fr/glint/internal/fetcher"
"git.k3nny.fr/glint/internal/model"
)
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)
}
})
}
}
// ── IncludeWarning.String ─────────────────────────────────────────────────────
func TestIncludeWarning_String(t *testing.T) {
skipped := IncludeWarning{Label: "myinclude", Skipped: true}
s := skipped.String()
if s == "" {
t.Error("skipped: expected non-empty string")
}
withErr := IncludeWarning{Label: "remote foo", Err: fmt.Errorf("connection refused")}
s2 := withErr.String()
if s2 == "" {
t.Error("withErr: expected non-empty string")
}
}
// ── normaliseInclude ──────────────────────────────────────────────────────────
func TestNormaliseInclude(t *testing.T) {
m, ok := normaliseInclude(map[string]any{"local": "ci.yml"})
if !ok || m["local"] != "ci.yml" {
t.Error("map form")
}
m2, ok2 := normaliseInclude("ci.yml")
if !ok2 || m2["local"] != "ci.yml" {
t.Error("string form")
}
_, ok3 := normaliseInclude(42)
if ok3 {
t.Error("int should not normalise")
}
}
// ── includeFiles ─────────────────────────────────────────────────────────────
func TestIncludeFiles(t *testing.T) {
if got := includeFiles(map[string]any{"file": "a.yml"}); len(got) != 1 {
t.Error("string file")
}
if got := includeFiles(map[string]any{"file": []any{"a.yml", "b.yml"}}); len(got) != 2 {
t.Error("slice file")
}
if got := includeFiles(map[string]any{"file": []any{"a.yml", 42}}); len(got) != 1 {
t.Error("mixed slice, int dropped")
}
if got := includeFiles(map[string]any{}); got != nil {
t.Error("no file key")
}
if got := includeFiles(map[string]any{"file": 99}); got != nil {
t.Error("unknown type returns nil")
}
}
// ── extractInputs ─────────────────────────────────────────────────────────────
func TestExtractInputs(t *testing.T) {
m := map[string]any{
"component": "host/p/c@v1",
"with": map[string]any{"KEY": "val"},
}
got := extractInputs(m)
if got == nil || got["KEY"] != "val" {
t.Error("expected KEY=val")
}
got2 := extractInputs(map[string]any{"component": "x"})
if got2 != nil {
t.Error("expected nil without with: key")
}
got3 := extractInputs(map[string]any{"with": "not-a-map"})
if got3 != nil {
t.Error("expected nil for non-map with:")
}
}
// ── parseComponentRef ─────────────────────────────────────────────────────────
func TestParseComponentRef_Resolver(t *testing.T) {
host, proj, comp, ver, err := parseComponentRef("gitlab.com/group/proj/comp@v1.0")
if err != nil {
t.Fatal(err)
}
if host != "gitlab.com" {
t.Errorf("host: %q", host)
}
if proj != "group/proj" {
t.Errorf("proj: %q", proj)
}
if comp != "comp" {
t.Errorf("comp: %q", comp)
}
if ver != "v1.0" {
t.Errorf("ver: %q", ver)
}
_, _, _, _, err2 := parseComponentRef("no-at")
if err2 == nil {
t.Error("expected error for no @")
}
_, _, _, _, err3 := parseComponentRef("a@")
if err3 == nil {
t.Error("expected error for empty version")
}
_, _, _, _, err4 := parseComponentRef("host/comp@v1")
if err4 == nil {
t.Error("expected error for too few parts")
}
}
// ── mergeIncluded ─────────────────────────────────────────────────────────────
func TestMergeIncluded(t *testing.T) {
dst := &model.Pipeline{
Stages: []string{"build"},
Jobs: map[string]model.Job{"existing": {Name: "existing"}},
RawJobs: map[string]map[string]any{},
}
src := &model.Pipeline{
Stages: []string{"build", "test"},
Jobs: map[string]model.Job{"new-job": {Name: "new-job"}, "existing": {Name: "existing-from-src"}},
RawJobs: map[string]map[string]any{"new-job": {"script": "echo"}},
Variables: map[string]any{"KEY": "val"},
}
mergeIncluded(dst, src)
if len(dst.Stages) != 2 {
t.Errorf("stages: got %d want 2", len(dst.Stages))
}
if _, ok := dst.Jobs["new-job"]; !ok {
t.Error("expected new-job in dst")
}
if dst.Jobs["existing"].Name != "existing" {
t.Error("dst wins on conflict")
}
if dst.Variables["KEY"] != "val" {
t.Error("variable merged")
}
// Merge again with conflicting variable: dst wins
src2 := &model.Pipeline{
Jobs: map[string]model.Job{},
Variables: map[string]any{"KEY": "other"},
}
mergeIncluded(dst, src2)
if dst.Variables["KEY"] != "val" {
t.Error("dst var wins on conflict")
}
}
func TestMergeIncluded_NilVariables(t *testing.T) {
dst := &model.Pipeline{Jobs: map[string]model.Job{}}
src := &model.Pipeline{
Jobs: map[string]model.Job{},
Variables: map[string]any{"A": "1"},
}
mergeIncluded(dst, src)
if dst.Variables["A"] != "1" {
t.Error("expected A in dst after nil init")
}
}
// ── resolveLocalInclude ───────────────────────────────────────────────────────
func TestResolveLocalInclude_ValidFile(t *testing.T) {
dir := t.TempDir()
childPath := filepath.Join(dir, "child.yml")
if err := os.WriteFile(childPath, []byte("child-job:\n script: echo ok\n"), 0o644); err != nil {
t.Fatal(err)
}
p := &model.Pipeline{Jobs: map[string]model.Job{}, RawJobs: map[string]map[string]any{}}
visited := map[string]bool{}
warnings, _ := resolveLocalInclude(p, "/child.yml", fetcher.GitLabConfig{}, dir, visited, 0)
if len(warnings) != 0 {
t.Errorf("unexpected warnings: %v", warnings)
}
if _, ok := p.Jobs["child-job"]; !ok {
t.Error("child-job should be merged")
}
}
func TestResolveLocalInclude_MissingFile(t *testing.T) {
p := &model.Pipeline{Jobs: map[string]model.Job{}, RawJobs: map[string]map[string]any{}}
warnings, _ := resolveLocalInclude(p, "/nonexistent.yml", fetcher.GitLabConfig{}, "/tmp", map[string]bool{}, 0)
if len(warnings) == 0 {
t.Error("expected warning for missing file")
}
}
func TestResolveLocalInclude_InvalidYAML(t *testing.T) {
dir := t.TempDir()
badPath := filepath.Join(dir, "bad.yml")
if err := os.WriteFile(badPath, []byte(":\tbad yaml\n"), 0o644); err != nil {
t.Fatal(err)
}
p := &model.Pipeline{Jobs: map[string]model.Job{}, RawJobs: map[string]map[string]any{}}
warnings, _ := resolveLocalInclude(p, "/bad.yml", fetcher.GitLabConfig{}, dir, map[string]bool{}, 0)
if len(warnings) == 0 {
t.Error("expected warning for invalid YAML")
}
}
func TestResolveLocalInclude_AlreadyVisited(t *testing.T) {
dir := t.TempDir()
childPath := filepath.Join(dir, "child.yml")
if err := os.WriteFile(childPath, []byte("job:\n script: echo\n"), 0o644); err != nil {
t.Fatal(err)
}
p := &model.Pipeline{Jobs: map[string]model.Job{}, RawJobs: map[string]map[string]any{}}
visited := map[string]bool{childPath: true}
warnings, _ := resolveLocalInclude(p, "/child.yml", fetcher.GitLabConfig{}, dir, visited, 0)
if len(warnings) != 0 {
t.Error("no warnings expected for already-visited")
}
if len(p.Jobs) != 0 {
t.Error("no jobs should be merged for already-visited")
}
}
func TestResolveLocalInclude_WithNestedIncludes(t *testing.T) {
dir := t.TempDir()
grandchild := filepath.Join(dir, "grandchild.yml")
if err := os.WriteFile(grandchild, []byte("gc-job:\n script: echo\n"), 0o644); err != nil {
t.Fatal(err)
}
child := filepath.Join(dir, "child.yml")
childContent := "include:\n - local: /grandchild.yml\nchild-job:\n script: echo\n"
if err := os.WriteFile(child, []byte(childContent), 0o644); err != nil {
t.Fatal(err)
}
p := &model.Pipeline{Jobs: map[string]model.Job{}, RawJobs: map[string]map[string]any{}}
_, _ = resolveLocalInclude(p, "/child.yml", fetcher.GitLabConfig{}, dir, map[string]bool{}, 0)
if _, ok := p.Jobs["gc-job"]; !ok {
t.Error("grandchild job should be merged")
}
}
// ── resolveRemoteInclude ──────────────────────────────────────────────────────
func TestResolveRemoteInclude_Success(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
fmt.Fprintln(w, "remote-job:\n script: echo remote")
}))
defer srv.Close()
p := &model.Pipeline{Jobs: map[string]model.Job{}, RawJobs: map[string]map[string]any{}}
cfg := fetcher.AutoConfig()
warnings, _ := resolveRemoteInclude(p, srv.URL+"/ci.yml", cfg, "/tmp", map[string]bool{}, 0)
if len(warnings) != 0 {
t.Errorf("unexpected warnings: %v", warnings)
}
if _, ok := p.Jobs["remote-job"]; !ok {
t.Error("remote-job should be merged")
}
}
func TestResolveRemoteInclude_Error(t *testing.T) {
p := &model.Pipeline{Jobs: map[string]model.Job{}, RawJobs: map[string]map[string]any{}}
warnings, _ := resolveRemoteInclude(p, "http://localhost:0/unreachable.yml", fetcher.GitLabConfig{}, "/tmp", map[string]bool{}, 0)
if len(warnings) == 0 {
t.Error("expected warning for unreachable URL")
}
}
func TestResolveRemoteInclude_AlreadyVisited(t *testing.T) {
p := &model.Pipeline{Jobs: map[string]model.Job{}, RawJobs: map[string]map[string]any{}}
visited := map[string]bool{"http://example.com/ci.yml": true}
warnings, _ := resolveRemoteInclude(p, "http://example.com/ci.yml", fetcher.GitLabConfig{}, "/tmp", visited, 0)
if len(warnings) != 0 {
t.Error("no warning expected for already-visited URL")
}
}
func TestResolveRemoteInclude_InvalidYAML(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
fmt.Fprintln(w, ":\tbad\tyaml")
}))
defer srv.Close()
p := &model.Pipeline{Jobs: map[string]model.Job{}, RawJobs: map[string]map[string]any{}}
warnings, _ := resolveRemoteInclude(p, srv.URL+"/bad.yml", fetcher.AutoConfig(), "/tmp", map[string]bool{}, 0)
if len(warnings) == 0 {
t.Error("expected warning for invalid YAML from remote")
}
}
// ── resolveProjectInclude ─────────────────────────────────────────────────────
func TestResolveProjectInclude_NoToken(t *testing.T) {
p := &model.Pipeline{Jobs: map[string]model.Job{}, RawJobs: map[string]map[string]any{}}
entry := map[string]any{"project": "g/p", "file": "ci.yml"}
warnings, _ := resolveProjectInclude(p, entry, "g/p", fetcher.GitLabConfig{}, "/tmp", map[string]bool{}, 0)
if len(warnings) == 0 {
t.Error("expected skipped warning when no token")
}
if !warnings[0].Skipped {
t.Error("expected Skipped=true when no token")
}
}
func TestResolveProjectInclude_AlreadyVisited(t *testing.T) {
p := &model.Pipeline{Jobs: map[string]model.Job{}, RawJobs: map[string]map[string]any{}}
visited := map[string]bool{"project:g/p:ci.yml@": true}
entry := map[string]any{"project": "g/p", "file": "ci.yml"}
warnings, _ := resolveProjectInclude(p, entry, "g/p", fetcher.GitLabConfig{Token: "tok"}, "/tmp", visited, 0)
if len(warnings) != 0 {
t.Error("already-visited should produce no warning")
}
}
func TestResolveProjectInclude_WithToken_FetchError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(404)
}))
defer srv.Close()
p := &model.Pipeline{Jobs: map[string]model.Job{}, RawJobs: map[string]map[string]any{}}
cfg := fetcher.GitLabConfig{BaseURL: srv.URL, Token: "tok"}
entry := map[string]any{"project": "g/p", "file": "ci.yml", "ref": "main"}
warnings, _ := resolveProjectInclude(p, entry, "g/p", cfg, "/tmp", map[string]bool{}, 0)
if len(warnings) == 0 {
t.Error("expected warning for 404 fetch")
}
}
func TestResolveProjectInclude_NoFiles(t *testing.T) {
p := &model.Pipeline{Jobs: map[string]model.Job{}, RawJobs: map[string]map[string]any{}}
entry := map[string]any{"project": "g/p"} // no file key
warnings, _ := resolveProjectInclude(p, entry, "g/p", fetcher.GitLabConfig{Token: "tok"}, "/tmp", map[string]bool{}, 0)
if len(warnings) != 0 {
t.Error("no warnings expected when no files")
}
}
// ── resolveComponentInclude ───────────────────────────────────────────────────
func TestResolveComponentInclude_DollarRef(t *testing.T) {
p := &model.Pipeline{Jobs: map[string]model.Job{}, RawJobs: map[string]map[string]any{}}
warn, _, hadErr := resolveComponentInclude(p, "${CI_SERVER}/g/p/comp@v1", nil, fetcher.GitLabConfig{}, "/tmp", map[string]bool{}, 0)
if !hadErr {
t.Error("expected hadErr for $ ref")
}
if warn.Label == "" {
t.Error("expected non-empty label")
}
}
func TestResolveComponentInclude_BadRef(t *testing.T) {
p := &model.Pipeline{Jobs: map[string]model.Job{}, RawJobs: map[string]map[string]any{}}
_, _, hadErr := resolveComponentInclude(p, "no-at-sign", nil, fetcher.GitLabConfig{}, "/tmp", map[string]bool{}, 0)
if !hadErr {
t.Error("expected hadErr for bad ref")
}
}
func TestResolveComponentInclude_AlreadyVisited(t *testing.T) {
p := &model.Pipeline{Jobs: map[string]model.Job{}, RawJobs: map[string]map[string]any{}}
visited := map[string]bool{"component:host/g/p/comp@v1": true}
_, _, hadErr := resolveComponentInclude(p, "host/g/p/comp@v1", nil, fetcher.GitLabConfig{}, "/tmp", visited, 0)
if hadErr {
t.Error("already-visited should not return hadErr")
}
}
func TestResolveComponentInclude_FetchError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(404)
}))
defer srv.Close()
p := &model.Pipeline{Jobs: map[string]model.Job{}, RawJobs: map[string]map[string]any{}}
cfg := fetcher.GitLabConfig{BaseURL: srv.URL, Token: "tok"}
_, _, hadErr := resolveComponentInclude(p, "localhost/g/p/comp@v1", nil, cfg, "/tmp", map[string]bool{}, 0)
if !hadErr {
t.Error("expected hadErr for 404 component fetch")
}
}
// ── resolveIncludes — depth guard ─────────────────────────────────────────────
func TestResolveIncludes_DepthExceeded(t *testing.T) {
p := &model.Pipeline{Jobs: map[string]model.Job{}, RawJobs: map[string]map[string]any{}}
warnings, _ := resolveIncludes(p, nil, fetcher.GitLabConfig{}, "/tmp", map[string]bool{}, maxIncludeDepth+1)
if len(warnings) == 0 {
t.Error("expected depth-exceeded warning")
}
}
// ── ResolveIncludes (public entry) ────────────────────────────────────────────
func TestResolveIncludes_LocalFile(t *testing.T) {
dir := t.TempDir()
childPath := filepath.Join(dir, "child.yml")
if err := os.WriteFile(childPath, []byte("include-job:\n script: echo\n"), 0o644); err != nil {
t.Fatal(err)
}
p := &model.Pipeline{
Jobs: map[string]model.Job{},
RawJobs: map[string]map[string]any{},
Include: []any{map[string]any{"local": "/child.yml"}},
}
warnings, _ := ResolveIncludes(p, fetcher.GitLabConfig{}, dir)
if len(warnings) != 0 {
t.Errorf("unexpected warnings: %v", warnings)
}
if _, ok := p.Jobs["include-job"]; !ok {
t.Error("include-job should be merged into pipeline")
}
}
func TestResolveIncludes_TemplateSkipped(t *testing.T) {
p := &model.Pipeline{
Jobs: map[string]model.Job{},
RawJobs: map[string]map[string]any{},
Include: []any{map[string]any{"template": "Auto-DevOps.gitlab-ci.yml"}},
}
warnings, _ := ResolveIncludes(p, fetcher.GitLabConfig{}, "/tmp")
if len(warnings) != 0 {
t.Errorf("template should be silently skipped, got: %v", warnings)
}
}
func TestResolveIncludes_StringLocalInclude(t *testing.T) {
dir := t.TempDir()
childPath := filepath.Join(dir, "ci.yml")
if err := os.WriteFile(childPath, []byte("str-job:\n script: echo\n"), 0o644); err != nil {
t.Fatal(err)
}
p := &model.Pipeline{
Jobs: map[string]model.Job{},
RawJobs: map[string]map[string]any{},
Include: []any{"ci.yml"},
}
warnings, _ := ResolveIncludes(p, fetcher.GitLabConfig{}, dir)
if len(warnings) != 0 {
t.Errorf("unexpected warnings: %v", warnings)
}
}
func TestResolveIncludes_InvalidEntry(t *testing.T) {
p := &model.Pipeline{
Jobs: map[string]model.Job{},
RawJobs: map[string]map[string]any{},
Include: []any{42},
}
warnings, _ := ResolveIncludes(p, fetcher.GitLabConfig{}, "/tmp")
if len(warnings) != 0 {
t.Errorf("unexpected warnings for invalid entry: %v", warnings)
}
}
// ── fetchComponentFile ────────────────────────────────────────────────────────
func TestFetchComponentFile_FallbackPath(t *testing.T) {
calls := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
w.WriteHeader(404)
}))
defer srv.Close()
cfg := fetcher.GitLabConfig{BaseURL: srv.URL, Token: "tok"}
_, err := fetchComponentFile(cfg, "g/p", "mycomp", "v1")
if err == nil {
t.Error("expected error when both paths fail")
}
}
func TestFetchComponentFile_PrimarySuccess(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
fmt.Fprintln(w, "comp-job:\n script: echo")
}))
defer srv.Close()
cfg := fetcher.GitLabConfig{BaseURL: srv.URL}
data, err := fetchComponentFile(cfg, "g/p", "mycomp", "v1")
if err != nil {
t.Fatalf("primary success: unexpected error: %v", err)
}
if len(data) == 0 {
t.Error("primary success: expected non-empty data")
}
}
func TestFetchComponentFile_FallbackSuccess(t *testing.T) {
calls := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
if calls == 1 {
// First call (primary path) fails.
w.WriteHeader(404)
return
}
// Second call (fallback path) succeeds.
w.WriteHeader(200)
fmt.Fprintln(w, "comp-job:\n script: echo")
}))
defer srv.Close()
cfg := fetcher.GitLabConfig{BaseURL: srv.URL}
data, err := fetchComponentFile(cfg, "g/p", "mycomp", "v1")
if err != nil {
t.Fatalf("fallback success: unexpected error: %v", err)
}
if len(data) == 0 {
t.Error("fallback success: expected non-empty data")
}
}
// ── resolveProjectInclude — success path ──────────────────────────────────────
func TestResolveProjectInclude_Success(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
fmt.Fprintln(w, "proj-job:\n script: echo from project")
}))
defer srv.Close()
p := &model.Pipeline{Jobs: map[string]model.Job{}, RawJobs: map[string]map[string]any{}}
cfg := fetcher.GitLabConfig{BaseURL: srv.URL, Token: "tok"}
entry := map[string]any{"project": "g/p", "file": "ci.yml", "ref": "main"}
warnings, _ := resolveProjectInclude(p, entry, "g/p", cfg, "/tmp", map[string]bool{}, 0)
if len(warnings) != 0 {
t.Errorf("success: unexpected warnings: %v", warnings)
}
if _, ok := p.Jobs["proj-job"]; !ok {
t.Error("proj-job should be merged into pipeline")
}
}
func TestResolveProjectInclude_InvalidYAML(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
fmt.Fprintln(w, ":\tbad\tyaml")
}))
defer srv.Close()
p := &model.Pipeline{Jobs: map[string]model.Job{}, RawJobs: map[string]map[string]any{}}
cfg := fetcher.GitLabConfig{BaseURL: srv.URL, Token: "tok"}
entry := map[string]any{"project": "g/p", "file": "ci.yml"}
warnings, _ := resolveProjectInclude(p, entry, "g/p", cfg, "/tmp", map[string]bool{}, 0)
if len(warnings) == 0 {
t.Error("invalid YAML: expected warning")
}
}
// TestResolveProjectInclude_WithSubIncludes covers includes.go:236-240 —
// the fetched project YAML itself contains include: entries.
func TestResolveProjectInclude_WithSubIncludes(t *testing.T) {
dir := t.TempDir()
// Write a local file that the project YAML sub-includes.
localContent := "local-from-project:\n script: echo local\n"
if err := os.WriteFile(filepath.Join(dir, "local.yml"), []byte(localContent), 0o644); err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
// Return YAML that itself has a local sub-include.
fmt.Fprintln(w, "include:\n - local: /local.yml\nproj-sub-job:\n script: echo proj")
}))
defer srv.Close()
p := &model.Pipeline{Jobs: map[string]model.Job{}, RawJobs: map[string]map[string]any{}}
cfg := fetcher.GitLabConfig{BaseURL: srv.URL, Token: "tok"}
entry := map[string]any{"project": "g/p", "file": "ci.yml", "ref": "main"}
warnings, _ := resolveProjectInclude(p, entry, "g/p", cfg, dir, map[string]bool{}, 0)
if len(warnings) != 0 {
t.Errorf("sub-includes: unexpected warnings: %v", warnings)
}
if _, ok := p.Jobs["proj-sub-job"]; !ok {
t.Error("proj-sub-job should be merged via project include")
}
}
// tlsComp sets up a TLS test server and swaps http.DefaultTransport so the test
// client trusts the self-signed cert. Returns the host (without scheme).
func tlsComp(t *testing.T, h http.HandlerFunc) (host string) {
t.Helper()
srv := httptest.NewTLSServer(h)
t.Cleanup(srv.Close)
orig := http.DefaultTransport
http.DefaultTransport = srv.Client().Transport
t.Cleanup(func() { http.DefaultTransport = orig })
return srv.URL[len("https://"):]
}
// ── resolveComponentInclude — success path ────────────────────────────────────
func TestResolveComponentInclude_Success(t *testing.T) {
host := tlsComp(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
fmt.Fprintln(w, "comp-job:\n script: echo from component")
})
ref := host + "/g/p/mycomp@v1"
p := &model.Pipeline{Jobs: map[string]model.Job{}, RawJobs: map[string]map[string]any{}}
cfg := fetcher.GitLabConfig{}
_, _, hadErr := resolveComponentInclude(p, ref, nil, cfg, "/tmp", map[string]bool{}, 0)
if hadErr {
t.Error("success: unexpected hadErr")
}
if _, ok := p.Jobs["comp-job"]; !ok {
t.Error("comp-job should be merged into pipeline")
}
}
func TestResolveComponentInclude_InvalidYAML(t *testing.T) {
host := tlsComp(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
fmt.Fprintln(w, ":\tbad\tyaml")
})
ref := host + "/g/p/mycomp@v1"
p := &model.Pipeline{Jobs: map[string]model.Job{}, RawJobs: map[string]map[string]any{}}
cfg := fetcher.GitLabConfig{}
_, _, hadErr := resolveComponentInclude(p, ref, nil, cfg, "/tmp", map[string]bool{}, 0)
if !hadErr {
t.Error("invalid YAML: expected hadErr")
}
}
// TestResolveComponentInclude_WithSubIncludes covers includes.go:288-291 —
// the fetched component YAML itself contains include: entries.
func TestResolveComponentInclude_WithSubIncludes(t *testing.T) {
dir := t.TempDir()
localContent := "comp-local-job:\n script: echo comp-local\n"
if err := os.WriteFile(filepath.Join(dir, "sub.yml"), []byte(localContent), 0o644); err != nil {
t.Fatal(err)
}
host := tlsComp(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
// Component YAML contains a local sub-include.
fmt.Fprintln(w, "include:\n - local: /sub.yml\ncomp-job:\n script: echo comp")
})
ref := host + "/g/p/mycomp@v1"
p := &model.Pipeline{Jobs: map[string]model.Job{}, RawJobs: map[string]map[string]any{}}
cfg := fetcher.GitLabConfig{}
_, extW, hadErr := resolveComponentInclude(p, ref, nil, cfg, dir, map[string]bool{}, 0)
if hadErr {
t.Errorf("sub-includes: unexpected hadErr; extWarnings=%v", extW)
}
if _, ok := p.Jobs["comp-job"]; !ok {
t.Error("comp-job should be merged via component include")
}
}
// ── resolveRemoteInclude — sub-includes ───────────────────────────────────────
func TestResolveRemoteInclude_WithSubIncludes(t *testing.T) {
// The remote file itself includes a local file. The local file resolution
// exercises the sub-include path in resolveRemoteInclude (line 189-193).
dir := t.TempDir()
localContent := "local-child-job:\n script: echo child\n"
if err := os.WriteFile(filepath.Join(dir, "child.yml"), []byte(localContent), 0o644); err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
// The remote YAML includes a local file.
fmt.Fprintln(w, "include:\n - local: /child.yml\nremote-job:\n script: echo remote")
}))
defer srv.Close()
p := &model.Pipeline{Jobs: map[string]model.Job{}, RawJobs: map[string]map[string]any{}}
warnings, _ := resolveRemoteInclude(p, srv.URL+"/ci.yml", fetcher.AutoConfig(), dir, map[string]bool{}, 0)
if len(warnings) != 0 {
t.Errorf("sub-includes: unexpected warnings: %v", warnings)
}
if _, ok := p.Jobs["remote-job"]; !ok {
t.Error("remote-job should be merged")
}
if _, ok := p.Jobs["local-child-job"]; !ok {
t.Error("local-child-job should be merged via sub-include")
}
}
// ── resolveIncludes — routing branches ───────────────────────────────────────
func TestResolveIncludes_RemoteEntry(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
fmt.Fprintln(w, "remote-job:\n script: echo")
}))
defer srv.Close()
p := &model.Pipeline{Jobs: map[string]model.Job{}, RawJobs: map[string]map[string]any{}}
includes := []any{map[string]any{"remote": srv.URL + "/ci.yml"}}
warnings, _ := resolveIncludes(p, includes, fetcher.AutoConfig(), "/tmp", map[string]bool{}, 0)
if len(warnings) != 0 {
t.Errorf("remote entry: unexpected warnings: %v", warnings)
}
if _, ok := p.Jobs["remote-job"]; !ok {
t.Error("remote-job should be merged via resolveIncludes remote routing")
}
}
func TestResolveIncludes_ProjectEntry(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
fmt.Fprintln(w, "proj-job:\n script: echo")
}))
defer srv.Close()
p := &model.Pipeline{Jobs: map[string]model.Job{}, RawJobs: map[string]map[string]any{}}
cfg := fetcher.GitLabConfig{BaseURL: srv.URL, Token: "tok"}
includes := []any{map[string]any{"project": "g/p", "file": "ci.yml"}}
_, _ = resolveIncludes(p, includes, cfg, "/tmp", map[string]bool{}, 0)
// proj-job should be merged (or warning if fetch fails, but with token it should succeed)
}
func TestResolveIncludes_ComponentEntry(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
fmt.Fprintln(w, "comp-job:\n script: echo")
}))
defer srv.Close()
host := srv.URL[len("http://"):]
p := &model.Pipeline{Jobs: map[string]model.Job{}, RawJobs: map[string]map[string]any{}}
cfg := fetcher.GitLabConfig{BaseURL: srv.URL}
includes := []any{map[string]any{"component": host + "/g/p/mycomp@v1"}}
warnings, _ := resolveIncludes(p, includes, cfg, "/tmp", map[string]bool{}, 0)
_ = warnings // component path is exercised regardless of outcome
}
func TestResolveIncludes_ComponentEntry_HadErr(t *testing.T) {
// Component with a dollar sign in ref → hadErr=true → warning is appended.
p := &model.Pipeline{Jobs: map[string]model.Job{}, RawJobs: map[string]map[string]any{}}
includes := []any{map[string]any{"component": "${CI_SERVER}/g/p/comp@v1"}}
warnings, _ := resolveIncludes(p, includes, fetcher.GitLabConfig{}, "/tmp", map[string]bool{}, 0)
if len(warnings) == 0 {
t.Error("$ in component ref: expected warning")
}
}
// ── substituteInputs — empty data ────────────────────────────────────────────
func TestSubstituteInputs_EmptyData(t *testing.T) {
result := substituteInputs([]byte{}, map[string]any{"KEY": "val"})
if len(result) != 0 {
t.Errorf("empty data: expected empty result, got %q", result)
}
}
+10
View File
@@ -0,0 +1,10 @@
stages:
- build
# GL007 (only/except deprecated) would normally fire here, but the .glint.yml
# in this directory ignores GL007.
deprecated-job:
stage: build
only:
- main
script: echo ok
+2
View File
@@ -0,0 +1,2 @@
ignore:
- GL007
+8
View File
@@ -0,0 +1,8 @@
stages:
- build
# GL004 (undefined stage) is demoted to warning by .glint.yml, so the exit
# code must be 0 even though there is a finding.
bad-stage-job:
stage: nonexistent
script: echo hi
+2
View File
@@ -0,0 +1,2 @@
severity:
GL004: warning
+15
View File
@@ -0,0 +1,15 @@
stages:
- build
# glint: ignore GL007
suppressed-job:
stage: build
only:
- main
script: echo ok
still-flagged-job:
stage: build
only:
- main
script: echo ok
+59
View File
@@ -0,0 +1,59 @@
---
# dead_rules.yml
# Exercises GL033: rules: block where every rule has when: never.
# All flagged jobs produce a WARNING (exit 0). Valid jobs must not be flagged.
stages:
- build
- test
- deploy
# ── Jobs that should trigger GL033 ─────────────────────────────────────────
# Single bare catch-all never — job is always excluded.
disabled-job:
stage: build
script: echo disabled
rules:
- when: never
# Multiple rules, all when: never — no branch can activate this job.
dead-multi:
stage: test
script: echo dead
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
when: never
- if: '$CI_COMMIT_BRANCH == "develop"'
when: never
- when: never
# ── Jobs that must NOT trigger GL033 ───────────────────────────────────────
# First rule can activate.
main-only:
stage: build
script: echo main
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
when: on_success
- when: never
# Rule with no when: — defaults to on_success, so job is reachable.
implicit-on-success:
stage: test
script: echo implicit
rules:
- if: '$CI_COMMIT_BRANCH == "develop"'
# Manual is not never — job is reachable (just gated).
manual-gate:
stage: deploy
script: echo deploy
rules:
- when: manual
# No rules at all — always active.
always-active:
stage: build
script: echo always
+1 -1
View File
@@ -3,7 +3,7 @@ stages:
- deploy - deploy
# CI/CD catalog components. Without network access or a token the fetches will # CI/CD catalog components. Without network access or a token the fetches will
# fail and gitlab-sim emits a WARNING for each, then lints the local jobs normally. # fail and glint emits a WARNING for each, then lints the local jobs normally.
include: include:
# Standard single-segment component path (file layout: templates/sast.yml) # Standard single-segment component path (file layout: templates/sast.yml)
- component: gitlab.com/components/sast/sast@latest - component: gitlab.com/components/sast/sast@latest
+1 -1
View File
@@ -3,7 +3,7 @@ stages:
- test - test
# This pipeline includes templates from another GitLab project. # This pipeline includes templates from another GitLab project.
# Without a token, gitlab-sim warns but still lints the local jobs. # Without a token, glint warns but still lints the local jobs.
include: include:
- project: my-group/ci-templates - project: my-group/ci-templates
ref: main ref: main
+14
View File
@@ -0,0 +1,14 @@
---
# Exercises include: remote: URL fetching and sub-include recursion.
# The remote file is a real public GitLab CI template; it may contain its own
# includes which should also be resolved. Exit code is 0 (warnings allowed).
stages:
- test
include:
- remote: https://gitlab.com/gitlab-org/gitlab/-/raw/master/lib/gitlab/ci/templates/Bash.gitlab-ci.yml
local-job:
stage: test
script:
- echo "local job alongside remote include"
+26
View File
@@ -0,0 +1,26 @@
stages:
- build
- test
# No default: block — any inherit: default: declaration is a no-op.
# GL043: inherit: default: false but no default: block.
isolated-job:
stage: build
inherit:
default: false
script: echo isolated
# GL043: inherit: default: [list] but no default: block.
selective-inherit-job:
stage: test
inherit:
default: [image, tags]
script: echo selective
# Not GL043: inherit only touches variables: (no default: check needed).
vars-inherit-job:
stage: test
inherit:
variables: false
script: echo vars-only
+20
View File
@@ -0,0 +1,20 @@
stages:
- build
- test
default:
image: node:20
# GL043: before_script is in the list but not defined in default:.
selective-bad:
stage: build
inherit:
default: [image, before_script] # before_script not in default:
script: echo hi
# Not GL043: image IS defined in default: — list is valid.
selective-good:
stage: test
inherit:
default: [image]
script: echo hi
+4
View File
@@ -9,6 +9,10 @@ workflow:
when: always when: always
- when: never - when: never
default:
tags:
- docker
variables: variables:
GO_VERSION: "1.26" GO_VERSION: "1.26"
+9
View File
@@ -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
+81
View File
@@ -0,0 +1,81 @@
stages:
- build
- test
- test
variables:
DEPLOY_ENV: staging
# GL040: duplicate stage "test" above triggers a warning
# GL034: services map form missing name
service-no-name:
stage: build
script: [echo ok]
services:
- alias: my-svc
# GL034: services map form with invalid alias (contains spaces)
service-bad-alias:
stage: build
script: [echo ok]
services:
- name: redis:latest
alias: "my bad alias"
# GL035: rules:changes with absolute path
absolute-changes:
stage: test
script: [echo test]
rules:
- changes:
- /src/main.go
# GL035: rules:exists with absolute path
absolute-exists:
stage: test
script: [echo test]
rules:
- exists:
- /Dockerfile
# GL036: invalid timeout format
bad-timeout:
stage: build
script: [echo build]
timeout: forever
# GL037: id_tokens entry missing aud
bad-token:
stage: test
script: [echo test]
id_tokens:
MY_TOKEN:
expire: 3600
# GL038: secrets entry missing provider
bad-secret:
stage: test
script: [echo test]
secrets:
DB_PASSWORD:
expire: 3600
# GL039: pages keyword but publish dir not in artifacts.paths
bad-pages:
stage: build
script: [mkdocs build]
pages:
publish: dist
artifacts:
paths:
- public
# GL041: cache.key.files contains a glob
bad-cache-glob:
stage: build
script: [echo build]
cache:
key:
files:
- "*.sum"
+116
View File
@@ -0,0 +1,116 @@
stages:
- build
- test
- deploy
variables:
DEPLOY_ENV: staging
# GL034: services — string form and map form with name are both valid
service-string:
stage: build
script: [echo ok]
services:
- redis:latest
- postgres:14
service-map:
stage: build
script: [echo ok]
services:
- name: postgres:14
alias: db
- name: redis:latest
alias: cache-svc
# GL035: rules:changes/exists — relative paths are valid
rules-relative:
stage: test
script: [echo test]
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
changes:
- src/**/*.go
- tests/*.go
- exists:
- Dockerfile
- docker-compose.yml
when: on_success
# GL036: timeout — valid duration strings
timeout-short:
stage: build
script: [echo build]
timeout: 30m
timeout-long:
stage: build
script: [echo build]
timeout: 1h 30m
timeout-words:
stage: test
script: [echo test]
timeout: 90 minutes
timeout-combined:
stage: deploy
script: [echo deploy]
timeout: 2 hours 30 minutes
# GL037: id_tokens — entry with valid aud
token-job:
stage: build
script: [echo build]
id_tokens:
VAULT_TOKEN:
aud: https://vault.example.com
SIGSTORE_TOKEN:
aud: sigstore
# GL038: secrets — valid provider keys
secret-vault:
stage: deploy
script: [echo deploy]
secrets:
DB_PASSWORD:
vault: production/db/password@ops
secret-gcp:
stage: deploy
script: [echo deploy]
secrets:
API_KEY:
gcp_secret_manager:
name: my-api-key
version: latest
# GL039: pages keyword — publish dir present in artifacts.paths
pages-keyword:
stage: deploy
script: [mkdocs build]
pages:
publish: site
artifacts:
paths:
- site
pages-keyword-default:
stage: deploy
script: [make docs]
pages: true
artifacts:
paths:
- public
# GL040: no duplicate stages (unique stages defined above)
# GL041: cache.key.files — list of exact paths
cache-key-job:
stage: build
script: [echo build]
cache:
key:
files:
- go.sum
- go.mod
+51
View File
@@ -0,0 +1,51 @@
---
# rules_if_expr.yml
# Exercises the rules:if: expression evaluator for:
# - Multi-line block-scalar expressions (|| on next line)
# - ${VAR} curly-brace variable syntax
# - Regex flags (/pattern/i case-insensitive)
# - Parenthesised compound expressions
# Expected: exits 0 (lints clean with no context).
stages:
- build
- deploy
variables:
DEPLOY_ENVIRONMENTS:
value: "staging"
description: "Target deployment environment"
build:
stage: build
script: make build
rules:
# Multi-line expression: || on next line
- if: |
$CI_COMMIT_BRANCH == "main" ||
$CI_COMMIT_BRANCH == "develop"
when: on_success
- when: never
deploy-feature:
stage: deploy
script: make deploy
rules:
# ${VAR} curly-brace syntax
- if: '${CI_COMMIT_BRANCH} != null && ${CI_COMMIT_TAG} == null'
when: manual
- when: never
release:
stage: deploy
script: make release
rules:
# Case-insensitive regex flag
- if: '$CI_COMMIT_BRANCH =~ /^(main|master)$/i'
when: on_success
# Parenthesised compound with multi-line
- if: >-
($CI_COMMIT_TAG != null) &&
($CI_PIPELINE_SOURCE == "push" || $CI_PIPELINE_SOURCE == "web")
when: on_success
- when: never
+4
View File
@@ -0,0 +1,4 @@
include:
- /.gitlab-ci-default-runners.yml
# Currently we're happy with the defaults
+12
View File
@@ -0,0 +1,12 @@
# This is just used for the scheduled pipelines in the
# https://gitlab.com/samba-team/samba configuration
#
variables:
SAMBA_CI_FLAVOR: "coverage"
# "--enable-coverage" or ""
SAMBA_CI_AUTOBUILD_ENABLE_COVERAGE: "--enable-coverage"
include:
- /.gitlab-ci-coverage-runners.yml
- /.gitlab-ci-main.yml
+30
View File
@@ -0,0 +1,30 @@
# From https://docs.gitlab.com/ee/ci/runners/hosted_runners/linux.html
#
# ...
#
# Runner Tag vCPUs Memory Storage
# saas-linux-small-amd64 2 8 GB 25 GB
#
# Our current private runner 'docker', 'samba-ci-private', 'shared' and
# 'ubuntu2204'. It runs with an ubuntu2204 kernel (5.15) and provides an
# ext4 filesystem, 2 CPU and 4 GB (shared tag) 8G (samba-ci-private tag) RAM.
#
.shared_runner_build:
# We use saas-linux-small-amd64 shared runners by default.
# We avoid adding explicit tags for them in order
# to work with potential changes in future
#
# In order to generate valid yaml, we define a dummy variable...
variables:
SAMBA_SHARED_RUNNER_BUILD_DUMMY_VARIABLE: shared_runner_build
.shared_runner_test:
# We use saas-linux-small-amd64 shared runners by default.
extends: .shared_runner_build
.private_runner_test:
# We use our private runner only for special tests
tags:
- docker
- samba-ci-private
+10
View File
@@ -0,0 +1,10 @@
variables:
SAMBA_CI_FLAVOR: "default"
# "--enable-coverage" or ""
# See .gitlab-ci-coverage.yml
SAMBA_CI_AUTOBUILD_ENABLE_COVERAGE: ""
AUTOBUILD_SKIP_SAMBA_O3: "0"
include:
- /.gitlab-ci-default-runners.yml
- /.gitlab-ci-main.yml
+688
View File
@@ -0,0 +1,688 @@
# see https://docs.gitlab.com/ce/ci/yaml/README.html for all available options
# Stages explained
#
# images: Build the images with the bootstrap script
# build_first: Build a few things first to find silly errors (fast job)
# (don't pay for 35 machines until something compiles)
# build: The main parallel job
# (keep these to 1hour as we are billed per hour)
# test_only: Tests using the build from prior stages, these typically
# have an explicit dependency defined to a specific build job,
# which means that start as soon as the build job finished.
# test_private: Like test_only, but running on private runners
# report: Code coverage reporting
stages:
- images
- build_first
- build
- test_only
- test_private
- report
variables:
# We want to be resilient to runner failures
ARTIFACT_DOWNLOAD_ATTEMPTS: "3"
EXECUTOR_JOB_SECTION_ATTEMPTS: "3"
GET_SOURCES_ATTEMPTS: "3"
RESTORE_CACHE_ATTEMPTS: "3"
#
GIT_STRATEGY: fetch
GIT_DEPTH: "3"
#
# Use GZip by default, it is fast and is good enough. Other options include --xz
SAMBA_TESTBASE_TAR_OPTIONS: -z
#
# we run autobuild.py inside a samba CI docker image located on gitlab's registry
# overwrite this variable if you want use your own image registry.
#
# Or better ask for access to the shared development repository, see
# https://wiki.samba.org/index.php/Samba_CI_on_gitlab#Getting_Access
#
SAMBA_CI_CONTAINER_REGISTRY: registry.gitlab.com/samba-team/devel/samba
#
# Set this to the contents of bootstrap/sha1sum.txt
# which is generated by bootstrap/template.py --render
#
SAMBA_CI_CONTAINER_TAG: e494a8092a6d0e794223f56ddb2ffbaf76402cf6
#
# We use the ubuntu2204 image as default as
# it matches what we have on atb-devel-224
#
SAMBA_CI_CONTAINER_IMAGE: ubuntu2204
#
# The following images are available
# Please see the samba-o3 sections at the end of this file!
# We should run that for each available image
#
SAMBA_CI_CONTAINER_IMAGE_ubuntu2204: ubuntu2204
SAMBA_CI_CONTAINER_IMAGE_ubuntu2404: ubuntu2404
SAMBA_CI_CONTAINER_IMAGE_ubuntu2604: ubuntu2604
SAMBA_CI_CONTAINER_IMAGE_debian11: debian11
SAMBA_CI_CONTAINER_IMAGE_debian11_32bit: debian11-32bit
SAMBA_CI_CONTAINER_IMAGE_debian12: debian12
SAMBA_CI_CONTAINER_IMAGE_opensuse160: opensuse160
SAMBA_CI_CONTAINER_IMAGE_rocky8: rocky8
SAMBA_CI_CONTAINER_IMAGE_centos9s: centos9s
SAMBA_CI_CONTAINER_IMAGE_fedora43: fedora43
include:
# The image creation details are specified in a separate file
# See bootstrap/README.md for details
- 'bootstrap/.gitlab-ci.yml'
.shared_runner_build_image:
extends: .shared_runner_build
variables:
SAMBA_CI_JOB_IMAGE: ${SAMBA_CI_CONTAINER_IMAGE}
image: ${SAMBA_CI_CONTAINER_REGISTRY}/samba-ci-${SAMBA_CI_JOB_IMAGE}:${SAMBA_CI_CONTAINER_TAG}
.shared_template:
extends: .shared_runner_build_image
# All Samba jobs are interruptible, this avoids burning CPU when a
# newer branch is pushed.
interruptible: true
timeout: 2h
# Otherwise we run twice, once on push and once on MR
# https://forum.gitlab.com/t/new-rules-syntax-and-detached-pipelines/37292
rules:
- if: $CI_MERGE_REQUEST_ID
when: never
- when: on_success
variables:
AUTOBUILD_JOB_NAME: $CI_JOB_NAME
stage: build
cache:
key: ccache.${CI_JOB_NAME}.${SAMBA_CI_JOB_IMAGE}.${SAMBA_CI_FLAVOR}
paths:
- ccache
# This is overridden in many cases, but ensures none of the other
# main jobs start until and unless this build finishes. However
# this also ensures we do not download artifacts from any build
# unless we specifically depend on it, saving bandwidth
needs:
- job: samba-def-build
artifacts: false
before_script:
- uname -a
- ls -l /sys/module/
- ls -l /sys/kernel/security/
- if [ -e /sys/kernel/security/lsm ]; then cat /sys/kernel/security/lsm ; echo; fi
- if [ -e /proc/config.gz ]; then sudo zcat /proc/config.gz; echo; fi
- lsb_release -a
- cat /etc/os-release
- id
- cat /proc/self/status
- lscpu
- cat /proc/cpuinfo
- mount
- df -h
- cat /proc/swaps
- free -h
# ld will fail if coverage enabled, force link ld to ld.bfd
- if [ -n "$SAMBA_CI_AUTOBUILD_ENABLE_COVERAGE" ]; then sudo ln -sf $(which ld.bfd) $(which ld); fi
# See bootstrap/.gitlab-ci.yml how to generate a new image
- echo "SAMBA_CI_CONTAINER_REGISTRY[${SAMBA_CI_CONTAINER_REGISTRY}]"
- echo "SAMBA_CI_CONTAINER_TAG[${SAMBA_CI_CONTAINER_TAG}]"
- echo "SAMBA_CI_JOB_IMAGE[${SAMBA_CI_JOB_IMAGE}]"
- echo "CI_JOB_IMAGE[${CI_JOB_IMAGE}]"
- bootstrap/template.py --sha1sum > /tmp/sha1sum-template.txt
- diff -u bootstrap/sha1sum.txt /tmp/sha1sum-template.txt
- echo "${SAMBA_CI_CONTAINER_TAG}" > /tmp/sha1sum-tag.txt
- diff -u bootstrap/sha1sum.txt /tmp/sha1sum-tag.txt
- diff -u bootstrap/sha1sum.txt /sha1sum.txt
- echo "${CI_COMMIT_SHA} ${CI_COMMIT_TITLE}" > /tmp/commit.txt
- export CCACHE_BASEDIR="${PWD}"
- export CCACHE_DIR="${PWD}/ccache" && mkdir -pv "$CCACHE_DIR"
- export CC="ccache cc"
- export CXX="ccache c++"
- ccache -z -M 500M
- ccache -s
# We are already running .gitlab-ci directives from this repo, remove additional checks that break our CI
- git config --global --add safe.directory '*'
after_script:
- mount
- df -h
- cat /proc/swaps
- free -h
- CCACHE_BASEDIR="${PWD}" CCACHE_DIR="${PWD}/ccache" ccache -s -c
artifacts:
expire_in: 1 week
paths:
- "*.stdout"
- "*.stderr"
- "*.info"
- public
- system-info.txt
retry:
max: 2
when:
- runner_system_failure
- stuck_or_timeout_failure
- api_failure
- runner_unsupported
- stale_schedule
- archived_failure
- scheduler_failure
- data_integrity_failure
script:
# gitlab predefines CI_JOB_NAME for each job. The gitlab job usually matches the
# autobuild name, which means we can define a default template that runs most autobuild jobs
- script/autobuild.py $AUTOBUILD_JOB_NAME $SAMBA_CI_AUTOBUILD_ENABLE_COVERAGE --verbose --nocleanup --keeplogs --tail --full-testbase /builds/samba-testbase
# Ensure when adding a new job below that you also add it to
# the dependencies for 'pages' below for the code coverage page
# generation.
others:
extends: .shared_template
script:
- script/autobuild.py pidl $SAMBA_CI_AUTOBUILD_ENABLE_COVERAGE --verbose --nocleanup --keeplogs --tail --full-testbase /builds/samba-testbase/pidl
- script/autobuild.py replace $SAMBA_CI_AUTOBUILD_ENABLE_COVERAGE --verbose --nocleanup --keeplogs --tail --full-testbase /builds/samba-testbase/replace
- script/autobuild.py talloc $SAMBA_CI_AUTOBUILD_ENABLE_COVERAGE --verbose --nocleanup --keeplogs --tail --full-testbase /builds/samba-testbase/talloc
- script/autobuild.py tdb $SAMBA_CI_AUTOBUILD_ENABLE_COVERAGE --verbose --nocleanup --keeplogs --tail --full-testbase /builds/samba-testbase/tdb
- script/autobuild.py tevent $SAMBA_CI_AUTOBUILD_ENABLE_COVERAGE --verbose --nocleanup --keeplogs --tail --full-testbase /builds/samba-testbase/tevent
- script/autobuild.py samba-xc $SAMBA_CI_AUTOBUILD_ENABLE_COVERAGE --verbose --nocleanup --keeplogs --tail --full-testbase /builds/samba-testbase/samba-xc
- script/autobuild.py docs-xml $SAMBA_CI_AUTOBUILD_ENABLE_COVERAGE --verbose --nocleanup --keeplogs --tail --full-testbase /builds/samba-testbase/docs-xml
- make -C coverity
.shared_template_build_only:
extends: .shared_template
timeout: 2h
needs:
artifacts:
expire_in: 1 week
paths:
- "*.stdout"
- "*.stderr"
- "*.info"
- system-info.txt
- samba-testbase.tar
script:
# gitlab predefines CI_JOB_NAME for each job. The gitlab job usually matches the
# autobuild name, which means we can define a default template that runs most autobuild jobs
- script/autobuild.py $AUTOBUILD_JOB_NAME $SAMBA_CI_AUTOBUILD_ENABLE_COVERAGE --verbose --nocleanup --keeplogs --tail --full-testbase /builds/samba-testbase
# On success we need to pack everything into an artifacts file
# which needs to be in the git checkout.
# As tar doesn't handle hardlink of read-only files,
# we remember the acls and add write permissions
# before creating the archive. The consumer will apply
# the acls again.
- cp -a /sha1sum.txt /builds/samba-testbase/image-sha1sum.txt
- cp -a /tmp/commit.txt /builds/samba-testbase/commit.txt
- ln -s /builds/samba-testbase/${AUTOBUILD_JOB_NAME}/ /builds/samba-testbase/build_subdir_link
- pushd /builds && getfacl -R samba-testbase > samba-testbase.acl.dump && popd
- chmod -R +w /builds/samba-testbase
- mv /builds/samba-testbase.acl.dump /builds/samba-testbase/
- tar $SAMBA_TESTBASE_TAR_OPTIONS -cf samba-testbase.tar /builds/samba-testbase
- ls -la samba-testbase.tar
- sha1sum samba-testbase.tar
.shared_template_test_only:
extends:
- .shared_template
- .shared_runner_test
stage: test_only
script:
# Print the Kerberos version to check we ended up with the right one
# in the runner. We do not have configure output to recognize it
# otherwise.
- if [ -x "$(command -v krb5-config)" ]; then krb5-config --version; fi
# We unpack the artifacts file created by the .shared_template_build_only
# run we depend on
- ls -la samba-testbase.tar
- sha1sum samba-testbase.tar
- tar $SAMBA_TESTBASE_TAR_OPTIONS -xf samba-testbase.tar -C /
- diff -u /builds/samba-testbase/image-sha1sum.txt /sha1sum.txt
- diff -u /builds/samba-testbase/commit.txt /tmp/commit.txt
- mv /builds/samba-testbase/samba-testbase.acl.dump /builds/samba-testbase.acl.dump
- pushd /builds && setfacl --restore=/builds/samba-testbase.acl.dump && popd
- ls -la /builds/samba-testbase/
- ls -la /builds/samba-testbase/build_subdir_link
- ls -la /builds/samba-testbase/build_subdir_link/
- if [ -n "$SAMBA_CI_AUTOBUILD_ENABLE_COVERAGE" ]; then find /builds/samba-testbase/build_subdir_link/ -type d -printf "'%p'\n" | xargs chmod u+w; fi
- ls -la /builds/samba-testbase/build_subdir_link/
# gitlab predefines CI_JOB_NAME for each job. The gitlab job usually matches the
# autobuild name, which means we can define a default template that runs most autobuild jobs
- script/autobuild.py $AUTOBUILD_JOB_NAME $SAMBA_CI_AUTOBUILD_ENABLE_COVERAGE --skip-dependencies --verbose --nocleanup --keeplogs --tail --full-testbase /builds/samba-testbase
samba-def-build:
extends: .shared_template_build_only
stage: build_first
.needs_samba-def-build:
extends: .shared_template_test_only
needs:
- job: samba-def-build
artifacts: true
- job: samba-codecheck
samba-mit-build:
extends: .shared_template_build_only
variables:
SAMBA_CI_JOB_IMAGE: ${SAMBA_CI_CONTAINER_IMAGE_fedora43}
stage: build_first
.needs_samba-mit-build:
extends: .shared_template_test_only
variables:
SAMBA_CI_JOB_IMAGE: ${SAMBA_CI_CONTAINER_IMAGE_fedora43}
needs:
- job: samba-mit-build
artifacts: true
- job: samba-codecheck
samba-h5l-build:
extends: .shared_template_build_only
.needs_samba-h5l-build:
extends: .shared_template_test_only
needs:
- job: samba-h5l-build
artifacts: true
samba-without-smb1-build:
extends: .shared_template_build_only
.needs_samba-without-smb1-build:
extends: .shared_template_test_only
needs:
- job: samba-without-smb1-build
artifacts: true
samba-nt4-build:
extends: .shared_template_build_only
.needs_samba-nt4-build:
extends: .shared_template_test_only
needs:
- job: samba-nt4-build
artifacts: true
samba-no-opath-build:
extends: .shared_template_build_only
.needs_samba-no-opath-build:
extends: .shared_template_test_only
needs:
- job: samba-no-opath-build
artifacts: true
samba:
extends: .shared_template
samba-mitkrb5:
extends: .shared_template
variables:
SAMBA_CI_JOB_IMAGE: ${SAMBA_CI_CONTAINER_IMAGE_fedora43}
samba-minimal-smbd:
extends: .shared_template
samba-nopython:
extends: .shared_template
samba-admem:
extends: .needs_samba-def-build
samba-ad-dc-2:
extends: .needs_samba-def-build
samba-ad-dc-3:
extends: .needs_samba-def-build
samba-ad-dc-4a:
extends: .needs_samba-def-build
samba-ad-dc-4b:
extends: .needs_samba-def-build
samba-ad-dc-5:
extends: .needs_samba-def-build
samba-ad-dc-6:
extends: .needs_samba-def-build
samba-ad-back1:
extends: .needs_samba-def-build
samba-ad-back2:
extends: .needs_samba-def-build
samba-schemaupgrade:
extends: .needs_samba-def-build
samba-libs:
extends: .shared_template
samba-fuzz:
extends: .shared_template
variables:
SAMBA_CI_JOB_IMAGE: ${SAMBA_CI_CONTAINER_IMAGE_ubuntu2404}
ctdb:
extends: .shared_template
samba-ctdb:
extends: .shared_template
samba-ad-dc-ntvfs:
extends: .needs_samba-def-build
samba-admem-mit:
extends: .needs_samba-mit-build
samba-addc-mit-4a:
extends: .needs_samba-mit-build
samba-addc-mit-4b:
extends: .needs_samba-mit-build
# This task is run first to ensure we compile before we start the
# main run as it is the fastest full compile of Samba.
samba-fips:
extends: .shared_template
variables:
SAMBA_CI_JOB_IMAGE: ${SAMBA_CI_CONTAINER_IMAGE_fedora43}
samba-codecheck:
extends: .shared_template
needs:
stage: build_first
.private_test_only:
extends: .private_runner_test
stage: test_private
rules:
# See above, to avoid a duplicate CI on the MR (these rules override the others)
- if: $CI_MERGE_REQUEST_ID
when: never
# These jobs are only run if the gitlab repo has private runners available.
# To enable private jobs, you must add the following var and value to
# your gitlab repo by navigating to:
# settings -> CI/CD -> Environment variables
- if: $SUPPORT_PRIVATE_TEST == "yes"
.needs_ext4_support:
# All runners provide an ext4 filesystem
#
# Note: we don't use
# extends: .shared_template_test_only
# as that somehow resets the needs section
# and generates problems for something
# like this (which is used below)
#
# .needs_samba-SOME-build-ext4:
# extends:
# - .needs_samba-SOME-build
# - .needs_ext4_support
#
# So we only set stage again instead...
stage: test_only
.needs_5_15_kernel:
# Our private runners are based on
# ubuntu2204 with a 5.15 kernel.
#
# And they also provide an ext4 filesystem
extends: .private_test_only
.needs_samba-def-build-ext4:
extends:
- .needs_samba-def-build
- .needs_ext4_support
.needs_samba-mit-build-ext4:
extends:
- .needs_samba-mit-build
- .needs_ext4_support
.needs_samba-h5l-build-ext4:
extends:
- .needs_samba-h5l-build
- .needs_ext4_support
.needs_samba-without-smb1-build-5_15:
# Currently this doesn't strictly
# require a kernel >= 5.15, but only
# ext4 support.
#
# But we want to make sure that
# our private runners keep working
# and at least do a single job.
#
# In future we'll be able to run
# tests with io_uring in this
# setup, which will requires a
# 5.15 kernel in order to be useful.
extends:
- .needs_samba-without-smb1-build
- .needs_5_15_kernel
.needs_samba-nt4-build-ext4:
extends:
- .needs_samba-nt4-build
- .needs_ext4_support
.needs_samba-no-opath-build-ext4:
extends:
- .needs_samba-no-opath-build
- .needs_ext4_support
samba-fileserver:
extends: .needs_samba-h5l-build-ext4
samba-fileserver-without-smb1:
extends: .needs_samba-without-smb1-build
# This is a full build without the AD DC so we test the build with MIT
# Kerberos from the default system (Ubuntu 22.04 at this stage).
# Runtime behaviour checked via the ktest (static ccache and keytab)
# environment
samba-ktest-mit:
extends: .shared_template
samba-ad-dc-1:
extends: .needs_samba-def-build-ext4
samba-nt4:
extends: .needs_samba-nt4-build-ext4
samba-addc-mit-1:
extends: .needs_samba-mit-build-ext4
samba-no-opath1:
extends: .needs_samba-no-opath-build-ext4
samba-no-opath2:
extends: .needs_samba-no-opath-build-ext4
# 'pages' is a special job which can publish artifacts in `public` dir to gitlab pages
pages:
extends: .shared_runner_build_image
stage: report
dependencies: # tell gitlab to download artifacts for these jobs
- others
- samba
- samba-mitkrb5
- samba-admem
- samba-ad-dc-2
- samba-ad-dc-3
- samba-ad-dc-4a
- samba-ad-dc-4b
- samba-ad-dc-5
- samba-ad-dc-6
- samba-libs
- samba-minimal-smbd
- samba-nopython
- samba-fuzz
# - ctdb # TODO
- samba-ctdb
- samba-ad-dc-ntvfs
- samba-admem-mit
- samba-addc-mit-4a
- samba-addc-mit-4b
- samba-ad-back1
- samba-ad-back2
- samba-fileserver
- samba-fileserver-without-smb1
- samba-ad-dc-1
- samba-nt4
- samba-schemaupgrade
- samba-addc-mit-1
- samba-fips
- samba-no-opath1
- samba-no-opath2
- ubuntu2204-samba-o3
script:
- ls -la *.info
- ./configure.developer
- make -j
- ls -la *.info
- lcov $(ls *.info | xargs -I{} echo -n "-a {} ") -o all.info
- ls -la *.info
- genhtml all.info --ignore-errors source --output-directory public --prefix=$(pwd) --title "coverage report for $CI_COMMIT_REF_NAME $CI_COMMIT_SHORT_SHA"
artifacts:
expire_in: 30 days
paths:
- public
only:
variables:
- $SAMBA_CI_AUTOBUILD_ENABLE_COVERAGE == "--enable-coverage"
# Coverity Scan
coverity:
extends: .shared_runner_build_image
variables:
SAMBA_CI_JOB_IMAGE: ${SAMBA_CI_CONTAINER_IMAGE_opensuse160}
stage: build
script:
- wget https://scan.coverity.com/download/linux64 --post-data "token=$COVERITY_SCAN_TOKEN&project=$COVERITY_SCAN_PROJECT_NAME" -O /tmp/coverity_tool.tgz
- tar xf /tmp/coverity_tool.tgz
- ./configure.developer --with-cluster-support
- cov-analysis-linux64-*/bin/coverity capture --dir cov-int --project-dir ./
- tar czf cov-int.tar.gz cov-int
- curl
--form token=$COVERITY_SCAN_TOKEN
--form email=$COVERITY_SCAN_EMAIL
--form file=@cov-int.tar.gz
--form version="`git describe --tags`"
--form description="CI build"
https://scan.coverity.com/builds?project=$COVERITY_SCAN_PROJECT_NAME
only:
refs:
- master
- schedules
variables:
- $COVERITY_SCAN_TOKEN != null
- $COVERITY_SCAN_PROJECT_NAME != null
- $COVERITY_SCAN_EMAIL != null
artifacts:
expire_in: 1 week
when: on_failure
paths:
- cov-int/*.txt
debian11-samba-32bit:
extends: .shared_template
variables:
AUTOBUILD_JOB_NAME: samba-32bit
SAMBA_CI_JOB_IMAGE: ${SAMBA_CI_CONTAINER_IMAGE_debian11_32bit}
#
# We build samba-o3 on all supported distributions
#
# This job, which matches the main CI, needs to still do coverage so
# we show the coverage on the "none" environment tests
#
# We want --enable-coverage specified here otherwise we will have a
# different set of build options on the coverage build and can fail
# when -O3 gets combined with --enable-coverage in the scheduled
# builds.
ubuntu2204-samba-o3:
extends: .shared_template
variables:
AUTOBUILD_JOB_NAME: samba-o3
SAMBA_CI_JOB_IMAGE: ${SAMBA_CI_CONTAINER_IMAGE_ubuntu2204}
SAMBA_CI_AUTOBUILD_ENABLE_COVERAGE: "--enable-coverage"
rules:
# See above, to avoid a duplicate CI on the MR (these rules override the others)
- if: $CI_MERGE_REQUEST_ID
when: never
# do not run o3 builds (which run a lot of VMs) if told not to
# (this uses the same variable as autobuild.py)
- if: $AUTOBUILD_SKIP_SAMBA_O3 == "1"
when: never
- when: on_success
# All other jobs do not want code coverage.
.samba-o3-template:
extends: .shared_template
variables:
AUTOBUILD_JOB_NAME: samba-o3
rules:
# See above, to avoid a duplicate CI on the MR (these rules override the others)
- if: $CI_MERGE_REQUEST_ID
when: never
# do not run o3 builds (which run a lot of VMs) if told not to
# (this uses the same variable as autobuild.py)
- if: $AUTOBUILD_SKIP_SAMBA_O3 == "1"
when: never
# do not run o3 for coverage since they are using different images
- if: $SAMBA_CI_AUTOBUILD_ENABLE_COVERAGE == ""
ubuntu2404-samba-o3:
extends: .samba-o3-template
variables:
SAMBA_CI_JOB_IMAGE: ${SAMBA_CI_CONTAINER_IMAGE_ubuntu2404}
ubuntu2604-samba-o3:
extends: .samba-o3-template
variables:
SAMBA_CI_JOB_IMAGE: ${SAMBA_CI_CONTAINER_IMAGE_ubuntu2604}
debian11-samba-o3:
extends: .samba-o3-template
variables:
SAMBA_CI_JOB_IMAGE: ${SAMBA_CI_CONTAINER_IMAGE_debian11}
debian12-samba-o3:
extends: .samba-o3-template
variables:
SAMBA_CI_JOB_IMAGE: ${SAMBA_CI_CONTAINER_IMAGE_debian12}
opensuse160-samba-o3:
extends: .samba-o3-template
variables:
SAMBA_CI_JOB_IMAGE: ${SAMBA_CI_CONTAINER_IMAGE_opensuse160}
rocky8-samba-o3:
extends: .samba-o3-template
variables:
SAMBA_CI_JOB_IMAGE: ${SAMBA_CI_CONTAINER_IMAGE_rocky8}
centos9s-samba-o3:
extends: .samba-o3-template
variables:
SAMBA_CI_JOB_IMAGE: ${SAMBA_CI_CONTAINER_IMAGE_centos9s}
fedora43-samba-o3:
extends: .samba-o3-template
variables:
SAMBA_CI_JOB_IMAGE: ${SAMBA_CI_CONTAINER_IMAGE_fedora43}
#
# Keep the samba-o3 sections at the end ...
#
+5
View File
@@ -0,0 +1,5 @@
# This is just a legacy alias used by the
# https://gitlab.com/samba-team/devel/samba configuration
#
include:
- '/.gitlab-ci.yml'
+2
View File
@@ -0,0 +1,2 @@
include:
- /.gitlab-ci-default.yml
+16
View File
@@ -0,0 +1,16 @@
#
# GitLeaks Repo Specific Configuration
#
# This allowlist is used to help Red Hat ignore false positives during its code
# scans.
[allowlist]
paths = [
'''docs-xml/manpages/smbstatus.1.xml''',
'''selftests/*''',
'''source3/script/tests/*''',
'''source4/dsdb/tests/*''',
'''source4/torture/*''',
'''testprogs/blackbox/*''',
'''tests/*''',
]
+122
View File
@@ -0,0 +1,122 @@
---
.build_image_template:
image: quay.io/podman/stable:latest
stage: images
tags:
# We need to make sure we only use gitlab.com
# runners and not our own runners, as our current runners
# don't allow 'docker build ...' to run.
- saas-linux-small-amd64
variables:
SAMBA_CI_IS_BROKEN_IMAGE: "no"
SAMBA_CI_TEST_JOB: "samba-o3"
SAMBA_CI_PLATFORM: "linux/amd64"
before_script:
# install prerequisites
- dnf install -qy diffutils
# Ensure we are generating correct the container
- uname -a
- cat /etc/os-release
- echo "SAMBA_CI_CONTAINER_REGISTRY[${SAMBA_CI_CONTAINER_REGISTRY}]"
- echo "SAMBA_CI_CONTAINER_TAG[${SAMBA_CI_CONTAINER_TAG}]"
- echo "SAMBA_CI_IS_BROKEN_IMAGE[${SAMBA_CI_IS_BROKEN_IMAGE}]"
- echo "SAMBA_CI_REBUILD_IMAGES[${SAMBA_CI_REBUILD_IMAGES}]"
- echo "SAMBA_CI_REBUILD_BROKEN_IMAGES[${SAMBA_CI_REBUILD_BROKEN_IMAGES}]"
- echo "GITLAB_USER_LOGIN[${GITLAB_USER_LOGIN}]"
- echo "${SAMBA_CI_CONTAINER_TAG}" > /tmp/sha1sum-tag.txt
- diff -u bootstrap/sha1sum.txt /tmp/sha1sum-tag.txt
script: |
set -xueo pipefail
ci_image_name=samba-ci-${CI_JOB_NAME}
podman build --platform ${SAMBA_CI_PLATFORM} --tag ${ci_image_name} --build-arg SHA1SUM=${SAMBA_CI_CONTAINER_TAG} bootstrap/generated-dists/${CI_JOB_NAME}
ci_image_path="${SAMBA_CI_CONTAINER_REGISTRY}/${ci_image_name}"
timestamp=$(date +%Y%m%d%H%M%S)
container_hash=$(podman image inspect --format='{{ .Id }}' ${ci_image_name} | cut -c 1-9)
timestamp_tag=${SAMBA_CI_CONTAINER_TAG}-${timestamp}-${GITLAB_USER_LOGIN}-${container_hash}
samba_repo_root=/home/samba/samba
# Ensure we are generating the correct container that we expect to be in
echo "${SAMBA_CI_CONTAINER_TAG}" > /tmp/sha1sum-tag.txt
diff -u bootstrap/sha1sum.txt /tmp/sha1sum-tag.txt
podman run --volume $(pwd):${samba_repo_root} --workdir ${samba_repo_root} ${ci_image_name} \
/bin/bash -c "echo \"${SAMBA_CI_CONTAINER_TAG}\" > /tmp/sha1sum-tag.txt; diff -u bootstrap/sha1sum.txt /tmp/sha1sum-tag.txt"
podman run --volume $(pwd):${samba_repo_root} --workdir ${samba_repo_root} ${ci_image_name} \
diff -u bootstrap/sha1sum.txt /sha1sum.txt
podman run --volume $(pwd):${samba_repo_root} --workdir ${samba_repo_root} ${ci_image_name} \
bootstrap/template.py --sha1sum > /tmp/sha1sum-template.txt
diff -u bootstrap/sha1sum.txt /tmp/sha1sum-template.txt
# run smoke test with samba-o3 or samba-fuzz
podman run --volume $(pwd):/src:ro ${ci_image_name} \
/bin/bash -c "git config --global --add safe.directory /src/.git && git clone /src samba && cd samba && export PKG_CONFIG_PATH=/usr/lib64/compat-gnutls34/pkgconfig:/usr/lib64/compat-nettle32/pkgconfig && script/autobuild.py ${SAMBA_CI_TEST_JOB} --verbose --nocleanup --keeplogs --tail --testbase /tmp/samba-testbase"
podman tag ${ci_image_name} ${ci_image_path}:${SAMBA_CI_CONTAINER_TAG}
podman tag ${ci_image_name} ${ci_image_path}:${timestamp_tag}
# We build all images, but only upload is it's not marked as broken
test x"${SAMBA_CI_IS_BROKEN_IMAGE}" = x"yes" || { \
podman login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY; \
podman push ${ci_image_path}:${SAMBA_CI_CONTAINER_TAG}; \
podman push ${ci_image_path}:${timestamp_tag}; \
}
echo "Success for ${ci_image_path}:${timestamp_tag}"
test x"${SAMBA_CI_IS_BROKEN_IMAGE}" = x"no" || { \
echo "The image ${CI_JOB_NAME} is marked as broken and should have failed!"; \
echo "Replace .build_image_template_force_broken with .build_image_template!"; \
echo "Add a .samba-o3-template section at the end of the main .gitlab-ci.yml!"; \
/bin/false; \
}
only:
variables:
#
# You need a custom pipeline which passes
# SAMBA_CI_REBUILD_IMAGES="yes".
#
# https://gitlab.com/samba-team/devel/samba/pipelines/new
#
- $SAMBA_CI_REBUILD_IMAGES == "yes"
.build_image_template_force_broken:
extends: .build_image_template
variables:
SAMBA_CI_IS_BROKEN_IMAGE: "yes"
only:
variables:
#
# You need a custom pipeline which passes
# SAMBA_CI_REBUILD_BROKEN_IMAGES="yes"
# in order to build broken images for debugging
#
# https://gitlab.com/samba-team/devel/samba/pipelines/new
#
- $SAMBA_CI_REBUILD_BROKEN_IMAGES == "yes"
ubuntu2204:
extends: .build_image_template
ubuntu2404:
extends: .build_image_template
ubuntu2604:
extends: .build_image_template
debian11:
extends: .build_image_template
debian12:
extends: .build_image_template
fedora43:
extends: .build_image_template
debian11-32bit:
extends: .build_image_template
variables:
SAMBA_CI_TEST_JOB: "samba-32bit"
SAMBA_CI_PLATFORM: "linux/i386"
rocky8:
extends: .build_image_template
centos9s:
extends: .build_image_template
opensuse160:
extends: .build_image_template
+87
View File
@@ -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
+48
View File
@@ -0,0 +1,48 @@
stages:
- deploy
- test
variables:
DEPLOY: "false"
SKIP_TEST: "true"
# GL042: both if: conditions always evaluate to false given the declared variables.
always-dead-deploy:
stage: deploy
rules:
- if: '$DEPLOY == "true"'
when: on_success
script: echo deploy
# GL042: if: is false, explicit when:on_success rule — still never fires.
always-dead-test:
stage: test
rules:
- if: '$SKIP_TEST == "false"'
when: on_success
script: echo test
# Not GL042: references a predefined CI_ variable → unknown value → skip check.
predefined-var-job:
stage: test
rules:
- if: '$CI_COMMIT_BRANCH == "nonexistent-branch"'
when: on_success
script: echo branch
# Not GL042: has an unconditional rule (no if: → always matches).
unconditional-job:
stage: test
rules:
- if: '$DEPLOY == "true"'
when: never
- when: on_success
script: echo always
# Not GL042: references undeclared variable → unknown → conservative.
undeclared-var-job:
stage: test
rules:
- if: '$UNDECLARED_VAR == "something"'
when: on_success
script: echo undeclared
+51
View File
@@ -0,0 +1,51 @@
---
# variable_refs.yml
# Verifies that GL032 does not fire for variables that are declared or predefined.
# Covers: pipeline variables:, job variables:, workflow:rules:variables:, and CI_* prefixes.
# Expected: exits 0 (no errors; no GL032 warnings).
stages:
- build
- deploy
variables:
DEPLOY_TARGET: "staging"
FEATURE_FLAG: "false"
workflow:
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
variables:
DEPLOY_TARGET: "production"
- when: always
build:
stage: build
script: make build
rules:
# pipeline-level variable — GL032 must not fire
- if: '$DEPLOY_TARGET == "staging"'
when: on_success
# predefined CI variable — GL032 must not fire
- if: '$CI_COMMIT_BRANCH =~ /^feat\/.+/'
when: manual
deploy:
stage: deploy
script: make deploy
variables:
DEPLOY_REGION: "us-east-1"
rules:
# pipeline-level variable — no warning
- if: '$FEATURE_FLAG == "true"'
when: never
# workflow-rule-injected variable — no warning
- if: '$DEPLOY_TARGET == "production"'
when: on_success
# job-level variable — no warning
- if: '$DEPLOY_REGION == "us-east-1"'
when: on_success
# predefined GITLAB_ variable — no warning
- if: '$GITLAB_USER_LOGIN == "bot"'
when: never
- when: manual
+25
View File
@@ -0,0 +1,25 @@
---
# variable_refs_included.yml
# GL032 must NOT fire for variables declared in an included file.
# The included file (variable_refs_included_template.yml) declares TEMPLATE_VAR.
# Expected: exits 0 (no errors; no GL032 warnings).
stages:
- build
include:
- local: /variable_refs_included_template.yml
build:
stage: build
script: make build
rules:
# TEMPLATE_VAR comes from the included file — GL032 must not fire.
- if: '$TEMPLATE_VAR == "enabled"'
when: on_success
# ROOT_VAR is declared in this file — GL032 must not fire.
- if: '$ROOT_VAR == "yes"'
when: manual
variables:
ROOT_VAR: "yes"
+6
View File
@@ -0,0 +1,6 @@
---
# Included by variable_refs_included.yml — declares a pipeline-level variable
# that the parent pipeline's jobs reference in rules:if: expressions.
variables:
TEMPLATE_VAR: "enabled"
+57
View File
@@ -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
+57
View File
@@ -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