Compare commits

..

3 Commits

Author SHA1 Message Date
k3nny 0e51844c3a feat(graph): pipeline graph improvements — on_failure, tooltips, bus-bar connectors, skipped colouring, HTML and Mermaid output
ci / vet, staticcheck, test, build (push) Failing after 1m58s
release / Build and publish release (push) Successful in 1m10s
- when: on_failure visual distinction: red circle (#d9534f), X-mark icon, dashed
  chip border, and Mermaid classDef; legend entry added
- Job tooltip / detail panel: each chip wrapped in <g data-job="…"><title>…</title>
  <desc>…</desc> so SVG viewers and the HTML sidebar show stage, when, image, needs
- Multi-job connector accuracy: classic mode now uses a bus-bar pattern (vertical
  rail at midpoint + per-job stubs) instead of one center-to-center line per stage pair
- Blocked/skipped state colouring: RenderPipeline and pipelineSVG accept
  *cicontext.Context; skipped jobs rendered in grey (#868686) with dimmed text
- Interactive HTML output (--format html): self-contained .html with inline SVG,
  mouse-wheel zoom, drag-to-pan, double-click reset, and a click-to-open sidebar
- Mermaid pipeline output (--format mermaid): prints existing Pipeline() flowchart
  to stdout; suitable for mermaid.live or Markdown embedding
- imageString() helper to extract image name from string or map form
- 100% statement coverage maintained; 809 tests pass

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 00:56:06 +02:00
k3nny 8dc30d9207 feat(linter): rules:needs: validation (GL044)
ci / vet, staticcheck, test, build (push) Failing after 2m4s
release / Build and publish release (push) Successful in 1m12s
Add GL044 to validate jobs listed in rules:needs: overrides (GitLab CI
16.4+). rules:needs: lets a specific rule override the job's top-level
needs: list; any referenced job must exist in the pipeline. Unknown jobs
produce an error; optional: true entries produce a warning, matching the
GL027 behaviour for top-level needs:. Cross-pipeline needs and skipped
jobs (when a context is active) are excluded from checking.

Implementation:
- model.Rule gains a Needs []any field (yaml:"needs")
- checkRulesNeeds(p, skipped) added to needs.go; wired into Lint
- GL044 / RuleRulesNeedsUnknown added to rules.go and explain.go
- 6 unit tests + 2 testdata fixtures; task validate updated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 00:33:52 +02:00
k3nny 416659ffd8 feat(linter): context-scoped needs:/dependencies: cross-checks
ci / vet, staticcheck, test, build (push) Failing after 1m54s
release / Build and publish release (push) Successful in 1m6s
When glint check runs in single-context mode (--branch, --tag, --source,
or --var), jobs that resolve to JobSkipped against that context are now
excluded from needs: and dependencies: cross-job checks (GL027-GL031).
This eliminates false-positive errors for jobs intentionally gated to
specific pipeline events (e.g. a deploy job with rules:if: CI_COMMIT_TAG
no longer triggers GL027 on branch pipelines).

linter.Lint now accepts a skipped map[string]bool (nil = check all jobs);
checkNeeds and checkDependencies skip jobs present in the map. Multi-context
mode (--context) passes nil, so all jobs are checked regardless of context.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 00:25:09 +02:00
23 changed files with 1106 additions and 118 deletions
+32
View File
@@ -5,6 +5,38 @@ 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.25] - 2026-06-25
### Added
- **`when: on_failure` visual distinction** — SVG and HTML pipeline graphs now render failure-path jobs with a red status circle (`#d9534f`), an X-mark icon, and a dashed chip border. A new `on_failure` legend entry and Mermaid `classDef` entry are included.
- **Job tooltip / detail panel** — every job chip in SVG and HTML output is wrapped in a `<g data-job="…"><title>…</title><desc>…</desc>` group. SVG viewers that support `<title>` show the job name on hover; `<desc>` carries `stage`, `when`, `image`, and `needs` details. In HTML output the sidebar reads these directly from the DOM.
- **Multi-job connector accuracy** — classic mode (no `needs:`) now uses a bus-bar connector: horizontal stubs from every job in stage N to a vertical rail at the midpoint, then stubs from the rail to every job in stage N+1. The previous single center-to-center line left top and bottom jobs visually disconnected.
- **Skipped / blocked state colouring** — `glint graph pipeline` now accepts the same context flags as `glint check` (`--branch`, `--tag`, `--source`, `--var`, `--changes`, `--changes-from`). Jobs that evaluate to `JobSkipped` in the given context are rendered with a grey circle (`#868686`) and dimmed job name. When no context flags are given, all jobs render with their normal colours.
- **Interactive HTML output** (`--format html`) — `glint graph pipeline --format html` writes a self-contained `.html` file to `--out`. The page embeds the SVG inline with mouse-wheel zoom (centred on cursor), drag-to-pan, double-click-to-reset, and a collapsible sidebar showing job details on chip click. No external dependencies; works offline.
- **Mermaid pipeline output** (`--format mermaid`) — `glint graph pipeline --format mermaid` prints the existing Mermaid flowchart (`pipeline.go`) to stdout. Suitable for pasting into [mermaid.live](https://mermaid.live) or embedding in Markdown documentation.
- **`imageString` helper** — internal utility that extracts the image name from a `Job.Image` field that may be a plain string or a map (`image: {name: ..., entrypoint: ...}`); used by the tooltip desc builder.
## [0.2.24] - 2026-06-25
### Added
- **`rules:needs:` validation (GL044)** — validates jobs listed in `rules:needs:` overrides (GitLab CI 16.4+). Each entry must reference a job that exists in the pipeline; `optional: true` entries that reference missing jobs are downgraded to warnings (same behaviour as GL027 for top-level `needs:`). Cross-pipeline needs (maps with a `pipeline:` key) are ignored. Skipped jobs are excluded when a context is provided. `glint explain GL044` documents the rule with a bad-YAML example and fix.
- **`Rule.Needs` model field** — `rules:` entries now parse their `needs:` key into `model.Rule.Needs []any`, making the per-rule needs list available for validation and future evaluation.
## [0.2.23] - 2026-06-25
### Added
- **Context-scoped linting** — when a context is supplied via `--branch`, `--tag`, `--source`, or `--var`, `glint check` now evaluates every job against that context before running lint rules. Jobs that are statically unreachable in the given context (i.e. their `rules:` block resolves to `JobSkipped`) are excluded from `needs:` and `dependencies:` cross-job checks (GL027GL031). This eliminates false-positive errors for jobs that are intentionally gated to specific pipeline events (e.g. a deploy job with `rules: [{if: '$CI_COMMIT_TAG != ""'}]` does not generate a GL027 error on branch pipelines). Filtering applies only in single-context mode; multi-context mode (`--context`) always checks all jobs.
## [0.2.22] - 2026-06-25 ## [0.2.22] - 2026-06-25
### Added ### Added
+34 -1
View File
@@ -69,6 +69,7 @@ description, bad-YAML example, and fix.
| GL029 | ERR | Circular dependency in `needs:` graph | | GL029 | ERR | Circular dependency in `needs:` graph |
| GL030 | ERR | `dependencies:` references a job that doesn't exist | | GL030 | ERR | `dependencies:` references a job that doesn't exist |
| GL031 | ERR | `dependencies:` references a job in the same or a later stage | | GL031 | ERR | `dependencies:` references a job in the same or a later stage |
| GL044 | ERR/WARN | `rules:needs:` references a job that doesn't exist (WARN when `optional: true`) |
### Expression & reachability ### Expression & reachability
@@ -151,6 +152,17 @@ Use `--list-vars` to print the resolved variable table to stderr.
| `--changes PATH` | Mark PATH as changed; repeatable | | `--changes PATH` | Mark PATH as changed; repeatable |
| `--changes-from REF` | Run `git diff --name-only REF` to auto-detect changed files | | `--changes-from REF` | Run `git diff --name-only REF` to auto-detect changed files |
**Context-scoped linting** (single-context mode):
When a context is given, jobs evaluated as `skipped` are excluded from `needs:` and `dependencies:` cross-job checks (GL027GL031). This eliminates false positives for jobs that are intentionally gated to specific pipeline events:
```yaml
deploy-job:
needs: [build-job] # GL027 suppressed on branch pipelines; only checked on tag pipelines
rules:
- if: '$CI_COMMIT_TAG != ""'
```
**Multi-context comparison** (`--context`): **Multi-context comparison** (`--context`):
Pass `--context KEY=VALUE[,...]` (repeatable) instead of `--branch`/`--tag`/`--source` to evaluate every job across multiple contexts simultaneously. Each `--context` flag defines one column; glint prints a table showing `active`, `manual`, `skipped`, or `blocked` per job per context. Pass `--context KEY=VALUE[,...]` (repeatable) instead of `--branch`/`--tag`/`--source` to evaluate every job across multiple contexts simultaneously. Each `--context` flag defines one column; glint prints a table showing `active`, `manual`, `skipped`, or `blocked` per job per context.
@@ -270,8 +282,29 @@ jobs or pipeline-level findings.
| `pipeline` | GitLab CI-style SVG/PNG written to `--out` directory (default: `glint-out/`); converted to PNG when `rsvg-convert`, `inkscape`, or `magick` is available | | `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 | | `all` | `includes` to stdout + `pipeline` file path to stderr |
**`glint graph pipeline --format <FORMAT>`**
| Format | Effect |
|--------|--------|
| `svg` (default) | Write GitLab CI-style SVG/PNG to `--out` |
| `mermaid` | Print Mermaid flowchart to stdout (paste into [mermaid.live](https://mermaid.live)) |
| `html` | Write self-contained HTML to `--out` with mouse pan/zoom and a click-to-open job-detail sidebar |
**Visual distinctions in SVG and HTML output:**
- **Regular** — blue circle with checkmark
- **Manual** — orange circle with play triangle
- **Trigger** — purple circle with chevron
- **Delayed** — yellow circle with clock
- **`when: on_failure`** — red circle (`#d9534f`) with X mark; dashed chip border
- **Skipped** (with `--branch`/`--tag`/`--source` context flags) — grey circle, dimmed job name
In DAG pipelines (any job has `needs:`) the pipeline graph uses job-to-job In DAG pipelines (any job has `needs:`) the pipeline graph uses job-to-job
Bézier connectors instead of stage-to-stage lines. Bézier connectors. In classic mode a bus-bar pattern (vertical rail + per-job
stubs) accurately connects every job across adjacent stages.
Each chip carries a `<title>` and `<desc>` with stage, when, image, and needs —
shown as a tooltip in SVG viewers and as a sidebar panel in HTML output.
--- ---
+2 -2
View File
@@ -6,7 +6,7 @@
<p align="center"> <p align="center">
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-blue.svg" alt="License"></a> <a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-blue.svg" alt="License"></a>
<a href="CHANGELOG.md"><img src="https://img.shields.io/badge/release-v0.2.22-blue.svg" alt="Release"></a> <a href="CHANGELOG.md"><img src="https://img.shields.io/badge/release-v0.2.25-blue.svg" alt="Release"></a>
</p> </p>
> **Disclaimer:** This tool was built through iterative AI-assisted development with [Claude](https://claude.ai). It is experimental, incomplete, and not intended for production use. Coverage of GitLab CI keywords is best-effort and may lag behind GitLab's evolving spec. Use it at your own discretion — no correctness guarantees are made. Contributions and bug reports are welcome. > **Disclaimer:** This tool was built through iterative AI-assisted development with [Claude](https://claude.ai). It is experimental, incomplete, and not intended for production use. Coverage of GitLab CI keywords is best-effort and may lag behind GitLab's evolving spec. Use it at your own discretion — no correctness guarantees are made. Contributions and bug reports are welcome.
@@ -20,7 +20,7 @@ A local tool to validate and lint `.gitlab-ci.yml` pipelines without needing a G
- **Simulates context** — `--branch`, `--tag`, `--source` flags evaluate `rules:if:` and `only`/`except` to show which jobs would be active, manual, or skipped; `--context branch=main --context branch=develop` prints a multi-column comparison table across multiple contexts in one run - **Simulates context** — `--branch`, `--tag`, `--source` flags evaluate `rules:if:` and `only`/`except` to show which jobs would be active, manual, or skipped; `--context branch=main --context branch=develop` prints a multi-column comparison table across multiple contexts in one run
- **Multiple output formats** — `--format text` (default, ruff-style), `json`, `sarif` (GitHub Code Scanning / GitLab SAST), `junit`, `github` (PR annotations) - **Multiple output formats** — `--format text` (default, ruff-style), `json`, `sarif` (GitHub Code Scanning / GitLab SAST), `junit`, `github` (PR annotations)
- **Project config** — `.glint.yml` for rule suppression, severity overrides, token/URL defaults; `# glint: ignore RULE` for per-job inline suppression - **Project config** — `.glint.yml` for rule suppression, severity overrides, token/URL defaults; `# glint: ignore RULE` for per-job inline suppression
- **Graph visualization** — `glint graph` prints a terminal job tree; `glint graph pipeline` renders a GitLab CI-style SVG/PNG - **Graph visualization** — `glint graph` prints a terminal job tree; `glint graph pipeline` renders a GitLab CI-style SVG/PNG; `--format mermaid` emits a Mermaid flowchart; `--format html` produces a self-contained HTML file with pan/zoom and a job-detail sidebar; context flags grey out skipped jobs
See [FEATURES.md](FEATURES.md) for the complete feature reference and lint rules table, and [ROADMAP.md](ROADMAP.md) for planned improvements. See [FEATURES.md](FEATURES.md) for the complete feature reference and lint rules table, and [ROADMAP.md](ROADMAP.md) for planned improvements.
+8 -8
View File
@@ -24,7 +24,7 @@ Pass `--branch`, `--tag`, `--source`, or `--var` to `glint check` or `glint grap
- ~~**Single `=` operator**~~ — ✓ shipped v0.2.14; bare `=` accepted as alias for `==` in `rules:if:` expressions - ~~**Single `=` operator**~~ — ✓ shipped v0.2.14; bare `=` accepted as alias for `==` in `rules:if:` expressions
- ~~**`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 - ~~**`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**~~ — ✓ shipped v0.2.22; `--context KEY=VALUE[,...]`; repeatable; prints a comparison table of `active`/`manual`/`skipped`/`blocked` per job across all contexts - ~~**Multi-context simulation**~~ — ✓ shipped v0.2.22; `--context KEY=VALUE[,...]`; repeatable; prints a comparison table of `active`/`manual`/`skipped`/`blocked` per job across all contexts
- **Context-scoped linting** — skip `needs:`/`dependencies:` cross-checks for jobs that are statically unreachable in the given context - ~~**Context-scoped linting**~~✓ shipped v0.2.23; jobs evaluated as `JobSkipped` in the supplied context are excluded from `needs:`/`dependencies:` cross-checks (GL027GL031) to eliminate false-positive errors for conditionally-gated jobs
--- ---
@@ -71,12 +71,12 @@ The SVG renderer and terminal tree cover the basic layout. These would bring it
- ~~**Terminal job tree**~~ — ✓ shipped in v0.2.0 as `glint graph tree`; stages as branches, jobs as leaves, context-aware annotations - ~~**Terminal job tree**~~ — ✓ shipped in v0.2.0 as `glint graph tree`; stages as branches, jobs as leaves, context-aware annotations
- ~~**`glint graph includes` shows jobs per file**~~ — ✓ shipped post-v0.2.0; each include node shows the jobs it defines as dashed-arrow rounded nodes in a distinct style - ~~**`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**~~✓ shipped v0.2.25; classic mode uses a bus-bar pattern (vertical rail at the midpoint + horizontal stubs per job) instead of a single center-to-center line, so 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**~~✓ shipped v0.2.25; each chip is wrapped in `<g data-job="…"><title>…</title><desc>…</desc>` SVG viewers show stage, when, image, and needs on hover; HTML output uses the data for the sidebar
- **`when: on_failure` visual distinction** — dashed border or distinct icon for failure-path jobs - ~~**`when: on_failure` visual distinction**~~ ✓ shipped v0.2.25; dashed chip border + X-mark icon + red circle (`#d9534f`); legend entry added; Mermaid `on_failure` class wired
- **Blocked / skipped state colouring** — grey out jobs that are statically unreachable given known `rules:` conditions - ~~**Blocked / skipped state colouring**~~✓ shipped v0.2.25; `glint graph pipeline` accepts context flags (`--branch`, `--tag`, etc.); jobs evaluated as skipped are greyed out (`#868686`) with dimmed text and no icon
- **Interactive HTML output** — self-contained `.html` file with pan/zoom and a job-detail sidebar; no external dependencies - ~~**Interactive HTML output**~~ ✓ shipped v0.2.25; `glint graph pipeline --format html` writes a self-contained `.html` file with mouse pan/zoom and a click-to-open job-detail sidebar; no external dependencies
- **Mermaid pipeline output** — keep `pipeline.go` but wire it up through `--graph pipeline --format mermaid` for users who want to paste into mermaid.live - ~~**Mermaid pipeline output**~~✓ shipped v0.2.25; `glint graph pipeline --format mermaid` prints a Mermaid flowchart to stdout (paste into mermaid.live)
--- ---
@@ -113,7 +113,7 @@ The SVG renderer and terminal tree cover the basic layout. These would bring it
## Reliability and developer experience ## Reliability and developer experience
- ~~**Structured rule IDs**~~ — ✓ shipped post-v0.2.0; GL001GL031 assigned; GL032 added v0.2.11; 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 - ~~**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; GL044 added v0.2.24; graph improvements shipped v0.2.25
- ~~**`glint explain <rule-id>`**~~ — ✓ shipped v0.2.20; prints rule description, rationale, bad-YAML example, and fix; `glint explain` (no arg) lists all rules - ~~**`glint explain <rule-id>`**~~ — ✓ shipped v0.2.20; prints rule description, rationale, bad-YAML example, and fix; `glint explain` (no arg) lists all rules
- ~~**Semantic versioning and first release**~~ — shipped as `v0.1.0` (2026-06-07) - ~~**Semantic versioning and first release**~~ — shipped as `v0.1.0` (2026-06-07)
- ~~**Subcommand CLI**~~ — shipped as `v0.2.0` (2026-06-11); `glint check` / `glint graph [mode]` with ruff-style `--help` - ~~**Subcommand CLI**~~ — shipped as `v0.2.0` (2026-06-11); `glint check` / `glint graph [mode]` with ruff-style `--help`
+6
View File
@@ -113,10 +113,16 @@ tasks:
ignore_error: false ignore_error: false
- cmd: ./{{.BINARY}} check testdata/inherit_dead_fields.yml - cmd: ./{{.BINARY}} check testdata/inherit_dead_fields.yml
ignore_error: false ignore_error: false
- cmd: ./{{.BINARY}} check testdata/rules_needs_valid.yml
ignore_error: false
- cmd: ./{{.BINARY}} check testdata/rules_needs_invalid.yml
ignore_error: true
- cmd: ./{{.BINARY}} explain GL007 - cmd: ./{{.BINARY}} explain GL007
ignore_error: false ignore_error: false
- cmd: ./{{.BINARY}} explain gl042 - cmd: ./{{.BINARY}} explain gl042
ignore_error: false ignore_error: false
- cmd: ./{{.BINARY}} explain GL044
ignore_error: false
- cmd: ./{{.BINARY}} explain - cmd: ./{{.BINARY}} explain
ignore_error: false ignore_error: false
+32 -1
View File
@@ -206,7 +206,16 @@ glint graph includes .gitlab-ci.yml > includes.mmd
glint graph pipeline .gitlab-ci.yml glint graph pipeline .gitlab-ci.yml
# prints the output path, e.g.: glint-out/pipeline-20260614-143022.png # prints the output path, e.g.: glint-out/pipeline-20260614-143022.png
# Mermaid to stdout + pipeline file path to stderr # Pipeline graph with skipped-job colouring (grey out context-unreachable jobs)
glint graph pipeline --branch main .gitlab-ci.yml
# Interactive HTML with pan/zoom and job-detail sidebar
glint graph pipeline --format html .gitlab-ci.yml
# Mermaid flowchart of the pipeline to stdout
glint graph pipeline --format mermaid .gitlab-ci.yml
# Mermaid includes graph to stdout + pipeline SVG file path to stderr
glint graph all .gitlab-ci.yml > includes.mmd glint graph all .gitlab-ci.yml > includes.mmd
# Custom output directory # Custom output directory
@@ -224,6 +233,28 @@ include type: orange (main file), purple (project), green (component), blue
**Pipeline graph** — GitLab CI-style SVG rendered to a timestamped file. **Pipeline graph** — GitLab CI-style SVG rendered to a timestamped file.
Converted to PNG when `rsvg-convert`, `inkscape`, or `magick` is available. Converted to PNG when `rsvg-convert`, `inkscape`, or `magick` is available.
**Pipeline graph formats** (`--format`):
| Flag | Output |
|------|--------|
| `svg` (default) | Write SVG/PNG to `--out` |
| `html` | Write self-contained HTML to `--out` — inline SVG with mouse pan/zoom, drag, and a job-detail sidebar that opens on chip click |
| `mermaid` | Print Mermaid flowchart to stdout |
**Visual chip styles:**
| Type | Colour | Icon | Border |
|------|--------|------|--------|
| regular | blue `#1f75cb` | checkmark | solid |
| manual | orange `#fc6d26` | play triangle | solid |
| trigger | purple `#6b4fbb` | chevron | solid |
| delayed | yellow `#fca326` | clock | solid |
| on_failure | red `#d9534f` | X mark | dashed |
| skipped (context) | grey `#868686` | none | solid |
Pass context flags (`--branch`, `--tag`, `--source`, `--var`) to grey out jobs
that would be skipped in the given pipeline event.
DAG mode (Bézier arrows between jobs) activates automatically when any job has DAG mode (Bézier arrows between jobs) activates automatically when any job has
a `needs:` list; classic mode uses stage-column connectors otherwise. a `needs:` list; classic mode uses stage-column connectors otherwise.
+47 -9
View File
@@ -341,6 +341,8 @@ Examples:
} }
} }
var skipped map[string]bool // jobs excluded from cross-job lint checks
if len(contexts) > 0 { if len(contexts) > 0 {
// Multi-context mode: build one context per --context flag, then print a comparison table. // Multi-context mode: build one context per --context flag, then print a comparison table.
ctxList := make([]*cicontext.Context, 0, len(contexts)) ctxList := make([]*cicontext.Context, 0, len(contexts))
@@ -368,6 +370,17 @@ Examples:
if !enrichContext(ctx, p) { if !enrichContext(ctx, p) {
fmt.Fprintf(os.Stderr, "%s: [warning] workflow:rules: pipeline would not start for this context\n", path) fmt.Fprintf(os.Stderr, "%s: [warning] workflow:rules: pipeline would not start for this context\n", path)
} }
// Build skipped set: jobs statically unreachable in this context are
// excluded from needs:/dependencies: cross-checks to avoid false positives.
skipped = make(map[string]bool)
for name, job := range p.Jobs {
if cicontext.EvalJob(job, ctx) == cicontext.JobSkipped {
skipped[name] = true
}
}
if len(skipped) == 0 {
skipped = nil
}
} }
if *listVars { if *listVars {
printVars(p, ctx) printVars(p, ctx)
@@ -379,7 +392,7 @@ Examples:
} }
} }
findings := linter.Lint(p) findings := linter.Lint(p, skipped)
findings = applyConfig(findings, glintCfg, p.Suppressions) findings = applyConfig(findings, glintCfg, p.Suppressions)
errCount, _ := countSeverities(findings) errCount, _ := countSeverities(findings)
@@ -432,7 +445,8 @@ func cmdGraph(args []string) {
gitlabURL := fs.String("gitlab-url", "", "GitLab instance URL (overrides CI_SERVER_URL / GITLAB_URL)") gitlabURL := fs.String("gitlab-url", "", "GitLab instance URL (overrides CI_SERVER_URL / GITLAB_URL)")
cacheDir := fs.String("cache-dir", "", "directory to cache fetched remote includes (created if needed)") 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") offline := fs.Bool("offline", false, "skip all network calls; serve only from --cache-dir")
out := fs.String("out", "glint-out", "output directory for Mermaid graph files (pipeline mode)") out := fs.String("out", "glint-out", "output directory for rendered graph files (pipeline mode)")
format := fs.String("format", "svg", "pipeline output format: svg, mermaid, or html")
fs.Usage = func() { fs.Usage = func() {
fmt.Fprintf(os.Stderr, "glint %s\n\n", version) fmt.Fprintf(os.Stderr, "glint %s\n\n", version)
fmt.Fprint(os.Stderr, `Visualise the pipeline as a job tree and/or Mermaid graph. fmt.Fprint(os.Stderr, `Visualise the pipeline as a job tree and/or Mermaid graph.
@@ -449,6 +463,15 @@ Options:
Output directory for rendered graph files. Output directory for rendered graph files.
Used by the pipeline and all modes only. [default: glint-out] Used by the pipeline and all modes only. [default: glint-out]
--format <FORMAT>
Output format for pipeline mode: svg (default), mermaid, or html.
svg: write a GitLab CI-style SVG/PNG to --out (converted to PNG
when rsvg-convert, inkscape, or magick is available).
mermaid: print a Mermaid flowchart to stdout (paste into mermaid.live).
html: write a self-contained HTML file with pan/zoom and a
job-detail sidebar to --out.
[default: svg] [possible values: svg, mermaid, html]
--token <TOKEN> --token <TOKEN>
GitLab personal access token. Used to fetch remote project: includes GitLab personal access token. Used to fetch remote project: includes
when building the include dependency graph. when building the include dependency graph.
@@ -505,6 +528,8 @@ Examples:
glint graph includes .gitlab-ci.yml > includes.mmd glint graph includes .gitlab-ci.yml > includes.mmd
glint graph pipeline .gitlab-ci.yml glint graph pipeline .gitlab-ci.yml
glint graph pipeline --out /tmp/graphs .gitlab-ci.yml glint graph pipeline --out /tmp/graphs .gitlab-ci.yml
glint graph pipeline --format mermaid .gitlab-ci.yml
glint graph pipeline --format html .gitlab-ci.yml
glint graph all .gitlab-ci.yml > includes.mmd glint graph all .gitlab-ci.yml > includes.mmd
`) `)
} }
@@ -589,16 +614,29 @@ Examples:
case "includes": case "includes":
fmt.Print(graph.Includes(path, p.Include, cfg)) fmt.Print(graph.Includes(path, p.Include, cfg))
case "pipeline": case "pipeline":
outPath, err := graph.RenderPipeline(p, *out) switch *format {
if err != nil { case "mermaid":
fmt.Fprintf(os.Stderr, "error: rendering pipeline graph: %v\n", err) fmt.Print(graph.Pipeline(p))
exit(2) case "html":
return outPath, err := graph.RenderHTML(p, *out, ctx)
if err != nil {
fmt.Fprintf(os.Stderr, "error: rendering pipeline graph: %v\n", err)
exit(2)
return
}
fmt.Println(outPath)
default: // "svg"
outPath, err := graph.RenderPipeline(p, *out, ctx)
if err != nil {
fmt.Fprintf(os.Stderr, "error: rendering pipeline graph: %v\n", err)
exit(2)
return
}
fmt.Println(outPath)
} }
fmt.Println(outPath)
case "all": case "all":
fmt.Print(graph.Includes(path, p.Include, cfg)) fmt.Print(graph.Includes(path, p.Include, cfg))
outPath, err := graph.RenderPipeline(p, *out) outPath, err := graph.RenderPipeline(p, *out, ctx)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "error: rendering pipeline graph: %v\n", err) fmt.Fprintf(os.Stderr, "error: rendering pipeline graph: %v\n", err)
exit(2) exit(2)
+65
View File
@@ -823,6 +823,71 @@ build-job:
} }
} }
// ── context-scoped linting ───────────────────────────────────────────────────
func TestCmdCheck_ContextScopedLinting_SuppressesSkippedJob(t *testing.T) {
// deploy-job has needs: [nonexistent] but is gated to tag pipelines only.
// With --branch main the job is skipped → GL027 should be suppressed.
content := `
stages: [build, deploy]
build-job:
stage: build
script: make
deploy-job:
stage: deploy
script: make deploy
needs: [nonexistent-job]
rules:
- if: '$CI_COMMIT_TAG != ""'
when: on_success
- when: never
`
path := writePipeline(t, content)
// Without context (permissive default branch=main): deploy-job IS skipped
// by rules evaluation → needs cross-check suppressed → exit 0.
code := captureExit(t)
cmdCheck([]string{"--branch", "main", path})
if *code == 1 {
t.Error("skipped job's needs: error should be suppressed in context-scoped lint")
}
}
func TestCmdCheck_ContextScopedLinting_ActiveJobStillErrors(t *testing.T) {
// Same pipeline but with --tag set: deploy-job is active → GL027 fires.
content := `
stages: [build, deploy]
build-job:
stage: build
script: make
deploy-job:
stage: deploy
script: make deploy
needs: [nonexistent-job]
rules:
- if: '$CI_COMMIT_TAG != ""'
when: on_success
- when: never
`
path := writePipeline(t, content)
code := captureExit(t)
cmdCheck([]string{"--tag", "v1.0.0", path})
if *code != 1 {
t.Error("active job's bad needs: should still produce GL027 error")
}
}
func TestCmdCheck_ContextScopedLinting_SkippedSet_IsNilWhenAllActive(t *testing.T) {
// All jobs active → skipped set is nil → no regression in normal behaviour.
code := captureExit(t)
path := writePipeline(t, minimalPipeline)
cmdCheck([]string{"--branch", "main", path})
if *code == 1 {
t.Error("valid pipeline with all-active jobs should not produce errors")
}
}
// ── parseContextSpec ───────────────────────────────────────────────────────── // ── parseContextSpec ─────────────────────────────────────────────────────────
func TestParseContextSpec(t *testing.T) { func TestParseContextSpec(t *testing.T) {
+3
View File
@@ -27,6 +27,7 @@ func Pipeline(p *model.Pipeline) string {
w(" classDef manual fill:#fc6d26,stroke:#e56b1f,color:#fff") w(" classDef manual fill:#fc6d26,stroke:#e56b1f,color:#fff")
w(" classDef trigger fill:#6b4fbb,stroke:#5a3fa0,color:#fff") w(" classDef trigger fill:#6b4fbb,stroke:#5a3fa0,color:#fff")
w(" classDef delayed fill:#fca326,stroke:#d98a1e,color:#333") w(" classDef delayed fill:#fca326,stroke:#d98a1e,color:#333")
w(" classDef on_failure fill:#d9534f,stroke:#c0392b,color:#fff")
w("") w("")
// Collect visible (non-template) job names; sort for stable output. // Collect visible (non-template) job names; sort for stable output.
@@ -149,6 +150,8 @@ func jobClass(job model.Job) string {
return "manual" return "manual"
case "delayed": case "delayed":
return "delayed" return "delayed"
case "on_failure":
return "on_failure"
} }
return "regular" return "regular"
} }
+13
View File
@@ -105,10 +105,23 @@ func TestSanitizeID(t *testing.T) {
func TestJobClass(t *testing.T) { func TestJobClass(t *testing.T) {
if jobClass(model.Job{When: "manual"}) != "manual" { t.Error("manual") } if jobClass(model.Job{When: "manual"}) != "manual" { t.Error("manual") }
if jobClass(model.Job{When: "delayed"}) != "delayed" { t.Error("delayed") } if jobClass(model.Job{When: "delayed"}) != "delayed" { t.Error("delayed") }
if jobClass(model.Job{When: "on_failure"}) != "on_failure" { t.Error("on_failure") }
if jobClass(model.Job{Trigger: "x"}) != "trigger" { t.Error("trigger") } if jobClass(model.Job{Trigger: "x"}) != "trigger" { t.Error("trigger") }
if jobClass(model.Job{}) != "regular" { t.Error("regular") } if jobClass(model.Job{}) != "regular" { t.Error("regular") }
} }
func TestPipeline_OnFailureClass(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"cleanup"},
Jobs: map[string]model.Job{
"cleanup-job": {Name: "cleanup-job", Stage: "cleanup", When: "on_failure"},
},
}
out := Pipeline(p)
if !strings.Contains(out, "on_failure") { t.Error("expected on_failure class") }
if !strings.Contains(out, "#d9534f") { t.Error("expected on_failure color in classDef") }
}
// ── needsJobName ────────────────────────────────────────────────────────────── // ── needsJobName ──────────────────────────────────────────────────────────────
func TestNeedsJobName(t *testing.T) { func TestNeedsJobName(t *testing.T) {
+348 -54
View File
@@ -10,34 +10,35 @@ import (
"strings" "strings"
"time" "time"
"git.k3nny.fr/glint/internal/cicontext"
"git.k3nny.fr/glint/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.
const ( const (
chipW = 178 // job chip width chipW = 178 // job chip width
chipH = 34 // job chip height chipH = 34 // job chip height
chipGap = 6 // vertical gap between chips in a column chipGap = 6 // vertical gap between chips in a column
iconCX = 14 // status circle: x offset from chip left edge to circle centre iconCX = 14 // status circle: x offset from chip left edge to circle centre
iconR = 7 // status circle radius (14 px diameter) iconR = 7 // status circle radius (14 px diameter)
textLeft = 27 // job name text: x offset from chip left edge textLeft = 27 // job name text: x offset from chip left edge
labelH = 30 // stage-name label area height (text + bottom gap) labelH = 30 // stage-name label area height (text + bottom gap)
topPad = 40 // outer top padding topPad = 40 // outer top padding
sidePad = 40 // outer left / right padding sidePad = 40 // outer left / right padding
stageGap = 50 // horizontal gap between stage columns (connector space) stageGap = 50 // horizontal gap between stage columns (connector space)
botPad = 48 // outer bottom padding (legend lives here) botPad = 48 // outer bottom padding (legend lives here)
) )
type svgPt struct{ x, y int } type svgPt struct{ x, y int }
// RenderPipeline writes a PNG (or SVG fallback) file with a GitLab CI-style // RenderPipeline writes a PNG (or SVG fallback) file with a GitLab CI-style
// pipeline layout and returns the path to the generated file. // pipeline layout and returns the path to the generated file.
func RenderPipeline(p *model.Pipeline, outDir string) (string, error) { func RenderPipeline(p *model.Pipeline, outDir string, ctx *cicontext.Context) (string, error) {
if err := os.MkdirAll(outDir, 0o755); err != nil { if err := os.MkdirAll(outDir, 0o755); err != nil {
return "", fmt.Errorf("creating output directory %s: %w", outDir, err) return "", fmt.Errorf("creating output directory %s: %w", outDir, err)
} }
svg := pipelineSVG(p) svg := pipelineSVG(p, ctx)
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")
@@ -53,6 +54,24 @@ func RenderPipeline(p *model.Pipeline, outDir string) (string, error) {
return svgPath, nil return svgPath, nil
} }
// RenderHTML writes a self-contained HTML file with the pipeline graph embedded
// inline with pan/zoom and a job-detail sidebar, then returns the output path.
func RenderHTML(p *model.Pipeline, outDir string, ctx *cicontext.Context) (string, error) {
if err := os.MkdirAll(outDir, 0o755); err != nil {
return "", fmt.Errorf("creating output directory %s: %w", outDir, err)
}
svg := pipelineSVG(p, ctx)
html := htmlPage(svg)
ts := time.Now().Format("20060102-150405")
htmlPath := filepath.Join(outDir, "pipeline-"+ts+".html")
if err := os.WriteFile(htmlPath, []byte(html), 0o644); err != nil {
return "", fmt.Errorf("writing HTML: %w", err)
}
return htmlPath, nil
}
// 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 {
candidates := [][]string{ candidates := [][]string{
@@ -75,7 +94,7 @@ func convertToPNG(svgPath, pngPath string) bool {
return false return false
} }
func pipelineSVG(p *model.Pipeline) string { func pipelineSVG(p *model.Pipeline, ctx *cicontext.Context) 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 {
@@ -89,6 +108,16 @@ func pipelineSVG(p *model.Pipeline) string {
return svgEmpty() return svgEmpty()
} }
// Precompute which jobs are skipped in the given context.
skippedJobs := make(map[string]bool)
if ctx != nil {
for _, name := range visible {
if cicontext.EvalJob(p.Jobs[name], ctx) == cicontext.JobSkipped {
skippedJobs[name] = true
}
}
}
// 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.
byStage := make(map[string][]string) byStage := make(map[string][]string)
for _, name := range visible { for _, name := range visible {
@@ -120,8 +149,6 @@ func pipelineSVG(p *model.Pipeline) string {
} }
// Compute per-stage column heights and job anchor points for connectors. // Compute per-stage column heights and job anchor points for connectors.
colH := make([]int, len(stages)) // height of the chip stack (no label)
colCtY := make([]int, len(stages)) // y centre of the chip stack
maxColH := 0 maxColH := 0
rightMid := make(map[string]svgPt) rightMid := make(map[string]svgPt)
leftMid := make(map[string]svgPt) leftMid := make(map[string]svgPt)
@@ -131,7 +158,6 @@ func pipelineSVG(p *model.Pipeline) string {
sort.Strings(jobs) sort.Strings(jobs)
n := len(jobs) n := len(jobs)
h := n*chipH + max(0, n-1)*chipGap h := n*chipH + max(0, n-1)*chipGap
colH[i] = h
if h > maxColH { if h > maxColH {
maxColH = h maxColH = h
} }
@@ -141,7 +167,6 @@ func pipelineSVG(p *model.Pipeline) string {
rightMid[name] = svgPt{cx + chipW, chy + chipH/2} rightMid[name] = svgPt{cx + chipW, chy + chipH/2}
leftMid[name] = svgPt{cx, chy + chipH/2} leftMid[name] = svgPt{cx, chy + chipH/2}
} }
colCtY[i] = topPad + labelH + h/2
} }
svgW := sidePad*2 + len(stages)*chipW + max(0, len(stages)-1)*stageGap svgW := sidePad*2 + len(stages)*chipW + max(0, len(stages)-1)*stageGap
@@ -190,29 +215,73 @@ func pipelineSVG(p *model.Pipeline) string {
wf(` <line x1="%d" y1="%d" x2="%d" y2="%d" stroke="#eaeaea" stroke-width="1"/>`, wf(` <line x1="%d" y1="%d" x2="%d" y2="%d" stroke="#eaeaea" stroke-width="1"/>`,
cx, topPad+21, cx+chipW, topPad+21) cx, topPad+21, cx+chipW, topPad+21)
// Job chips. // Job chips each wrapped in a <g> with tooltip metadata.
for j, name := range jobs { for j, name := range jobs {
job := p.Jobs[name] job := p.Jobs[name]
chy := topPad + labelH + j*(chipH+chipGap) chy := topPad + labelH + j*(chipH+chipGap)
isSkipped := skippedJobs[name]
// Build <desc> content: shown in the HTML sidebar and SVG viewer tooltips.
desc := "stage: " + stage
if job.When != "" {
desc += "\nwhen: " + job.When
}
if img := imageString(job.Image); img != "" {
desc += "\nimage: " + img
}
var needNames []string
for _, n := range job.Needs {
if s := needsJobName(n); s != "" {
needNames = append(needNames, s)
}
}
if len(needNames) > 0 {
desc += "\nneeds: " + strings.Join(needNames, ", ")
}
if isSkipped {
desc += "\nstate: skipped"
}
// data-job attribute enables JS click detection in the HTML output.
wf(` <g data-job="%s">`, svgEsc(name))
wf(` <title>%s</title>`, svgEsc(name))
wf(` <desc>%s</desc>`, svgEsc(desc))
color := chipColor(job) color := chipColor(job)
if isSkipped {
color = "#868686"
}
// Chip card (white, rounded, subtle border + shadow). // Chip card (white, rounded, subtle border + shadow).
wf(` <rect x="%d" y="%d" width="%d" height="%d" rx="4" `+ // on_failure jobs get a dashed border to signal the failure path.
`fill="#ffffff" stroke="#dde1e7" stroke-width="1" filter="url(#chip-shadow)"/>`, dashAttr := ""
cx, chy, chipW, chipH) if !isSkipped && job.When == "on_failure" {
dashAttr = ` stroke-dasharray="4,3"`
}
wf(` <rect x="%d" y="%d" width="%d" height="%d" rx="4" `+
`fill="#ffffff" stroke="#dde1e7" stroke-width="1"%s filter="url(#chip-shadow)"/>`,
cx, chy, chipW, chipH, dashAttr)
// Colored status indicator circle. // Colored status indicator circle.
wf(` <circle cx="%d" cy="%d" r="%d" fill="%s"/>`, wf(` <circle cx="%d" cy="%d" r="%d" fill="%s"/>`,
cx+iconCX, chy+chipH/2, iconR, color) cx+iconCX, chy+chipH/2, iconR, color)
// Icon symbol inside the circle. // Icon inside the circle (omitted for skipped — grey circle speaks for itself).
drawChipIcon(&sb, job, cx+iconCX, chy+chipH/2) if !isSkipped {
drawChipIcon(&sb, job, cx+iconCX, chy+chipH/2)
}
// Job name text. // Job name text — dimmed for skipped jobs.
wf(` <text x="%d" y="%d" dominant-baseline="middle" `+ textColor := "#303030"
if isSkipped {
textColor = "#868686"
}
wf(` <text x="%d" y="%d" dominant-baseline="middle" `+
`font-family="'GitLab Sans','Segoe UI',-apple-system,BlinkMacSystemFont,sans-serif" `+ `font-family="'GitLab Sans','Segoe UI',-apple-system,BlinkMacSystemFont,sans-serif" `+
`font-size="13" fill="#303030">%s</text>`, `font-size="13" fill="%s">%s</text>`,
cx+textLeft, chy+chipH/2, svgEsc(svgTrunc(name, 20))) cx+textLeft, chy+chipH/2, textColor, svgEsc(svgTrunc(name, 20)))
wf(` </g>`)
} }
} }
@@ -241,27 +310,54 @@ func pipelineSVG(p *model.Pipeline) string {
} }
} }
} else { } else {
// Classic: one connector per adjacent stage pair. // Classic: bus-bar connectors every job in stage[i] fans to a vertical
// bus at the midpoint column gap, then fans out to every job in stage[i+1].
// This gives each job pair its own visual connection instead of a single
// center-to-center line that misses jobs at the top and bottom of columns.
for i := 0; i < len(stages)-1; i++ { for i := 0; i < len(stages)-1; i++ {
x1 := sidePad + i*(chipW+stageGap) + chipW x1 := sidePad + i*(chipW+stageGap) + chipW // right edge of left column
x2 := sidePad + (i+1)*(chipW+stageGap) x2 := sidePad + (i+1)*(chipW+stageGap) // left edge of right column
y1 := colCtY[i]
y2 := colCtY[i+1]
midX := (x1 + x2) / 2 midX := (x1 + x2) / 2
if y1 == y2 { srcJobs := byStage[stages[i]]
// Straight horizontal line. sort.Strings(srcJobs)
wf(` <line x1="%d" y1="%d" x2="%d" y2="%d" stroke="%s" stroke-width="2"/>`, dstJobs := byStage[stages[i+1]]
x1, y1, x2-7, y2, connStroke) sort.Strings(dstJobs)
} else {
// L-shaped elbow: right → vertical → right. // Collect all Y midpoints to span the vertical bus bar.
wf(` <polyline points="%d,%d %d,%d %d,%d %d,%d" `+ var allYs []int
`stroke="%s" stroke-width="2" fill="none" stroke-linejoin="round"/>`, srcY := make([]int, len(srcJobs))
x1, y1, midX, y1, midX, y2, x2-7, y2, connStroke) dstY := make([]int, len(dstJobs))
for j, name := range srcJobs {
srcY[j] = rightMid[name].y
allYs = append(allYs, srcY[j])
}
for j, name := range dstJobs {
dstY[j] = leftMid[name].y
allYs = append(allYs, dstY[j])
}
sort.Ints(allYs)
busMinY, busMaxY := allYs[0], allYs[len(allYs)-1]
// Vertical bus bar at midX (only drawn when there are multiple Y levels).
if busMinY < busMaxY {
wf(` <line x1="%d" y1="%d" x2="%d" y2="%d" stroke="%s" stroke-width="2"/>`,
midX, busMinY, midX, busMaxY, connStroke)
}
// Horizontal stubs from each source job to the bus.
for _, y := range srcY {
wf(` <line x1="%d" y1="%d" x2="%d" y2="%d" stroke="%s" stroke-width="2"/>`,
x1, y, midX, y, connStroke)
}
// Horizontal stubs from bus to each destination job, with arrowhead.
for _, y := range dstY {
wf(` <line x1="%d" y1="%d" x2="%d" y2="%d" stroke="%s" stroke-width="2"/>`,
midX, y, x2-7, y, connStroke)
wf(` <polygon points="%d,%d %d,%d %d,%d" fill="%s"/>`,
x2-7, y-4, x2, y, x2-7, y+4, connStroke)
} }
// Arrowhead at the destination.
wf(` <polygon points="%d,%d %d,%d %d,%d" fill="%s"/>`,
x2-7, y2-4, x2, y2, x2-7, y2+4, connStroke)
} }
} }
@@ -271,6 +367,7 @@ func pipelineSVG(p *model.Pipeline) string {
{"#fc6d26", "manual"}, {"#fc6d26", "manual"},
{"#6b4fbb", "trigger"}, {"#6b4fbb", "trigger"},
{"#fca326", "delayed"}, {"#fca326", "delayed"},
{"#d9534f", "on_failure"},
} }
const legendItemW = 82 const legendItemW = 82
legendY := topPad + labelH + maxColH + botPad/2 legendY := topPad + labelH + maxColH + botPad/2
@@ -293,7 +390,7 @@ func pipelineSVG(p *model.Pipeline) string {
func drawChipIcon(sb *strings.Builder, job model.Job, cx, cy int) { func drawChipIcon(sb *strings.Builder, job model.Job, cx, cy int) {
if job.Trigger != nil { if job.Trigger != nil {
// Right-pointing chevron for trigger jobs. // Right-pointing chevron for trigger jobs.
fmt.Fprintf(sb, " <polyline points=\"%d,%d %d,%d %d,%d\" "+ fmt.Fprintf(sb, " <polyline points=\"%d,%d %d,%d %d,%d\" "+
"stroke=\"#fff\" stroke-width=\"1.5\" fill=\"none\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n", "stroke=\"#fff\" stroke-width=\"1.5\" fill=\"none\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n",
cx-3, cy-3, cx+3, cy, cx-3, cy+3) cx-3, cy-3, cx+3, cy, cx-3, cy+3)
return return
@@ -301,18 +398,24 @@ func drawChipIcon(sb *strings.Builder, job model.Job, cx, cy int) {
switch job.When { switch job.When {
case "manual": case "manual":
// Filled play triangle. // Filled play triangle.
fmt.Fprintf(sb, " <polygon points=\"%d,%d %d,%d %d,%d\" fill=\"#fff\"/>\n", fmt.Fprintf(sb, " <polygon points=\"%d,%d %d,%d %d,%d\" fill=\"#fff\"/>\n",
cx-3, cy-4, cx+5, cy, cx-3, cy+4) cx-3, cy-4, cx+5, cy, cx-3, cy+4)
case "delayed": case "delayed":
// Clock hands: vertical + horizontal. // Clock: circle + hands.
fmt.Fprintf(sb, " <circle cx=\"%d\" cy=\"%d\" r=\"5\" stroke=\"#fff\" stroke-width=\"1.2\" fill=\"none\"/>\n", cx, cy) fmt.Fprintf(sb, " <circle cx=\"%d\" cy=\"%d\" r=\"5\" stroke=\"#fff\" stroke-width=\"1.2\" fill=\"none\"/>\n", cx, cy)
fmt.Fprintf(sb, " <line x1=\"%d\" y1=\"%d\" x2=\"%d\" y2=\"%d\" stroke=\"#fff\" stroke-width=\"1.2\" stroke-linecap=\"round\"/>\n", fmt.Fprintf(sb, " <line x1=\"%d\" y1=\"%d\" x2=\"%d\" y2=\"%d\" stroke=\"#fff\" stroke-width=\"1.2\" stroke-linecap=\"round\"/>\n",
cx, cy, cx, cy-3) cx, cy, cx, cy-3)
fmt.Fprintf(sb, " <line x1=\"%d\" y1=\"%d\" x2=\"%d\" y2=\"%d\" stroke=\"#fff\" stroke-width=\"1.2\" stroke-linecap=\"round\"/>\n", fmt.Fprintf(sb, " <line x1=\"%d\" y1=\"%d\" x2=\"%d\" y2=\"%d\" stroke=\"#fff\" stroke-width=\"1.2\" stroke-linecap=\"round\"/>\n",
cx, cy, cx+2, cy+1) cx, cy, cx+2, cy+1)
case "on_failure":
// X mark for failure-path jobs.
fmt.Fprintf(sb, " <line x1=\"%d\" y1=\"%d\" x2=\"%d\" y2=\"%d\" stroke=\"#fff\" stroke-width=\"1.5\" stroke-linecap=\"round\"/>\n",
cx-3, cy-3, cx+3, cy+3)
fmt.Fprintf(sb, " <line x1=\"%d\" y1=\"%d\" x2=\"%d\" y2=\"%d\" stroke=\"#fff\" stroke-width=\"1.5\" stroke-linecap=\"round\"/>\n",
cx+3, cy-3, cx-3, cy+3)
default: default:
// Regular job: small white checkmark outline. // Regular job: small white checkmark.
fmt.Fprintf(sb, " <polyline points=\"%d,%d %d,%d %d,%d\" "+ fmt.Fprintf(sb, " <polyline points=\"%d,%d %d,%d %d,%d\" "+
"stroke=\"#fff\" stroke-width=\"1.5\" fill=\"none\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n", "stroke=\"#fff\" stroke-width=\"1.5\" fill=\"none\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n",
cx-3, cy, cx-1, cy+3, cx+4, cy-3) cx-3, cy, cx-1, cy+3, cx+4, cy-3)
} }
@@ -327,10 +430,25 @@ func chipColor(job model.Job) string {
return "#fc6d26" return "#fc6d26"
case "delayed": case "delayed":
return "#fca326" return "#fca326"
case "on_failure":
return "#d9534f"
} }
return "#1f75cb" return "#1f75cb"
} }
// imageString extracts the image name from a job Image field (string or map form).
func imageString(img any) string {
switch v := img.(type) {
case string:
return v
case map[string]any:
if name, ok := v["name"].(string); ok {
return name
}
}
return ""
}
func svgEsc(s string) string { func svgEsc(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;") s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;") s = strings.ReplaceAll(s, "<", "&lt;")
@@ -356,3 +474,179 @@ func svgEmpty() string {
`</svg>`, `</svg>`,
w, h, w, h, w/2, h/2) w, h, w, h, w/2, h/2)
} }
// htmlPage wraps an SVG pipeline graph in a self-contained HTML page with
// mouse pan/zoom and a job-detail sidebar that appears on chip click.
func htmlPage(svgContent string) string {
var sb strings.Builder
sb.WriteString(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Pipeline Graph</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Segoe UI', Arial, sans-serif;
background: #f5f5f5;
display: flex;
height: 100vh;
overflow: hidden;
}
#sidebar {
width: 260px;
flex-shrink: 0;
background: #fff;
border-right: 1px solid #e0e0e0;
display: none;
flex-direction: column;
overflow-y: auto;
}
#sidebar.open { display: flex; }
#sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: .6rem .8rem;
border-bottom: 1px solid #e0e0e0;
background: #fafafa;
flex-shrink: 0;
}
#sidebar-header h3 {
font-size: .9rem;
color: #303030;
word-break: break-all;
}
#close-btn {
background: none;
border: none;
cursor: pointer;
font-size: 1rem;
color: #868686;
padding: .2rem .4rem;
margin-left: .4rem;
flex-shrink: 0;
}
#close-btn:hover { color: #303030; }
#sidebar-body { padding: .8rem; flex: 1; }
#sidebar-body pre {
font-size: .82rem;
color: #555;
white-space: pre-wrap;
line-height: 1.6;
}
#viewport {
flex: 1;
overflow: hidden;
cursor: grab;
position: relative;
background: #f5f5f5;
}
#viewport.dragging { cursor: grabbing; }
#svg-wrap {
display: inline-block;
transform-origin: 0 0;
padding: 20px;
}
g[data-job] { cursor: pointer; }
g[data-job]:hover > rect { stroke: #1f75cb; stroke-width: 2; }
</style>
</head>
<body>
<div id="sidebar">
<div id="sidebar-header">
<h3 id="sidebar-title">Job details</h3>
<button id="close-btn" title="Close">&#x2715;</button>
</div>
<div id="sidebar-body">
<pre id="sidebar-details"></pre>
</div>
</div>
<div id="viewport">
<div id="svg-wrap">
`)
sb.WriteString(svgContent)
sb.WriteString(`
</div>
</div>
<script>
(function() {
'use strict';
var vp = document.getElementById('viewport');
var wrap = document.getElementById('svg-wrap');
var sidebar = document.getElementById('sidebar');
var titleEl = document.getElementById('sidebar-title');
var detailsEl = document.getElementById('sidebar-details');
var tx = 0, ty = 0, scale = 1;
function applyTransform() {
wrap.style.transform = 'translate(' + tx + 'px,' + ty + 'px) scale(' + scale + ')';
}
// Zoom toward the cursor position.
vp.addEventListener('wheel', function(e) {
e.preventDefault();
var factor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
var r = vp.getBoundingClientRect();
var mx = e.clientX - r.left;
var my = e.clientY - r.top;
tx = mx - (mx - tx) * factor;
ty = my - (my - ty) * factor;
scale *= factor;
applyTransform();
}, { passive: false });
// Drag to pan (skip if clicking on a job chip).
var dragging = false, ox = 0, oy = 0;
vp.addEventListener('mousedown', function(e) {
if (e.target.closest('g[data-job]')) return;
dragging = true;
ox = e.clientX - tx;
oy = e.clientY - ty;
vp.classList.add('dragging');
});
window.addEventListener('mousemove', function(e) {
if (!dragging) return;
tx = e.clientX - ox;
ty = e.clientY - oy;
applyTransform();
});
window.addEventListener('mouseup', function() {
dragging = false;
vp.classList.remove('dragging');
});
// Double-click on the background to reset the view.
vp.addEventListener('dblclick', function(e) {
if (e.target.closest('g[data-job]')) return;
tx = 0; ty = 0; scale = 1;
applyTransform();
});
// Click a job chip to show its details in the sidebar.
document.addEventListener('click', function(e) {
var g = e.target.closest('g[data-job]');
if (!g) return;
var jobName = g.dataset.job;
var descEl = g.querySelector('desc');
var desc = descEl ? descEl.textContent : '';
titleEl.textContent = jobName;
detailsEl.textContent = desc;
sidebar.classList.add('open');
});
// Close the sidebar.
document.getElementById('close-btn').addEventListener('click', function() {
sidebar.classList.remove('open');
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') sidebar.classList.remove('open');
});
})();
</script>
</body>
</html>
`)
return sb.String()
}
+212 -26
View File
@@ -5,6 +5,7 @@ import (
"strings" "strings"
"testing" "testing"
"git.k3nny.fr/glint/internal/cicontext"
"git.k3nny.fr/glint/internal/model" "git.k3nny.fr/glint/internal/model"
) )
@@ -59,9 +60,20 @@ func TestChipColor(t *testing.T) {
if chipColor(model.Job{Trigger: "x"}) != "#6b4fbb" { t.Error("trigger color") } 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: "manual"}) != "#fc6d26" { t.Error("manual color") }
if chipColor(model.Job{When: "delayed"}) != "#fca326" { t.Error("delayed color") } if chipColor(model.Job{When: "delayed"}) != "#fca326" { t.Error("delayed color") }
if chipColor(model.Job{When: "on_failure"}) != "#d9534f" { t.Error("on_failure color") }
if chipColor(model.Job{}) != "#1f75cb" { t.Error("regular color") } if chipColor(model.Job{}) != "#1f75cb" { t.Error("regular color") }
} }
// ── imageString ───────────────────────────────────────────────────────────────
func TestImageString(t *testing.T) {
if imageString("alpine") != "alpine" { t.Error("string form") }
if imageString(map[string]any{"name": "golang:1.21"}) != "golang:1.21" { t.Error("map form") }
if imageString(map[string]any{"other": "x"}) != "" { t.Error("map without name key") }
if imageString(nil) != "" { t.Error("nil") }
if imageString(42) != "" { t.Error("unexpected type") }
}
// ── drawChipIcon ────────────────────────────────────────────────────────────── // ── drawChipIcon ──────────────────────────────────────────────────────────────
func TestDrawChipIcon(t *testing.T) { func TestDrawChipIcon(t *testing.T) {
@@ -72,6 +84,7 @@ func TestDrawChipIcon(t *testing.T) {
{model.Job{Trigger: "x"}, "<polyline"}, {model.Job{Trigger: "x"}, "<polyline"},
{model.Job{When: "manual"}, "<polygon"}, {model.Job{When: "manual"}, "<polygon"},
{model.Job{When: "delayed"}, "<circle"}, {model.Job{When: "delayed"}, "<circle"},
{model.Job{When: "on_failure"}, "<line"},
{model.Job{}, "<polyline"}, {model.Job{}, "<polyline"},
} }
for _, tc := range cases { for _, tc := range cases {
@@ -86,7 +99,7 @@ func TestDrawChipIcon(t *testing.T) {
// ── pipelineSVG ─────────────────────────────────────────────────────────────── // ── pipelineSVG ───────────────────────────────────────────────────────────────
func TestPipelineSVG_Empty(t *testing.T) { func TestPipelineSVG_Empty(t *testing.T) {
svg := pipelineSVG(&model.Pipeline{}) svg := pipelineSVG(&model.Pipeline{}, nil)
if !strings.Contains(svg, "no jobs defined") { if !strings.Contains(svg, "no jobs defined") {
t.Errorf("expected 'no jobs defined', got:\n%s", svg) t.Errorf("expected 'no jobs defined', got:\n%s", svg)
} }
@@ -100,13 +113,32 @@ func TestPipelineSVG_ClassicMode(t *testing.T) {
"test-job": {Name: "test-job", Stage: "test"}, "test-job": {Name: "test-job", Stage: "test"},
}, },
} }
svg := pipelineSVG(p) svg := pipelineSVG(p, nil)
if !strings.Contains(svg, "<svg") { t.Error("expected <svg") } if !strings.Contains(svg, "<svg") { t.Error("expected <svg") }
if !strings.Contains(svg, "build-job") { t.Error("expected build-job") } if !strings.Contains(svg, "build-job") { t.Error("expected build-job") }
if !strings.Contains(svg, "test-job") { t.Error("expected test-job") } if !strings.Contains(svg, "test-job") { t.Error("expected test-job") }
// Classic mode connector // Classic mode bus-bar draws <line> elements for connectors
if !strings.Contains(svg, "<polyline") && !strings.Contains(svg, "<line") { if !strings.Contains(svg, "<line") {
t.Error("expected connector in classic mode") t.Error("expected <line> connector in classic mode")
}
}
func TestPipelineSVG_ClassicMode_MultiJob(t *testing.T) {
// Two source jobs, two destination jobs → bus-bar with vertical segment
p := &model.Pipeline{
Stages: []string{"build", "test"},
Jobs: map[string]model.Job{
"build-a": {Name: "build-a", Stage: "build"},
"build-b": {Name: "build-b", Stage: "build"},
"test-a": {Name: "test-a", Stage: "test"},
"test-b": {Name: "test-b", Stage: "test"},
},
}
svg := pipelineSVG(p, nil)
// Bus bar: horizontal stubs from each src + dst, plus a vertical bus line
// and an arrowhead polygon for each dst job.
if !strings.Contains(svg, "<polygon") {
t.Error("expected arrowhead <polygon> in multi-job classic mode")
} }
} }
@@ -118,7 +150,7 @@ func TestPipelineSVG_DAGMode(t *testing.T) {
"test-job": {Name: "test-job", Stage: "test", Needs: []any{"build-job"}}, "test-job": {Name: "test-job", Stage: "test", Needs: []any{"build-job"}},
}, },
} }
svg := pipelineSVG(p) svg := pipelineSVG(p, nil)
// DAG mode draws <path> bezier curves // DAG mode draws <path> bezier curves
if !strings.Contains(svg, "<path") { if !strings.Contains(svg, "<path") {
t.Error("expected <path> connector in DAG mode") t.Error("expected <path> connector in DAG mode")
@@ -126,7 +158,7 @@ func TestPipelineSVG_DAGMode(t *testing.T) {
} }
func TestPipelineSVG_DAGMode_StraightConnector(t *testing.T) { func TestPipelineSVG_DAGMode_StraightConnector(t *testing.T) {
// Two stages at same height → straight <line> connector in classic mode // Two stages at same height → straight connector in classic mode
p := &model.Pipeline{ p := &model.Pipeline{
Stages: []string{"a", "b"}, Stages: []string{"a", "b"},
Jobs: map[string]model.Job{ Jobs: map[string]model.Job{
@@ -134,7 +166,7 @@ func TestPipelineSVG_DAGMode_StraightConnector(t *testing.T) {
"j2": {Name: "j2", Stage: "b"}, "j2": {Name: "j2", Stage: "b"},
}, },
} }
svg := pipelineSVG(p) svg := pipelineSVG(p, nil)
if !strings.Contains(svg, "<svg") { t.Error("expected <svg") } if !strings.Contains(svg, "<svg") { t.Error("expected <svg") }
} }
@@ -142,16 +174,21 @@ func TestPipelineSVG_AllJobTypes(t *testing.T) {
p := &model.Pipeline{ p := &model.Pipeline{
Stages: []string{"deploy"}, Stages: []string{"deploy"},
Jobs: map[string]model.Job{ Jobs: map[string]model.Job{
"manual-job": {Name: "manual-job", Stage: "deploy", When: "manual"}, "manual-job": {Name: "manual-job", Stage: "deploy", When: "manual"},
"delayed-job": {Name: "delayed-job", Stage: "deploy", When: "delayed"}, "delayed-job": {Name: "delayed-job", Stage: "deploy", When: "delayed"},
"trigger-job": {Name: "trigger-job", Stage: "deploy", Trigger: "other/project"}, "trigger-job": {Name: "trigger-job", Stage: "deploy", Trigger: "other/project"},
"onfailure-job": {Name: "onfailure-job", Stage: "deploy", When: "on_failure"},
}, },
} }
svg := pipelineSVG(p) svg := pipelineSVG(p, nil)
// Each type has its own color in the SVG
if !strings.Contains(svg, "#fc6d26") { t.Error("expected manual color") } if !strings.Contains(svg, "#fc6d26") { t.Error("expected manual color") }
if !strings.Contains(svg, "#fca326") { t.Error("expected delayed color") } if !strings.Contains(svg, "#fca326") { t.Error("expected delayed color") }
if !strings.Contains(svg, "#6b4fbb") { t.Error("expected trigger color") } if !strings.Contains(svg, "#6b4fbb") { t.Error("expected trigger color") }
if !strings.Contains(svg, "#d9534f") { t.Error("expected on_failure color") }
// on_failure chips have a dashed border
if !strings.Contains(svg, `stroke-dasharray="4,3"`) {
t.Error("expected dashed border on on_failure chip")
}
} }
func TestPipelineSVG_JobNoStage(t *testing.T) { func TestPipelineSVG_JobNoStage(t *testing.T) {
@@ -160,7 +197,7 @@ func TestPipelineSVG_JobNoStage(t *testing.T) {
"myjob": {Name: "myjob"}, "myjob": {Name: "myjob"},
}, },
} }
svg := pipelineSVG(p) svg := pipelineSVG(p, nil)
if !strings.Contains(svg, "myjob") { t.Error("expected myjob") } if !strings.Contains(svg, "myjob") { t.Error("expected myjob") }
} }
@@ -172,10 +209,85 @@ func TestPipelineSVG_CrossPipelineNeed(t *testing.T) {
Needs: []any{map[string]any{"pipeline": "other", "job": "x"}}}, Needs: []any{map[string]any{"pipeline": "other", "job": "x"}}},
}, },
} }
svg := pipelineSVG(p) svg := pipelineSVG(p, nil)
if !strings.Contains(svg, "<svg") { t.Error("expected <svg") } if !strings.Contains(svg, "<svg") { t.Error("expected <svg") }
} }
func TestPipelineSVG_Tooltip(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"build"},
Jobs: map[string]model.Job{
"build-job": {
Name: "build-job",
Stage: "build",
When: "on_success",
Image: "golang:1.21",
Needs: []any{"prep-job"},
},
},
}
svg := pipelineSVG(p, nil)
// Tooltip <title> and <desc> should appear inside the <g data-job> wrapper
if !strings.Contains(svg, `<title>build-job</title>`) {
t.Error("expected <title>build-job</title>")
}
if !strings.Contains(svg, `<desc>`) {
t.Error("expected <desc> element")
}
if !strings.Contains(svg, "image: golang:1.21") {
t.Error("expected image in desc")
}
if !strings.Contains(svg, "needs: prep-job") {
t.Error("expected needs in desc")
}
if !strings.Contains(svg, `data-job="build-job"`) {
t.Error("expected data-job attribute")
}
}
func TestPipelineSVG_SkippedJob(t *testing.T) {
// A job with rules that never match in the given context should be greyed out.
p := &model.Pipeline{
Stages: []string{"deploy"},
Jobs: map[string]model.Job{
"deploy-job": {
Name: "deploy-job",
Stage: "deploy",
Script: []any{"deploy.sh"},
Rules: []model.Rule{
{If: `$CI_COMMIT_TAG != ""`, When: "on_success"},
},
},
},
}
// Branch context: CI_COMMIT_TAG is empty → rule doesn't match → job skipped
ctx := cicontext.New("main", "", "push", nil)
svg := pipelineSVG(p, ctx)
// Skipped jobs use grey (#868686) instead of the regular blue
if !strings.Contains(svg, `fill="#868686"`) {
t.Error("expected grey fill for skipped job circle")
}
// Skipped state should appear in the tooltip desc
if !strings.Contains(svg, "state: skipped") {
t.Error("expected 'state: skipped' in tooltip desc")
}
}
func TestPipelineSVG_ContextNil(t *testing.T) {
// nil context → no skipped jobs, regular colors
p := &model.Pipeline{
Stages: []string{"build"},
Jobs: map[string]model.Job{
"j": {Name: "j", Stage: "build"},
},
}
svg := pipelineSVG(p, nil)
if !strings.Contains(svg, `fill="#1f75cb"`) {
t.Error("expected regular blue fill with nil context")
}
}
// ── RenderPipeline ──────────────────────────────────────────────────────────── // ── RenderPipeline ────────────────────────────────────────────────────────────
func TestRenderPipeline(t *testing.T) { func TestRenderPipeline(t *testing.T) {
@@ -186,7 +298,7 @@ func TestRenderPipeline(t *testing.T) {
}, },
} }
dir := t.TempDir() dir := t.TempDir()
path, err := RenderPipeline(p, dir) path, err := RenderPipeline(p, dir, nil)
if err != nil { if err != nil {
t.Fatalf("RenderPipeline: %v", err) t.Fatalf("RenderPipeline: %v", err)
} }
@@ -202,7 +314,7 @@ func TestRenderPipeline_InvalidDir(t *testing.T) {
if err := os.WriteFile(filePath, []byte("x"), 0o644); err != nil { if err := os.WriteFile(filePath, []byte("x"), 0o644); err != nil {
t.Fatal(err) t.Fatal(err)
} }
_, err := RenderPipeline(&model.Pipeline{}, filePath+"/nested") _, err := RenderPipeline(&model.Pipeline{}, filePath+"/nested", nil)
if err == nil { if err == nil {
t.Error("expected error writing to path under a file") t.Error("expected error writing to path under a file")
} }
@@ -222,7 +334,7 @@ func TestRenderPipeline_WriteFileFails(t *testing.T) {
Stages: []string{"build"}, Stages: []string{"build"},
Jobs: map[string]model.Job{"j": {Name: "j", Stage: "build"}}, Jobs: map[string]model.Job{"j": {Name: "j", Stage: "build"}},
} }
_, err := RenderPipeline(p, dir) _, err := RenderPipeline(p, dir, nil)
if err == nil { if err == nil {
t.Error("expected error when writing SVG to read-only directory") t.Error("expected error when writing SVG to read-only directory")
} }
@@ -241,7 +353,7 @@ func TestRenderPipeline_SVGFallback(t *testing.T) {
Jobs: map[string]model.Job{"j": {Name: "j", Stage: "build"}}, Jobs: map[string]model.Job{"j": {Name: "j", Stage: "build"}},
} }
dir := t.TempDir() dir := t.TempDir()
path, err := RenderPipeline(p, dir) path, err := RenderPipeline(p, dir, nil)
if err != nil { if err != nil {
t.Fatalf("RenderPipeline: %v", err) t.Fatalf("RenderPipeline: %v", err)
} }
@@ -264,7 +376,7 @@ func TestConvertToPNG_NoConverter(t *testing.T) {
func TestConvertToPNG_FakeConverter(t *testing.T) { func TestConvertToPNG_FakeConverter(t *testing.T) {
// Install a fake rsvg-convert that just creates the output file and exits 0. // 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. // This exercises the "return true" branch and os.Remove in RenderPipeline.
binDir := t.TempDir() binDir := t.TempDir()
script := "#!/bin/sh\n# rsvg-convert --output <out> <in>\ncp \"$3\" \"$2\"\n" script := "#!/bin/sh\n# rsvg-convert --output <out> <in>\ncp \"$3\" \"$2\"\n"
fakeBin := binDir + "/rsvg-convert" fakeBin := binDir + "/rsvg-convert"
@@ -303,7 +415,7 @@ func TestRenderPipeline_PNGConversion(t *testing.T) {
Jobs: map[string]model.Job{"j": {Name: "j", Stage: "build"}}, Jobs: map[string]model.Job{"j": {Name: "j", Stage: "build"}},
} }
dir := t.TempDir() dir := t.TempDir()
path, err := RenderPipeline(p, dir) path, err := RenderPipeline(p, dir, nil)
if err != nil { if err != nil {
t.Fatalf("RenderPipeline: %v", err) t.Fatalf("RenderPipeline: %v", err)
} }
@@ -315,7 +427,7 @@ func TestRenderPipeline_PNGConversion(t *testing.T) {
// ── pipelineSVG coverage gaps ───────────────────────────────────────────────── // ── pipelineSVG coverage gaps ─────────────────────────────────────────────────
func TestPipelineSVG_LShapedElbow(t *testing.T) { func TestPipelineSVG_LShapedElbow(t *testing.T) {
// Classic mode with unequal stage sizes → different column heights → L-shaped elbow // Classic mode with unequal stage sizes → vertical bus bar required
p := &model.Pipeline{ p := &model.Pipeline{
Stages: []string{"build", "test"}, Stages: []string{"build", "test"},
Jobs: map[string]model.Job{ Jobs: map[string]model.Job{
@@ -324,9 +436,10 @@ func TestPipelineSVG_LShapedElbow(t *testing.T) {
"j3": {Name: "j3", Stage: "test"}, "j3": {Name: "j3", Stage: "test"},
}, },
} }
svg := pipelineSVG(p) svg := pipelineSVG(p, nil)
if !strings.Contains(svg, "<polyline") { // The bus bar is a <line>; arrowheads are <polygon>
t.Error("expected <polyline> for L-shaped elbow connector") if !strings.Contains(svg, "<polygon") {
t.Error("expected <polygon> arrowhead in bus-bar connector")
} }
} }
@@ -339,9 +452,82 @@ func TestPipelineSVG_DAGNeedNotInPositionMap(t *testing.T) {
".hidden-template": {Name: ".hidden-template", Stage: "build"}, ".hidden-template": {Name: ".hidden-template", Stage: "build"},
}, },
} }
svg := pipelineSVG(p) svg := pipelineSVG(p, nil)
// Should render without panic; .hidden-template is not visible so no connector drawn. // Should render without panic; .hidden-template is not visible so no connector drawn.
if !strings.Contains(svg, "<svg") { if !strings.Contains(svg, "<svg") {
t.Error("expected valid SVG output") t.Error("expected valid SVG output")
} }
} }
// ── RenderHTML ────────────────────────────────────────────────────────────────
func TestRenderHTML(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 := RenderHTML(p, dir, nil)
if err != nil {
t.Fatalf("RenderHTML: %v", err)
}
if !strings.HasSuffix(path, ".html") {
t.Errorf("expected .html output, got %q", path)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("reading output: %v", err)
}
html := string(data)
if !strings.Contains(html, "<!DOCTYPE html>") { t.Error("expected DOCTYPE") }
if !strings.Contains(html, "<svg") { t.Error("expected embedded <svg") }
if !strings.Contains(html, "build-job") { t.Error("expected job name in HTML") }
if !strings.Contains(html, "applyTransform") { t.Error("expected JS pan/zoom function") }
if !strings.Contains(html, "sidebar") { t.Error("expected sidebar element") }
}
func TestRenderHTML_InvalidDir(t *testing.T) {
dir := t.TempDir()
filePath := dir + "/notadir"
if err := os.WriteFile(filePath, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
_, err := RenderHTML(&model.Pipeline{}, filePath+"/nested", nil)
if err == nil {
t.Error("expected error writing to path under a file")
}
}
func TestRenderHTML_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 := RenderHTML(p, dir, nil)
if err == nil {
t.Error("expected error when writing HTML to read-only directory")
}
}
// ── htmlPage ──────────────────────────────────────────────────────────────────
func TestHtmlPage(t *testing.T) {
svgContent := `<svg xmlns="http://www.w3.org/2000/svg" width="100" height="50"><rect/></svg>`
html := htmlPage(svgContent)
if !strings.Contains(html, "<!DOCTYPE html>") { t.Error("expected DOCTYPE") }
if !strings.Contains(html, svgContent) { t.Error("expected SVG embedded in HTML") }
if !strings.Contains(html, "id=\"viewport\"") { t.Error("expected viewport element") }
if !strings.Contains(html, "id=\"sidebar\"") { t.Error("expected sidebar element") }
if !strings.Contains(html, "applyTransform") { t.Error("expected JS transform function") }
}
+4 -1
View File
@@ -6,7 +6,7 @@ import (
"git.k3nny.fr/glint/internal/model" "git.k3nny.fr/glint/internal/model"
) )
func checkDependencies(p *model.Pipeline) []Finding { func checkDependencies(p *model.Pipeline, skipped map[string]bool) []Finding {
stageIndex := make(map[string]int, len(p.Stages)) stageIndex := make(map[string]int, len(p.Stages))
for i, s := range p.Stages { for i, s := range p.Stages {
stageIndex[s] = i stageIndex[s] = i
@@ -14,6 +14,9 @@ func checkDependencies(p *model.Pipeline) []Finding {
var findings []Finding var findings []Finding
for name, job := range p.Jobs { for name, job := range p.Jobs {
if skipped[name] {
continue
}
if len(job.Dependencies) == 0 { if len(job.Dependencies) == 0 {
continue continue
} }
+37
View File
@@ -816,4 +816,41 @@ my-job:
my-job: my-job:
script: echo hi`, script: echo hi`,
}, },
RuleRulesNeedsUnknown: {
Title: "'rules:needs:' references unknown job",
Severity: Error,
Description: "A job listed in a rule's 'needs:' override does not exist in " +
"the pipeline. 'rules:needs:' (GitLab CI 16.4+) overrides the top-level " +
"'needs:' list when that specific rule matches. GitLab will refuse to " +
"create the pipeline if any referenced job is missing.",
Example: `stages: [build, test]
build:
stage: build
script: make
test:
stage: test
script: make test
rules:
- if: $CI_COMMIT_BRANCH == "main"
needs: [build, lint] # lint does not exist`,
Fix: `stages: [build, test]
lint:
stage: build
script: make lint
build:
stage: build
script: make
test:
stage: test
script: make test
rules:
- if: $CI_COMMIT_BRANCH == "main"
needs: [build, lint]`,
},
} }
+25 -9
View File
@@ -458,7 +458,7 @@ func TestCheckNeeds(t *testing.T) {
Needs: []any{"build-job"}}, Needs: []any{"build-job"}},
}, },
} }
if len(checkNeeds(p)) != 0 { t.Error("valid needs: clean") } if len(checkNeeds(p, nil)) != 0 { t.Error("valid needs: clean") }
// needs unknown job // needs unknown job
p2 := &model.Pipeline{ p2 := &model.Pipeline{
@@ -466,7 +466,7 @@ func TestCheckNeeds(t *testing.T) {
"test-job": {Name: "test-job", Stage: "test", Needs: []any{"nonexistent"}}, "test-job": {Name: "test-job", Stage: "test", Needs: []any{"nonexistent"}},
}, },
} }
if len(checkNeeds(p2)) != 1 { t.Error("unknown needs: error") } if len(checkNeeds(p2, nil)) != 1 { t.Error("unknown needs: error") }
// optional: true for unknown job → warning not error // optional: true for unknown job → warning not error
p3 := &model.Pipeline{ p3 := &model.Pipeline{
@@ -475,7 +475,7 @@ func TestCheckNeeds(t *testing.T) {
Needs: []any{map[string]any{"job": "ghost", "optional": true}}}, Needs: []any{map[string]any{"job": "ghost", "optional": true}}},
}, },
} }
findings := checkNeeds(p3) findings := checkNeeds(p3, nil)
if len(findings) != 1 || findings[0].Severity != Warning { if len(findings) != 1 || findings[0].Severity != Warning {
t.Error("optional unknown needs: warning") t.Error("optional unknown needs: warning")
} }
@@ -487,7 +487,7 @@ func TestCheckNeeds(t *testing.T) {
Needs: []any{map[string]any{"pipeline": "other", "job": "j"}}}, Needs: []any{map[string]any{"pipeline": "other", "job": "j"}}},
}, },
} }
if len(checkNeeds(p4)) != 0 { t.Error("cross-pipeline: clean") } if len(checkNeeds(p4, nil)) != 0 { t.Error("cross-pipeline: clean") }
// needs later stage → error // needs later stage → error
p5 := &model.Pipeline{ p5 := &model.Pipeline{
@@ -501,7 +501,7 @@ func TestCheckNeeds(t *testing.T) {
}, },
} }
// Only cross-stage ordering violations should be found // Only cross-stage ordering violations should be found
_ = checkNeeds(p5) _ = checkNeeds(p5, nil)
} }
func TestCheckNeeds_Cycle(t *testing.T) { func TestCheckNeeds_Cycle(t *testing.T) {
@@ -511,7 +511,7 @@ func TestCheckNeeds_Cycle(t *testing.T) {
"b": {Name: "b", Needs: []any{"a"}}, "b": {Name: "b", Needs: []any{"a"}},
}, },
} }
findings := checkNeeds(p) findings := checkNeeds(p, nil)
for _, f := range findings { for _, f := range findings {
if f.Rule == RuleNeedsCycle { return } if f.Rule == RuleNeedsCycle { return }
} }
@@ -529,7 +529,7 @@ func TestCheckDependencies(t *testing.T) {
Dependencies: []string{"build-job"}}, Dependencies: []string{"build-job"}},
}, },
} }
if len(checkDependencies(p)) != 0 { t.Error("valid deps: clean") } if len(checkDependencies(p, nil)) != 0 { t.Error("valid deps: clean") }
// unknown dep // unknown dep
p2 := &model.Pipeline{ p2 := &model.Pipeline{
@@ -537,7 +537,7 @@ func TestCheckDependencies(t *testing.T) {
"test-job": {Name: "test-job", Stage: "test", Dependencies: []string{"ghost"}}, "test-job": {Name: "test-job", Stage: "test", Dependencies: []string{"ghost"}},
}, },
} }
if len(checkDependencies(p2)) != 1 { t.Error("unknown dep: error") } if len(checkDependencies(p2, nil)) != 1 { t.Error("unknown dep: error") }
// dep in same or later stage → error // dep in same or later stage → error
p3 := &model.Pipeline{ p3 := &model.Pipeline{
@@ -547,7 +547,23 @@ func TestCheckDependencies(t *testing.T) {
"test-job2": {Name: "test-job2", Stage: "test", Dependencies: []string{"test-job1"}}, "test-job2": {Name: "test-job2", Stage: "test", Dependencies: []string{"test-job1"}},
}, },
} }
if len(checkDependencies(p3)) != 1 { t.Error("same stage dep: error") } if len(checkDependencies(p3, nil)) != 1 { t.Error("same stage dep: error") }
}
func TestCheckDependencies_SkippedJob(t *testing.T) {
p := &model.Pipeline{
Jobs: map[string]model.Job{
"skipped-job": {Name: "skipped-job", Stage: "test", Dependencies: []string{"ghost"}},
},
}
// Without skipping: reports unknown dependency.
if len(checkDependencies(p, nil)) == 0 {
t.Fatal("expected GL030 without skipped set, got none")
}
// With job skipped: suppressed.
if len(checkDependencies(p, map[string]bool{"skipped-job": true})) != 0 {
t.Error("expected no findings for skipped job")
}
} }
// ── checkCacheKeyFiles ──────────────────────────────────────────────────────── // ── checkCacheKeyFiles ────────────────────────────────────────────────────────
+6 -3
View File
@@ -52,15 +52,18 @@ func (f Finding) String() string {
// Lint runs all rules against p and returns findings sorted by (File, Line, Rule). // 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. // Findings with no File (pipeline-level) sort before file-scoped ones.
func Lint(p *model.Pipeline) []Finding { // skipped is an optional set of job names to exclude from cross-job checks
// (needs:, dependencies:); pass nil to check all jobs.
func Lint(p *model.Pipeline, skipped map[string]bool) []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, checkDuplicateStages(p)...)
findings = append(findings, checkDefault(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, skipped)...)
findings = append(findings, checkDependencies(p)...) findings = append(findings, checkRulesNeeds(p, skipped)...)
findings = append(findings, checkDependencies(p, skipped)...)
findings = append(findings, checkVariableRefs(p)...) findings = append(findings, checkVariableRefs(p)...)
findings = append(findings, checkRulesIfReachability(p)...) findings = append(findings, checkRulesIfReachability(p)...)
findings = append(findings, checkInheritCompleteness(p)...) findings = append(findings, checkInheritCompleteness(p)...)
+42 -1
View File
@@ -12,7 +12,7 @@ type needEntry struct {
optional bool // true when the needs entry carries optional: true optional bool // true when the needs entry carries optional: true
} }
func checkNeeds(p *model.Pipeline) []Finding { func checkNeeds(p *model.Pipeline, skipped map[string]bool) []Finding {
var findings []Finding var findings []Finding
// Build a stage-index map for ordering checks. // Build a stage-index map for ordering checks.
@@ -26,6 +26,9 @@ func checkNeeds(p *model.Pipeline) []Finding {
needsGraph := make(map[string][]string) needsGraph := make(map[string][]string)
for name, job := range p.Jobs { for name, job := range p.Jobs {
if skipped[name] {
continue
}
if len(job.Needs) == 0 { if len(job.Needs) == 0 {
continue continue
} }
@@ -82,6 +85,44 @@ func checkNeeds(p *model.Pipeline) []Finding {
return findings return findings
} }
// checkRulesNeeds validates rules:needs: entries across all jobs. Each entry
// in a rule's needs: list must reference a job that exists in the pipeline.
// Cross-pipeline needs (maps with a "pipeline" key) are ignored. Skipped jobs
// are excluded from checking (same semantics as top-level needs: via GL027).
func checkRulesNeeds(p *model.Pipeline, skipped map[string]bool) []Finding {
var findings []Finding
for name, job := range p.Jobs {
if skipped[name] {
continue
}
for i, rule := range job.Rules {
if len(rule.Needs) == 0 {
continue
}
for _, entry := range parseNeedEntries(rule.Needs) {
if _, exists := p.Jobs[entry.job]; !exists {
sev := Error
if entry.optional {
sev = Warning
}
findings = append(findings, Finding{
Severity: sev,
Rule: RuleRulesNeedsUnknown,
Job: name,
File: job.File,
Line: job.Line,
Message: fmt.Sprintf(
"rules[%d].needs: references unknown job %q",
i, entry.job,
),
})
}
}
}
}
return findings
}
// parseNeedEntries extracts needs entries from a needs: list, preserving the // parseNeedEntries extracts needs entries from a needs: list, preserving the
// optional flag. Each element is a plain string (job name) or a map with a // optional flag. Each element is a plain string (job name) or a map with a
// "job" key. Cross-pipeline needs (maps with a "pipeline" key) are skipped. // "job" key. Cross-pipeline needs (maps with a "pipeline" key) are skipped.
+149 -1
View File
@@ -6,6 +6,154 @@ import (
"git.k3nny.fr/glint/internal/model" "git.k3nny.fr/glint/internal/model"
) )
// TestCheckNeeds_SkippedJob verifies that a skipped job's needs: violations are suppressed.
func TestCheckNeeds_SkippedJob(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"build", "deploy"},
Jobs: map[string]model.Job{
"skipped-job": {
Name: "skipped-job",
Stage: "build",
Script: []any{"echo"},
Needs: []any{"nonexistent-job"},
},
},
}
// Without skipping: should report unknown needs.
withoutSkip := checkNeeds(p, nil)
if len(withoutSkip) == 0 {
t.Fatal("expected GL027 without skipped set, got none")
}
// With job skipped: no findings.
withSkip := checkNeeds(p, map[string]bool{"skipped-job": true})
if len(withSkip) != 0 {
t.Errorf("expected no findings for skipped job, got %v", withSkip)
}
}
// TestCheckRulesNeeds_UnknownJob verifies that an unknown job in rules:needs: produces GL044.
func TestCheckRulesNeeds_UnknownJob(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{"make test"},
Rules: []model.Rule{
{
If: `$CI_COMMIT_BRANCH == "main"`,
Needs: []any{"build-job", "nonexistent"},
},
},
},
},
}
findings := checkRulesNeeds(p, nil)
var got bool
for _, f := range findings {
if f.Rule == RuleRulesNeedsUnknown && f.Severity == Error {
got = true
}
}
if !got {
t.Errorf("expected GL044 error for unknown rules:needs: job; got %v", findings)
}
}
// TestCheckRulesNeeds_KnownJob verifies no finding when all rules:needs: jobs exist.
func TestCheckRulesNeeds_KnownJob(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{"make test"},
Rules: []model.Rule{{Needs: []any{"build-job"}}},
},
},
}
if findings := checkRulesNeeds(p, nil); len(findings) != 0 {
t.Errorf("expected no findings for valid rules:needs:; got %v", findings)
}
}
// TestCheckRulesNeeds_Optional verifies that optional: true downgrades to Warning.
func TestCheckRulesNeeds_Optional(t *testing.T) {
p := &model.Pipeline{
Jobs: map[string]model.Job{
"test-job": {
Name: "test-job",
Script: []any{"make test"},
Rules: []model.Rule{
{Needs: []any{map[string]any{"job": "ghost", "optional": true}}},
},
},
},
}
findings := checkRulesNeeds(p, nil)
if len(findings) != 1 || findings[0].Severity != Warning {
t.Errorf("optional unknown rules:needs: should produce Warning; got %v", findings)
}
}
// TestCheckRulesNeeds_CrossPipeline verifies that cross-pipeline needs are ignored.
func TestCheckRulesNeeds_CrossPipeline(t *testing.T) {
p := &model.Pipeline{
Jobs: map[string]model.Job{
"test-job": {
Name: "test-job",
Script: []any{"make test"},
Rules: []model.Rule{
{Needs: []any{map[string]any{"pipeline": "other", "job": "j"}}},
},
},
},
}
if findings := checkRulesNeeds(p, nil); len(findings) != 0 {
t.Errorf("cross-pipeline rules:needs: should be ignored; got %v", findings)
}
}
// TestCheckRulesNeeds_SkippedJob verifies that a skipped job's rules:needs: violations are suppressed.
func TestCheckRulesNeeds_SkippedJob(t *testing.T) {
p := &model.Pipeline{
Jobs: map[string]model.Job{
"test-job": {
Name: "test-job",
Script: []any{"make test"},
Rules: []model.Rule{{Needs: []any{"nonexistent"}}},
},
},
}
if findings := checkRulesNeeds(p, map[string]bool{"test-job": true}); len(findings) != 0 {
t.Errorf("skipped job: expected no findings; got %v", findings)
}
}
// TestCheckRulesNeeds_NoNeeds verifies no findings when rules have no needs: override.
func TestCheckRulesNeeds_NoNeeds(t *testing.T) {
p := &model.Pipeline{
Jobs: map[string]model.Job{
"test-job": {
Name: "test-job",
Script: []any{"make test"},
Rules: []model.Rule{{When: "on_success"}},
},
},
}
if findings := checkRulesNeeds(p, nil); len(findings) != 0 {
t.Errorf("rules without needs: should produce no findings; got %v", findings)
}
}
// TestCheckNeeds_StageOrder verifies that a job needing a job in a later stage // TestCheckNeeds_StageOrder verifies that a job needing a job in a later stage
// produces RuleNeedsStageOrder (line 64-76 in needs.go). // produces RuleNeedsStageOrder (line 64-76 in needs.go).
func TestCheckNeeds_StageOrder(t *testing.T) { func TestCheckNeeds_StageOrder(t *testing.T) {
@@ -25,7 +173,7 @@ func TestCheckNeeds_StageOrder(t *testing.T) {
}, },
}, },
} }
findings := checkNeeds(p) findings := checkNeeds(p, nil)
var gotStageOrder bool var gotStageOrder bool
for _, f := range findings { for _, f := range findings {
if f.Rule == RuleNeedsStageOrder { if f.Rule == RuleNeedsStageOrder {
+4
View File
@@ -154,4 +154,8 @@ const (
// pipeline has no 'default:' block, making the declaration a no-op. Also fires // 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. // when 'inherit: default: [list]' names fields not set in the default: block.
RuleInheritNoDefault = "GL043" RuleInheritNoDefault = "GL043"
// GL044: a rules:needs: entry references a job that does not exist in the pipeline.
// rules:needs: overrides the top-level needs: when a specific rule matches (GitLab CI 16.4+).
RuleRulesNeedsUnknown = "GL044"
) )
+2 -2
View File
@@ -35,7 +35,7 @@ func TestSambaCI(t *testing.T) {
t.Logf("extends warning: job %q extends unknown %q", w.Job, w.Base) t.Logf("extends warning: job %q extends unknown %q", w.Job, w.Base)
} }
findings := linter.Lint(p) findings := linter.Lint(p, nil)
for _, f := range findings { for _, f := range findings {
if f.Severity == linter.Error { if f.Severity == linter.Error {
@@ -78,7 +78,7 @@ func TestSambaCIEntryFiles(t *testing.T) {
t.Logf("extends warning: job %q extends unknown %q", w.Job, w.Base) t.Logf("extends warning: job %q extends unknown %q", w.Job, w.Base)
} }
findings := linter.Lint(p) findings := linter.Lint(p, nil)
for _, f := range findings { for _, f := range findings {
if f.Severity == linter.Error { if f.Severity == linter.Error {
t.Errorf("unexpected error finding: %s", f) t.Errorf("unexpected error finding: %s", f)
+1
View File
@@ -89,6 +89,7 @@ type Rule struct {
Changes any `yaml:"changes"` // []string or {paths,compare_to} map Changes any `yaml:"changes"` // []string or {paths,compare_to} map
Exists any `yaml:"exists"` // []string or map form Exists any `yaml:"exists"` // []string or map form
Variables map[string]any `yaml:"variables"` // set/override variables when rule matches (GitLab CI 15.0+) Variables map[string]any `yaml:"variables"` // set/override variables when rule matches (GitLab CI 15.0+)
Needs []any `yaml:"needs"` // override needs: when this rule matches (GitLab CI 16.4+)
} }
// ReservedKeys are top-level GitLab CI keys that are NOT job definitions. // ReservedKeys are top-level GitLab CI keys that are NOT job definitions.
+15
View File
@@ -0,0 +1,15 @@
stages:
- build
- test
build-job:
stage: build
script: make
test-job:
stage: test
script: make test
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
needs: [build-job, nonexistent-job] # nonexistent-job does not exist → GL044
- when: on_success
+19
View File
@@ -0,0 +1,19 @@
stages:
- build
- test
build-job:
stage: build
script: make
lint-job:
stage: build
script: make lint
test-job:
stage: test
script: make test
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
needs: [build-job, lint-job] # both exist → clean
- when: on_success