Compare commits

..

6 Commits

Author SHA1 Message Date
k3nny 192ab3198b feat(build): GitLab CI component, GitHub Actions action, and pre-commit hook
ci / vet, staticcheck, test, build (push) Failing after 1m52s
release / Build and publish release (push) Successful in 1m13s
templates/check.yml — GitLab CI/CD Catalog component; downloads the glint
Linux binary and runs glint check; inputs: stage, pipeline_file, version,
allow_failure, extra_args.

action.yml — GitHub Actions composite action; downloads glint into
$RUNNER_TEMP and runs glint check; inputs: version, file, args. Mirror
to github.com/k3nny/glint to use as `uses: k3nny/glint@vX.Y.Z`.

.pre-commit-hooks.yaml — language: golang hook; pre-commit builds glint
from source on first run and re-runs on staged .gitlab-ci.yml changes.
Reference as repo: https://git.k3nny.fr/k3nny/glint.

README updated with an Integrations section covering all three.
Git remote corrected to https://git.k3nny.fr/k3nny/glint.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 00:12:55 +02:00
k3nny f197c368d3 docs(linter): add .glint.yml config example
ci / vet, staticcheck, test, build (push) Failing after 2m2s
2026-06-26 00:03:39 +02:00
k3nny d6afb148ca feat(build): expand fuzz coverage to expression evaluator and linter; fix empty-key parser bug
ci / vet, staticcheck, test, build (push) Failing after 3m15s
Run fuzz tests found a real bug: a bare '?' YAML input (null mapping key)
caused ParseBytes to store p.Jobs[""] — fixed in internal/model/parser.go by
rejecting empty keys with an explicit error.

New fuzz targets added:
- FuzzEvalIf / FuzzExpandVarRefs (internal/cicontext) — exercises the
  hand-rolled recursive-descent rules:if: parser and variable expander
- FuzzLint (internal/linter) — drives the full Parse → Lint path against
  arbitrary YAML; triggers every type-assertion in the lint rules

task fuzz now runs all 5 targets sequentially (30 s each by default).
All targets clean: 90-120 s runs, 400k-3.5M executions, zero failures.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 23:58:47 +02:00
k3nny 522c637b75 fix(model): reject null/empty YAML mapping keys in ParseBytes
ci / vet, staticcheck, test, build (push) Failing after 2m43s
A bare '?' in YAML is an explicit-key indicator for a null key, which
produced a job with an empty name (p.Jobs[""]) — a parser invariant
violation caught by FuzzParseBytes.

ParseBytes now returns an error when keyNode.Value is empty.
Failing corpus entry retained as a seed so CI exercises this path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 23:43:40 +02:00
k3nny 9342ce0eff feat(build): fuzz testing and git-cliff changelog automation
ci / vet, staticcheck, test, build (push) Failing after 2m24s
release / Build and publish release (push) Successful in 1m19s
Add FuzzParseBytes and FuzzSanitizeYAMLEscapes in internal/model/fuzz_test.go.
Both targets run as regular seed-based tests in CI (go test ./...) and can be
run continuously via `task fuzz` (default 30 s per target; FUZZ_TIME=60s to
extend). Fuzz corpus failures are saved to testdata/fuzz/ for regression.

Add cliff.toml configuring git-cliff to generate Keep-a-Changelog-compatible
release notes from Conventional Commits. New tasks: `task changelog`
(regenerate full CHANGELOG.md) and `task changelog-next` (preview unreleased
entries without writing). Requires git-cliff (brew/cargo install git-cliff).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 23:40:16 +02:00
k3nny 7a4d55ec9a feat(graph): intra-stage DAG sub-columns and connectors behind chips
ci / vet, staticcheck, test, build (push) Failing after 2m58s
release / Build and publish release (push) Successful in 1m25s
When jobs within the same declared GitLab stage have needs: relationships
between each other, the pipeline graph now splits that stage into
topological sub-columns: jobs with no same-stage deps are in sub-column 0,
jobs that depend on them shift one column right. A new computeColumns()
function handles the topo-sort; a narrower subStageGap (20 px vs 50 px
stageGap) separates sub-columns; stage headers span all sub-columns.

SVG connector lines (Bézier curves in DAG mode, bus-bar stubs in classic
mode) are now emitted before job chip rectangles so connectors visually
pass behind chips rather than on top of them.

100% statement coverage maintained (99 tests in graph package).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 23:06:49 +02:00
17 changed files with 986 additions and 117 deletions
+66
View File
@@ -0,0 +1,66 @@
# .glint.yml — glint project configuration
#
# Place this file anywhere between your .gitlab-ci.yml and the repository root.
# glint searches upward from the pipeline file and stops at the first .git
# boundary, so the repo root is the typical location.
#
# All keys are optional. Omit or comment out anything you don't need.
# ── Rule suppression ──────────────────────────────────────────────────────────
#
# Suppress rules globally for this project. Suppressed rules produce no output
# and do not affect the exit code.
#
ignore:
- GL007 # only:/except: used (migrating from legacy syntax)
- GL032 # rules:if: references undeclared variable (injected at runtime)
# ── Severity overrides ────────────────────────────────────────────────────────
#
# Override the default severity of any rule.
# Valid values: error | warning | ignore
# "ignore" is equivalent to listing the rule in `ignore:` above.
#
severity:
GL004: warning # demote "unknown stage" to warning during a stage migration
GL035: error # promote absolute-path warning to a hard error
GL007: ignore # equivalent to adding GL007 to ignore:
# ── Extra stage names ─────────────────────────────────────────────────────────
#
# Declare stage names that are valid for this project beyond what is listed in
# the pipeline's own `stages:` block. Jobs referencing these stages will not
# be flagged by GL004. Useful when stages are defined in a shared parent
# template that glint cannot reach.
#
stages:
- quality
- security
- compliance
# ── GitLab token ──────────────────────────────────────────────────────────────
#
# Default personal access token (read_api scope) used to fetch `include:
# project:` templates. This is the lowest-priority token source; it is
# overridden by the --token flag and the GITLAB_TOKEN / CI_JOB_TOKEN /
# GITLAB_PRIVATE_TOKEN environment variables.
#
# Avoid committing real tokens — use environment variables instead.
#
# token: glpat-xxxxxxxxxxxxxxxxxxxx
# ── GitLab instance URL ───────────────────────────────────────────────────────
#
# Default GitLab instance URL, used when fetching project: and component:
# includes. Overridden by --gitlab-url, CI_SERVER_URL, and GITLAB_URL.
#
# url: https://gitlab.example.com
# ── Include cache directory ───────────────────────────────────────────────────
#
# Default directory for caching fetched remote includes (project: and
# component:). The directory is created on first use. Overridden by
# --cache-dir. When --offline is given without --cache-dir, glint defaults
# to ~/.cache/glint regardless of this setting.
#
# cache_dir: ~/.cache/glint
+11
View File
@@ -0,0 +1,11 @@
- id: glint
name: glint — validate GitLab CI pipeline
description: >-
Lint .gitlab-ci.yml with glint before committing. Catches misconfigured
stages, invalid keywords, broken needs: graphs, deprecated patterns, and
more — without a GitLab server.
entry: glint check
language: golang
files: '(^|/)\.gitlab-ci\.yml$'
pass_filenames: true
minimum_pre_commit_version: '3.0.0'
+26
View File
@@ -5,6 +5,32 @@ 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.28] - 2026-06-26
### Added
- **Pre-commit hook** — `.pre-commit-hooks.yaml` defines a `glint` hook with `language: golang`; pre-commit builds glint from source automatically on first run and re-runs `glint check` on any staged `.gitlab-ci.yml` changes. Reference: `repo: https://git.k3nny.fr/k3nny/glint, rev: v0.2.28`.
- **GitLab CI component** (`templates/check.yml`) — a GitLab CI/CD Catalogcompatible component that downloads the glint Linux binary and runs `glint check` as a pipeline job. Accepts inputs: `stage` (default `validate`), `pipeline_file` (default `.gitlab-ci.yml`), `version` (default `latest`), `allow_failure` (default `false`), and `extra_args`. Can also be used as a plain local or remote include without the Catalog.
- **GitHub Actions composite action** (`action.yml`) — downloads the glint Linux binary into `$RUNNER_TEMP`, adds it to `$GITHUB_PATH`, and runs `glint check`. Inputs: `version`, `file`, `args`. Mirror this repository to GitHub as `k3nny/glint` to reference it as `uses: k3nny/glint@v0.2.28`.
## [0.2.27] - 2026-06-25
### Added
- **Fuzz testing** — `FuzzParseBytes` and `FuzzSanitizeYAMLEscapes` in `internal/model/fuzz_test.go` verify that neither the YAML parser nor the escape sanitizer panics on arbitrary input. Successful parses are also checked for structural integrity (non-nil pipeline, no empty job names). Run with `task fuzz` (default 30 s per target; set `FUZZ_TIME=60s` to extend). Found failures are saved to `testdata/fuzz/` for regression.
- **Changelog automation** — `cliff.toml` configures [git-cliff](https://git-cliff.org) to generate Keep-a-Changelogcompatible release notes from Conventional Commits. `task changelog` regenerates `CHANGELOG.md` from the full git history; `task changelog-next` previews only unreleased commits without writing. Install git-cliff with `brew install git-cliff` or `cargo install git-cliff`.
## [0.2.26] - 2026-06-25
### Changed
- **Same-stage job ordering** — when jobs within the same declared GitLab stage have `needs:` relationships between each other, the pipeline graph now splits that stage into topological sub-columns: jobs with no same-stage dependencies occupy the leftmost sub-column; jobs that depend on them are placed one sub-column to the right. Sub-columns use a narrower 20 px gap (vs. the 50 px gap between stages), and the stage header spans all sub-columns. Stages with no intra-stage `needs:` are unaffected.
- **Graph links rendered behind job chips** — SVG connector lines (Bézier curves in DAG mode, bus-bar stubs in classic mode) are now drawn before the job chip rectangles, so connector lines pass behind chips rather than on top of them.
## [0.2.25] - 2026-06-25 ## [0.2.25] - 2026-06-25
### Added ### Added
+57 -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.25-blue.svg" alt="Release"></a> <a href="CHANGELOG.md"><img src="https://img.shields.io/badge/release-v0.2.28-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.
@@ -32,7 +32,7 @@ See [FEATURES.md](FEATURES.md) for the complete feature reference and lint rules
## Installation ## Installation
```bash ```bash
git clone https://git.k3nny.fr/glint git clone https://git.k3nny.fr/k3nny/glint
cd glint cd glint
go build -o glint ./cmd/glint/... go build -o glint ./cmd/glint/...
``` ```
@@ -58,6 +58,54 @@ Run `glint <command> --help` for all flags. See [USAGE.md](USAGE.md) for full
examples covering output formats, context simulation, remote includes, cache, examples covering output formats, context simulation, remote includes, cache,
graph modes, and project configuration. graph modes, and project configuration.
## Integrations
### Pre-commit hook
Add to `.pre-commit-config.yaml` in your repository to run glint automatically whenever `.gitlab-ci.yml` changes:
```yaml
repos:
- repo: https://git.k3nny.fr/k3nny/glint
rev: v0.2.28
hooks:
- id: glint
```
Requires [pre-commit](https://pre-commit.com) and Go 1.21+. On first run, pre-commit builds glint from source automatically.
### GitLab CI component
Copy [`templates/check.yml`](templates/check.yml) into your repository and include it as a local file, or publish this repository to a GitLab CI/CD Catalog and reference it as a component:
```yaml
# As a local include (copy templates/check.yml to your repo first):
include:
- local: .gitlab/glint-check.yml
# As a Catalog component (after publishing to a GitLab instance):
include:
- component: $CI_SERVER_FQDN/k3nny/glint/check@v0.2.28
inputs:
stage: validate # optional, default: validate
allow_failure: true # optional, default: false
```
The component downloads the glint Linux binary, runs `glint check`, and respects all inputs defined in the `spec:` block.
### GitHub Actions
Copy [`action.yml`](action.yml) from this repository, or mirror this repo to GitHub as `k3nny/glint` and reference it directly:
```yaml
- uses: k3nny/glint@v0.2.28
with:
file: .gitlab-ci.yml # optional, default: .gitlab-ci.yml
args: '--format sarif' # optional
```
The action downloads the glint Linux binary into `$RUNNER_TEMP` and runs `glint check`. Only Linux runners are supported (matches the available release binary).
## Development ## Development
This project uses [Task](https://taskfile.dev) as a task runner. This project uses [Task](https://taskfile.dev) as a task runner.
@@ -69,11 +117,18 @@ 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 fuzz # run fuzz tests for the YAML parser (Ctrl-C to stop; FUZZ_TIME=60s to set duration)
task changelog # regenerate CHANGELOG.md from git history via git-cliff
task changelog-next # preview unreleased section (dry-run, no file written)
task build-windows # cross-compile for Windows x64 (requires a tagged commit → glint-<tag>.exe) 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 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
``` ```
**Optional tools:**
- [git-cliff](https://git-cliff.org) — changelog generator used by `task changelog`. Install with `brew install git-cliff` or `cargo install git-cliff`.
## Project structure ## Project structure
``` ```
+8 -6
View File
@@ -77,6 +77,8 @@ The SVG renderer and terminal tree cover the basic layout. These would bring it
- ~~**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 - ~~**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**~~ — ✓ 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 - ~~**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**~~ — ✓ shipped v0.2.25; `glint graph pipeline --format mermaid` prints a Mermaid flowchart to stdout (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)
- ~~**Same-stage job ordering**~~ — ✓ shipped v0.2.26; jobs within a stage that have `needs:` between each other are placed in topological sub-columns (left-to-right by depth); stage header spans all sub-columns
- ~~**Graph links rendered behind job chips**~~ — ✓ shipped v0.2.26; SVG connectors (Bézier curves and bus-bar stubs) are drawn before job chips so lines pass behind rectangles
--- ---
@@ -96,9 +98,9 @@ The SVG renderer and terminal tree cover the basic layout. These would bring it
## CI / editor integration ## CI / editor integration
- **GitLab CI template** — a `.gitlab-ci.yml` snippet that runs `glint` as a pipeline-validation job before the real pipeline executes; publishable to the GitLab CI/CD Catalog - ~~**GitLab CI template**~~✓ shipped v0.2.28; `templates/check.yml` is a GitLab CI/CD Catalog component with `spec:` inputs for stage, file, version, allow_failure, and extra args; also usable as a plain local/remote include
- **GitHub Actions action** — `uses: k3nny/glint@v1` wrapper for repositories that mirror or manage GitLab pipelines from GitHub - ~~**GitHub Actions action**~~✓ shipped v0.2.28; `action.yml` composite action downloads the glint Linux binary and runs `glint check`; mirror to GitHub as `k3nny/glint` to reference as `uses: k3nny/glint@v0.2.28`
- **Pre-commit hook** — entry for [pre-commit](https://pre-commit.com) so `glint` runs automatically on `git commit` when `.gitlab-ci.yml` changes - ~~**Pre-commit hook**~~✓ shipped v0.2.28; `.pre-commit-hooks.yaml` defines `language: golang` hook; pre-commit builds glint from source on first run and re-runs on staged `.gitlab-ci.yml` changes
- **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 - **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`
@@ -113,9 +115,9 @@ 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; GL044 added v0.2.24; graph improvements shipped v0.2.25 - ~~**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.25v0.2.26
- ~~**`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`
- **Changelog automation** — generate release notes from Conventional Commits via `git-cliff` or similar - ~~**Changelog automation**~~✓ shipped v0.2.27; `cliff.toml` configures git-cliff to produce Keep-a-Changelogcompatible release notes from Conventional Commits; `task changelog` regenerates `CHANGELOG.md`, `task changelog-next` previews unreleased entries
- **Fuzz testing** — add a `go test -fuzz` target for the YAML parser to harden it against malformed input - ~~**Fuzz testing**~~✓ shipped v0.2.27; `FuzzParseBytes` and `FuzzSanitizeYAMLEscapes` in `internal/model/fuzz_test.go`; seeds run as regular tests in CI; `task fuzz` runs them continuously (default 30 s)
+17
View File
@@ -175,6 +175,23 @@ tasks:
generates: generates:
- "{{.BINARY}}-{{.TAG}}-linux-amd64" - "{{.BINARY}}-{{.TAG}}-linux-amd64"
fuzz:
desc: "Run all fuzz targets (set FUZZ_TIME=60s to control per-target duration, default 30s)"
cmds:
- "{{.GO}} test -fuzz=FuzzParseBytes -fuzztime=${FUZZ_TIME:-30s} ./internal/model/"
- "{{.GO}} test -fuzz=FuzzSanitizeYAMLEscapes -fuzztime=${FUZZ_TIME:-30s} ./internal/model/"
- "{{.GO}} test -fuzz=FuzzEvalIf -fuzztime=${FUZZ_TIME:-30s} ./internal/cicontext/"
- "{{.GO}} test -fuzz=FuzzExpandVarRefs -fuzztime=${FUZZ_TIME:-30s} ./internal/cicontext/"
- "{{.GO}} test -fuzz=FuzzLint -fuzztime=${FUZZ_TIME:-30s} ./internal/linter/"
changelog:
desc: "Regenerate CHANGELOG.md from git history (requires git-cliff — see README)"
cmd: git cliff --config cliff.toml --output CHANGELOG.md
changelog-next:
desc: "Preview unreleased changelog entries without writing (requires git-cliff)"
cmd: git cliff --config cliff.toml --unreleased
clean: clean:
desc: Remove build artifacts desc: Remove build artifacts
cmd: rm -f {{.BINARY}} {{.BINARY}}-*.exe {{.BINARY}}-*-linux-amd64 cmd: rm -f {{.BINARY}} {{.BINARY}}-*.exe {{.BINARY}}-*-linux-amd64
+62
View File
@@ -0,0 +1,62 @@
# GitHub Actions composite action — glint pipeline validator
#
# To use this action, mirror this repository to GitHub as k3nny/glint, then:
#
# - uses: k3nny/glint@v0.2.28
# with:
# file: .gitlab-ci.yml # optional
#
# Alternatively, copy this file into your own repository and reference it
# as a local action:
#
# - uses: ./.github/actions/glint
name: 'glint'
description: 'Validate a GitLab CI pipeline file with glint'
author: 'k3nny'
branding:
icon: 'check-circle'
color: 'orange'
inputs:
version:
description: >-
glint release tag to install (e.g. 'v0.2.28'). Defaults to 'latest'
which resolves to the newest published release.
required: false
default: 'latest'
file:
description: 'Path to the pipeline file to validate.'
required: false
default: '.gitlab-ci.yml'
args:
description: 'Additional arguments passed to glint check (e.g. --format sarif).'
required: false
default: ''
runs:
using: 'composite'
steps:
- name: Install glint
shell: bash
env:
GLINT_VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
if [ "$GLINT_VERSION" = "latest" ]; then
GLINT_VERSION=$(curl -sf \
"https://git.k3nny.fr/api/v1/repos/k3nny/glint/releases?limit=1" \
| grep '"tag_name"' | head -1 | cut -d'"' -f4)
fi
DEST="$RUNNER_TEMP/glint-bin"
mkdir -p "$DEST"
URL="https://git.k3nny.fr/k3nny/glint/releases/download/${GLINT_VERSION}/glint-${GLINT_VERSION}-linux-amd64"
curl -sfL "$URL" -o "$DEST/glint"
chmod +x "$DEST/glint"
echo "$DEST" >> "$GITHUB_PATH"
"$DEST/glint" --version
- name: Run glint check
shell: bash
run: glint check ${{ inputs.args }} "${{ inputs.file }}"
+65
View File
@@ -0,0 +1,65 @@
# git-cliff configuration for glint
# Install: brew install git-cliff OR cargo install git-cliff
# Usage: task changelog -- regenerate full CHANGELOG.md
# task changelog-next -- preview unreleased section (dry-run)
[changelog]
header = """
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
This project uses [Semantic Versioning](https://semver.org).
"""
body = """
{% if version %}\
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
{% else %}\
## [Unreleased]
{% endif %}\
{% for group, commits in commits | group_by(attribute="group") %}\
### {{ group | upper_first }}
{% for commit in commits %}\
- {% if commit.scope %}**{{ commit.scope }}**: {% endif %}\
{{ commit.message | upper_first }}\
{% if commit.breaking %} **[BREAKING]**{% endif %}
{% endfor %}\
{% endfor %}\
"""
trim = true
footer = ""
postprocessors = []
[git]
conventional_commits = true
filter_unconventional = true
split_commits = false
commit_preprocessors = [
# Drop Co-Authored-By trailers (should not appear in subjects, but guard anyway).
{ pattern = "Co-Authored-By:.*", replace = "" },
]
commit_parsers = [
# Breaking changes (type! or scope!) — promote above everything else.
{ message = "^[a-z]+\\([a-z-]+\\)!:|^[a-z]+!:", group = "Breaking Changes" },
{ message = "^feat", group = "Added" },
{ message = "^fix", group = "Fixed" },
{ message = "^perf", group = "Changed" },
{ message = "^refactor", group = "Changed" },
# Maintenance commits — omit from the changelog body.
{ message = "^docs", skip = true },
{ message = "^style", skip = true },
{ message = "^test", skip = true },
{ message = "^chore", skip = true },
{ message = "^build", skip = true },
{ message = "^claude", skip = true },
]
protect_breaking_commits = false
filter_commits = true
tag_pattern = "v[0-9].*"
topo_order = false
sort_commits = "oldest"
+85
View File
@@ -0,0 +1,85 @@
package cicontext
import "testing"
// FuzzEvalIf ensures the rules:if: expression evaluator never panics on
// arbitrary input. It accepts two strings: the expression and a variable value
// substituted for every variable reference encountered.
// Run with: go test -fuzz=FuzzEvalIf ./internal/cicontext/
func FuzzEvalIf(f *testing.F) {
// Seed corpus: representative expressions exercising every code path in
// the hand-rolled recursive-descent parser.
seeds := []struct{ expr, varVal string }{
// Simple comparisons
{`$VAR == "main"`, "main"},
{`$VAR != "main"`, "main"},
{`$VAR == null`, ""},
{`$VAR != null`, "x"},
// Regex operators
{`$VAR =~ /^v\d+\.\d+/`, "v1.2.3"},
{`$VAR !~ /^v\d+\.\d+/`, "not-a-version"},
{`$VAR =~ /^us\//`, "us/west"},
// Boolean operators
{`$A == "x" && $B == "y"`, "x"},
{`$A == "x" || $B == "y"`, "z"},
{`!($VAR == "main")`, "main"},
// Nested parens
{`($VAR == "a" || $VAR == "b") && $VAR != "c"`, "a"},
// Bare true / false
{`$VAR == true`, "true"},
{`$VAR == false`, "false"},
// Integer comparison (GitLab CI compares as strings)
{`$VAR == 42`, "42"},
// Regex with flags
{`$VAR =~ /main/i`, "MAIN"},
// Syntax errors / incomplete expressions
{``, ""},
{`&&`, ""},
{`$VAR =~`, "x"},
{`($VAR`, "x"},
{`$VAR == `, "x"},
// Variable syntax variants
{`${VAR} == "main"`, "main"},
// Deeply nested
{`((($VAR == "a")))`, "a"},
// String with escapes
{`$VAR == "hello\nworld"`, "hello\nworld"},
// Null literal
{`null == null`, ""},
{`$VAR == null`, ""},
}
for _, s := range seeds {
f.Add(s.expr, s.varVal)
}
f.Fuzz(func(t *testing.T, expr, varVal string) {
// EvalIf must never panic; it may return any bool.
_ = EvalIf(expr, func(string) string { return varVal })
_ = EvalIfStrict(expr, func(string) string { return varVal })
})
}
// FuzzExpandVarRefs ensures variable expansion in expression strings never
// panics and never produces a longer output than the worst-case expansion bound.
func FuzzExpandVarRefs(f *testing.F) {
seeds := []struct{ s, val string }{
{"$VAR", "hello"},
{"${VAR}", "hello"},
{"$A $B $C", "x"},
{"no vars here", ""},
{"$$double", "x"},
{"$", "x"},
{"${", "x"},
{"${}", "x"},
{"prefix_$VAR_suffix", "mid"},
{"$1INVALID", "x"},
}
for _, s := range seeds {
f.Add(s.s, s.val)
}
f.Fuzz(func(t *testing.T, s, val string) {
vars := map[string]string{"VAR": val, "A": val, "B": val, "C": val}
_ = expandVarRefs(s, vars)
})
}
+227 -99
View File
@@ -19,18 +19,121 @@ 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 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)
subStageGap = 20 // horizontal gap between sub-columns within the same stage
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 }
// column represents one vertical stack of job chips in the SVG.
// A declared GitLab stage maps to one column unless jobs within it have
// same-stage needs:, in which case it splits into topological sub-columns.
type column struct {
stage string // declared GitLab stage name
jobs []string // job names in this column, topologically ordered
}
// computeColumns assigns jobs to SVG columns.
// When jobs within the same declared stage have needs: relationships between
// each other, the stage is split into topological sub-columns so that
// depended-upon jobs appear to the left of the jobs that depend on them.
func computeColumns(stages []string, byStage map[string][]string, jobs map[string]model.Job) []column {
var cols []column
for _, stage := range stages {
stageJobs := byStage[stage]
sort.Strings(stageJobs)
if len(stageJobs) == 0 {
continue
}
// Index same-stage membership.
stageSet := make(map[string]bool, len(stageJobs))
for _, j := range stageJobs {
stageSet[j] = true
}
// Check whether any job has a same-stage need.
hasIntraNeeds := false
outer:
for _, j := range stageJobs {
for _, need := range jobs[j].Needs {
if dep := needsJobName(need); dep != "" && stageSet[dep] {
hasIntraNeeds = true
break outer
}
}
}
if !hasIntraNeeds {
cols = append(cols, column{stage: stage, jobs: stageJobs})
continue
}
// Compute topological depth within the stage.
depth := make(map[string]int, len(stageJobs))
for _, j := range stageJobs {
depth[j] = -1
}
inProgress := make(map[string]bool, len(stageJobs))
var computeDepth func(j string) int
computeDepth = func(j string) int {
if depth[j] >= 0 {
return depth[j]
}
if inProgress[j] {
return 0 // cycle guard (cycles already rejected by GL029)
}
inProgress[j] = true
maxDep := -1
for _, need := range jobs[j].Needs {
dep := needsJobName(need)
if dep == "" || !stageSet[dep] {
continue
}
d := computeDepth(dep)
if d > maxDep {
maxDep = d
}
}
depth[j] = maxDep + 1
return depth[j]
}
for _, j := range stageJobs {
if depth[j] < 0 {
computeDepth(j)
}
}
// One sub-column per topological depth level.
maxDepth := 0
for _, j := range stageJobs {
if depth[j] > maxDepth {
maxDepth = depth[j]
}
}
for l := 0; l <= maxDepth; l++ {
var levelJobs []string
for _, j := range stageJobs {
if depth[j] == l {
levelJobs = append(levelJobs, j)
}
}
if len(levelJobs) > 0 {
sort.Strings(levelJobs)
cols = append(cols, column{stage: stage, jobs: levelJobs})
}
}
}
return cols
}
// 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, ctx *cicontext.Context) (string, error) { func RenderPipeline(p *model.Pipeline, outDir string, ctx *cicontext.Context) (string, error) {
@@ -148,28 +251,42 @@ func pipelineSVG(p *model.Pipeline, ctx *cicontext.Context) string {
} }
} }
// Compute per-stage column heights and job anchor points for connectors. // ── Column layout ─────────────────────────────────────────────────────────
// Split stages into sub-columns when intra-stage needs exist.
cols := computeColumns(stages, byStage, p.Jobs)
// X position of each column's left edge.
colX := make([]int, len(cols))
if len(cols) > 0 {
colX[0] = sidePad
for i := 1; i < len(cols); i++ {
gap := stageGap
if cols[i].stage == cols[i-1].stage {
gap = subStageGap
}
colX[i] = colX[i-1] + chipW + gap
}
}
// Compute SVG dimensions and per-job connector anchor points.
maxColH := 0 maxColH := 0
rightMid := make(map[string]svgPt) rightMid := make(map[string]svgPt)
leftMid := make(map[string]svgPt) leftMid := make(map[string]svgPt)
for i, stage := range stages { for ci, col := range cols {
jobs := byStage[stage] n := len(col.jobs)
sort.Strings(jobs)
n := len(jobs)
h := n*chipH + max(0, n-1)*chipGap h := n*chipH + max(0, n-1)*chipGap
if h > maxColH { if h > maxColH {
maxColH = h maxColH = h
} }
cx := sidePad + i*(chipW+stageGap) for j, name := range col.jobs {
for j, name := range jobs {
chy := topPad + labelH + j*(chipH+chipGap) chy := topPad + labelH + j*(chipH+chipGap)
rightMid[name] = svgPt{cx + chipW, chy + chipH/2} rightMid[name] = svgPt{colX[ci] + chipW, chy + chipH/2}
leftMid[name] = svgPt{cx, chy + chipH/2} leftMid[name] = svgPt{colX[ci], chy + chipH/2}
} }
} }
svgW := sidePad*2 + len(stages)*chipW + max(0, len(stages)-1)*stageGap svgW := colX[len(cols)-1] + chipW + sidePad
svgH := topPad + labelH + maxColH + botPad svgH := topPad + labelH + maxColH + botPad
// DAG mode: any visible job with a needs: list triggers job-to-job arrows. // DAG mode: any visible job with a needs: list triggers job-to-job arrows.
@@ -199,30 +316,117 @@ func pipelineSVG(p *model.Pipeline, ctx *cicontext.Context) string {
// White page background. // White page background.
wf(` <rect width="%d" height="%d" fill="#ffffff"/>`, svgW, svgH) wf(` <rect width="%d" height="%d" fill="#ffffff"/>`, svgW, svgH)
// ── Stage columns ───────────────────────────────────────────────────────── // ── Stage headers ─────────────────────────────────────────────────────────
for i, stage := range stages { // Each declared stage may span multiple sub-columns; draw one header per stage.
cx := sidePad + i*(chipW+stageGap) drawnHeader := make(map[string]bool)
jobs := byStage[stage] for ci, col := range cols {
sort.Strings(jobs) if drawnHeader[col.stage] {
continue
}
// Span all sub-columns that belong to this stage.
x1 := colX[ci]
x2 := colX[ci] + chipW
for j := ci + 1; j < len(cols) && cols[j].stage == col.stage; j++ {
x2 = colX[j] + chipW
}
centerX := (x1 + x2) / 2
// Stage name small, gray, uppercase, centered above the chip stack. // Stage name small, gray, uppercase.
wf(` <text x="%d" y="%d" text-anchor="middle" `+ wf(` <text x="%d" y="%d" text-anchor="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="11" font-weight="600" letter-spacing="0.8" fill="#868686">%s</text>`, `font-size="11" font-weight="600" letter-spacing="0.8" fill="#868686">%s</text>`,
cx+chipW/2, topPad+13, svgEsc(strings.ToUpper(stage))) centerX, topPad+13, svgEsc(strings.ToUpper(col.stage)))
// Subtle separator line below stage name. // Separator line spanning all sub-columns.
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) x1, topPad+21, x2, topPad+21)
// Job chips each wrapped in a <g> with tooltip metadata. drawnHeader[col.stage] = true
for j, name := range jobs { }
// ── Connectors (drawn before chips so they appear behind job blocks) ───────
const connStroke = "#dbdbdb"
if dagMode {
// Job-to-job bezier curves from needs:.
for _, name := range visible {
for _, need := range p.Jobs[name].Needs {
dep := needsJobName(need)
if dep == "" {
continue
}
src, okS := rightMid[dep]
dst, okD := leftMid[name]
if !okS || !okD {
continue
}
cpX := (src.x + dst.x) / 2
wf(` <path d="M%d,%d C%d,%d %d,%d %d,%d" stroke="%s" stroke-width="2" fill="none"/>`,
src.x, src.y, cpX, src.y, cpX, dst.y, dst.x-7, dst.y, connStroke)
// Small right-pointing triangle arrowhead.
wf(` <polygon points="%d,%d %d,%d %d,%d" fill="%s"/>`,
dst.x-7, dst.y-4, dst.x, dst.y, dst.x-7, dst.y+4, connStroke)
}
}
} else {
// Classic: bus-bar connectors between adjacent stage columns.
// Every job in stage[i] fans to a vertical bus at the midpoint gap,
// then fans out to every job in stage[i+1].
for ci := 0; ci < len(cols)-1; ci++ {
x1 := colX[ci] + chipW // right edge of current column
x2 := colX[ci+1] // left edge of next column
midX := (x1 + x2) / 2
srcJobs := cols[ci].jobs
dstJobs := cols[ci+1].jobs
// Collect all Y midpoints to span the vertical bus bar.
var allYs []int
srcY := make([]int, len(srcJobs))
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 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)
}
}
}
// ── Job chips (drawn after connectors so they appear on top) ─────────────
for ci, col := range cols {
for j, name := range col.jobs {
job := p.Jobs[name] job := p.Jobs[name]
chy := topPad + labelH + j*(chipH+chipGap) chy := topPad + labelH + j*(chipH+chipGap)
isSkipped := skippedJobs[name] isSkipped := skippedJobs[name]
cx := colX[ci]
// Build <desc> content: shown in the HTML sidebar and SVG viewer tooltips. // Build <desc> content: shown in the HTML sidebar and SVG viewer tooltips.
desc := "stage: " + stage desc := "stage: " + col.stage
if job.When != "" { if job.When != "" {
desc += "\nwhen: " + job.When desc += "\nwhen: " + job.When
} }
@@ -285,82 +489,6 @@ func pipelineSVG(p *model.Pipeline, ctx *cicontext.Context) string {
} }
} }
// ── Connectors ────────────────────────────────────────────────────────────
const connStroke = "#dbdbdb"
if dagMode {
// Job-to-job bezier curves from needs:.
for _, name := range visible {
for _, need := range p.Jobs[name].Needs {
dep := needsJobName(need)
if dep == "" {
continue
}
src, okS := rightMid[dep]
dst, okD := leftMid[name]
if !okS || !okD {
continue
}
cpX := (src.x + dst.x) / 2
wf(` <path d="M%d,%d C%d,%d %d,%d %d,%d" stroke="%s" stroke-width="2" fill="none"/>`,
src.x, src.y, cpX, src.y, cpX, dst.y, dst.x-7, dst.y, connStroke)
// Small right-pointing triangle arrowhead.
wf(` <polygon points="%d,%d %d,%d %d,%d" fill="%s"/>`,
dst.x-7, dst.y-4, dst.x, dst.y, dst.x-7, dst.y+4, connStroke)
}
}
} else {
// 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++ {
x1 := sidePad + i*(chipW+stageGap) + chipW // right edge of left column
x2 := sidePad + (i+1)*(chipW+stageGap) // left edge of right column
midX := (x1 + x2) / 2
srcJobs := byStage[stages[i]]
sort.Strings(srcJobs)
dstJobs := byStage[stages[i+1]]
sort.Strings(dstJobs)
// Collect all Y midpoints to span the vertical bus bar.
var allYs []int
srcY := make([]int, len(srcJobs))
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)
}
}
}
// ── Legend ──────────────────────────────────────────────────────────────── // ── Legend ────────────────────────────────────────────────────────────────
legend := []struct{ color, label string }{ legend := []struct{ color, label string }{
{"#1f75cb", "regular"}, {"#1f75cb", "regular"},
+132
View File
@@ -520,6 +520,138 @@ func TestRenderHTML_WriteFileFails(t *testing.T) {
} }
} }
// ── computeColumns ────────────────────────────────────────────────────────────
func TestComputeColumns_NoIntraNeeds(t *testing.T) {
// No intra-stage needs → one column per stage.
stages := []string{"build", "test"}
byStage := map[string][]string{
"build": {"build-a", "build-b"},
"test": {"test-a"},
}
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"},
}
cols := computeColumns(stages, byStage, jobs)
if len(cols) != 2 {
t.Fatalf("expected 2 columns (one per stage), got %d", len(cols))
}
if cols[0].stage != "build" || len(cols[0].jobs) != 2 {
t.Errorf("col[0]: got stage=%q jobs=%v", cols[0].stage, cols[0].jobs)
}
if cols[1].stage != "test" || len(cols[1].jobs) != 1 {
t.Errorf("col[1]: got stage=%q jobs=%v", cols[1].stage, cols[1].jobs)
}
}
func TestComputeColumns_IntraNeeds(t *testing.T) {
// job-b depends on job-a in the same stage → split into 2 sub-columns.
stages := []string{"build"}
byStage := map[string][]string{
"build": {"job-a", "job-b"},
}
jobs := map[string]model.Job{
"job-a": {Name: "job-a", Stage: "build"},
"job-b": {Name: "job-b", Stage: "build", Needs: []any{"job-a"}},
}
cols := computeColumns(stages, byStage, jobs)
if len(cols) != 2 {
t.Fatalf("expected 2 sub-columns, got %d", len(cols))
}
// Both belong to the same stage.
if cols[0].stage != "build" || cols[1].stage != "build" {
t.Error("both sub-columns should be in stage 'build'")
}
// job-a has no same-stage deps → depth 0 → first sub-column.
if len(cols[0].jobs) != 1 || cols[0].jobs[0] != "job-a" {
t.Errorf("col[0] should contain job-a, got %v", cols[0].jobs)
}
// job-b depends on job-a → depth 1 → second sub-column.
if len(cols[1].jobs) != 1 || cols[1].jobs[0] != "job-b" {
t.Errorf("col[1] should contain job-b, got %v", cols[1].jobs)
}
}
func TestComputeColumns_EmptyStageSkipped(t *testing.T) {
// Stages with no jobs are silently skipped.
stages := []string{"build", "empty", "test"}
byStage := map[string][]string{
"build": {"j1"},
"test": {"j2"},
}
jobs := map[string]model.Job{
"j1": {Name: "j1", Stage: "build"},
"j2": {Name: "j2", Stage: "test"},
}
cols := computeColumns(stages, byStage, jobs)
if len(cols) != 2 {
t.Fatalf("expected 2 columns (empty stage skipped), got %d", len(cols))
}
}
func TestComputeColumns_CrossStageNeedIgnored(t *testing.T) {
// job-b has needs: for both job-a (same stage) and prev-job (different stage).
// The cross-stage need must be ignored when computing topological depth.
stages := []string{"build"}
byStage := map[string][]string{
"build": {"job-a", "job-b"},
}
jobs := map[string]model.Job{
"job-a": {Name: "job-a", Stage: "build"},
"job-b": {Name: "job-b", Stage: "build", Needs: []any{"job-a", "prev-job"}},
"prev-job": {Name: "prev-job", Stage: "prepare"},
}
cols := computeColumns(stages, byStage, jobs)
// Still split into 2 sub-columns; the cross-stage need is ignored.
if len(cols) != 2 {
t.Fatalf("expected 2 sub-columns, got %d", len(cols))
}
if cols[0].jobs[0] != "job-a" {
t.Errorf("expected job-a in first sub-column, got %v", cols[0].jobs)
}
if cols[1].jobs[0] != "job-b" {
t.Errorf("expected job-b in second sub-column, got %v", cols[1].jobs)
}
}
func TestComputeColumns_CycleGuard(t *testing.T) {
// Intra-stage cycle must not hang (cycle guard returns depth 0).
stages := []string{"build"}
byStage := map[string][]string{
"build": {"a", "b"},
}
jobs := map[string]model.Job{
"a": {Name: "a", Stage: "build", Needs: []any{"b"}},
"b": {Name: "b", Stage: "build", Needs: []any{"a"}},
}
cols := computeColumns(stages, byStage, jobs)
// Should return without hanging; exact column count is implementation-defined.
if len(cols) == 0 {
t.Error("expected at least one column")
}
}
func TestPipelineSVG_IntraStageDAG(t *testing.T) {
// job-b depends on job-a in the same stage → SVG should render both,
// placed as sub-columns (sub-stage gap between them).
p := &model.Pipeline{
Stages: []string{"build"},
Jobs: map[string]model.Job{
"job-a": {Name: "job-a", Stage: "build"},
"job-b": {Name: "job-b", Stage: "build", Needs: []any{"job-a"}},
},
}
svg := pipelineSVG(p, nil)
if !strings.Contains(svg, "job-a") { t.Error("expected job-a in SVG") }
if !strings.Contains(svg, "job-b") { t.Error("expected job-b in SVG") }
// DAG mode: bezier connectors between the two jobs.
if !strings.Contains(svg, "<path") {
t.Error("expected bezier <path> connector between intra-stage jobs")
}
}
// ── htmlPage ────────────────────────────────────────────────────────────────── // ── htmlPage ──────────────────────────────────────────────────────────────────
func TestHtmlPage(t *testing.T) { func TestHtmlPage(t *testing.T) {
+69
View File
@@ -0,0 +1,69 @@
package linter
import (
"testing"
"git.k3nny.fr/glint/internal/model"
)
// FuzzLint ensures that the full Parse → Lint pipeline never panics on
// arbitrary YAML input. Lint rules type-assert model fields extensively;
// this fuzzer drives those assertions against malformed-but-parseable YAML.
// Run with: go test -fuzz=FuzzLint ./internal/linter/
func FuzzLint(f *testing.F) {
seeds := []string{
// Minimal valid pipeline
"stages: [build]\njob:\n stage: build\n script: echo ok\n",
// Manual / delayed / trigger / on_failure job types
"job:\n script: ok\n when: manual\n",
"job:\n script: ok\n when: delayed\n start_in: 5 minutes\n",
"job:\n trigger:\n project: group/repo\n",
"job:\n script: ok\n when: on_failure\n",
// needs: and dependencies:
"stages: [a,b]\na:\n stage: a\n script: ok\nb:\n stage: b\n needs: [a]\n script: ok\n",
"stages: [a,b]\na:\n stage: a\n script: ok\nb:\n stage: b\n dependencies: [a]\n script: ok\n",
// rules:
"job:\n script: ok\n rules:\n - if: '$CI_COMMIT_BRANCH == \"main\"'\n when: on_success\n",
// parallel matrix
"job:\n script: ok\n parallel:\n matrix:\n - ARCH: [amd64, arm64]\n",
// image as string / map
"job:\n script: ok\n image: golang:1.21\n",
"job:\n script: ok\n image:\n name: golang:1.21\n entrypoint: ['']\n",
// artifacts / cache with both string and map when:
"job:\n script: ok\n artifacts:\n when: on_success\n paths: [dist/]\n",
"job:\n script: ok\n cache:\n key: $CI_COMMIT_REF_SLUG\n paths: [vendor/]\n",
// environment / release / coverage
"job:\n script: ok\n environment:\n name: production\n url: https://example.com\n",
"job:\n script: ok\n release:\n tag_name: $CI_COMMIT_TAG\n description: Release\n",
"job:\n script: ok\n coverage: '/^TOTAL.*?(\\d+%)$/'\n",
// retry / timeout
"job:\n script: ok\n retry: 2\n",
"job:\n script: ok\n timeout: 2h30m\n",
// workflow
"workflow:\n rules:\n - if: '$CI_COMMIT_BRANCH'\n when: always\njob:\n script: ok\n",
// extends
".base:\n script: ok\nchild:\n extends: .base\n stage: test\n",
// id_tokens / secrets
"job:\n script: ok\n id_tokens:\n TOKEN:\n aud: https://example.com\n",
// services
"job:\n script: ok\n services:\n - name: postgres:14\n alias: db\n",
// pages job
"pages:\n script: make docs\n artifacts:\n paths: [public/]\n",
// inherit
"default:\n retry: 1\njob:\n script: ok\n inherit:\n default: false\n",
// allow_failure
"job:\n script: ok\n allow_failure:\n exit_codes: [1, 2]\n",
}
for _, s := range seeds {
f.Add([]byte(s))
}
f.Fuzz(func(t *testing.T, data []byte) {
p, err := model.ParseBytes(data)
if err != nil {
return
}
// Lint must never panic regardless of pipeline content.
_ = Lint(p, nil)
})
}
+78
View File
@@ -0,0 +1,78 @@
package model
import "testing"
// FuzzParseBytes ensures the YAML parser never panics on arbitrary input and
// that successful parses return a structurally sound Pipeline.
// Run with: go test -fuzz=FuzzParseBytes ./internal/model/
// Found failures are saved to testdata/fuzz/FuzzParseBytes/.
func FuzzParseBytes(f *testing.F) {
// Seed corpus: representative inputs covering the main code paths in
// ParseBytes, including the sanitizeYAMLEscapes pre-processing step.
seeds := [][]byte{
{},
[]byte("null"),
[]byte("stages: [build]\nbuild-job:\n stage: build\n script: echo ok\n"),
[]byte(".base:\n script: [make]\nchild:\n extends: .base\n stage: test\n"),
[]byte("stages: [a, b]\njob-a:\n stage: a\n script: run\njob-b:\n stage: b\n needs: [job-a]\n script: run\n"),
[]byte("*undefined_anchor"),
[]byte("- item1\n- item2\n"),
[]byte("my-job: \"just a string\"\n"),
[]byte("my-job:\n stage: [build, test]\n"),
[]byte("# glint: ignore GL007\nlegacy:\n only: [main]\n script: ok\n"),
[]byte("workflow:\n rules:\n - if: '$CI_COMMIT_BRANCH == \"main\"'\n when: always\njob:\n script: echo ok\n"),
[]byte("include:\n - local: other.yml\njob:\n script: echo ok\n"),
[]byte("job:\n script: echo ok\n when: on_failure\n rules:\n - if: '$VAR =~ /^us\\//'\n"),
[]byte("job:\n stage: test\n image:\n name: golang:1.21\n entrypoint: ['']\n parallel:\n matrix:\n - PLATFORM: [linux, darwin]\n script: go build\n"),
[]byte("default:\n retry: 2\n timeout: 1h30m\nvariables:\n ENV: production\nstages: [build, test, deploy]\n"),
[]byte("&anchor\n script: [echo ok]\njob:\n <<: *anchor\n stage: build\n"),
[]byte("?"), // null/empty YAML key — must error, not produce an empty-named job
}
for _, s := range seeds {
f.Add(s)
}
f.Fuzz(func(t *testing.T, data []byte) {
p, err := ParseBytes(data)
if err != nil {
return // errors are acceptable; panics are not
}
if p == nil {
t.Fatal("ParseBytes returned nil pipeline with nil error")
}
for name := range p.Jobs {
if name == "" {
t.Fatal("ParseBytes produced a job with an empty name")
}
}
})
}
// FuzzSanitizeYAMLEscapes ensures the escape sanitizer never panics and never
// produces output shorter than its input (it can only expand \/ to \\/).
func FuzzSanitizeYAMLEscapes(f *testing.F) {
seeds := [][]byte{
{},
[]byte("stage: build"),
[]byte(`if: "$CI_BRANCH =~ /^us\//"`),
[]byte(`"pattern: /^us\//"`),
[]byte(`'single quoted \/ unchanged'`),
[]byte(`"\n\t\r"`),
[]byte(`"nested \"quote\" inside"`),
[]byte(`'it''s fine'`),
[]byte(`"unclosed`),
{'"', '\\'}, // double-quoted string ending with a lone backslash
{'"', '\\', '/'}, // the exact sequence being rewritten
}
for _, s := range seeds {
f.Add(s)
}
f.Fuzz(func(t *testing.T, data []byte) {
out := sanitizeYAMLEscapes(data)
if len(out) < len(data) {
t.Fatalf("sanitizeYAMLEscapes shrank output: input len=%d output len=%d\ninput: %q",
len(data), len(out), data)
}
})
}
+3
View File
@@ -57,6 +57,9 @@ func ParseBytes(data []byte) (*Pipeline, error) {
keyNode := root.Content[i] keyNode := root.Content[i]
valNode := root.Content[i+1] valNode := root.Content[i+1]
key := keyNode.Value key := keyNode.Value
if key == "" {
return nil, fmt.Errorf("job name cannot be empty (null or missing YAML key)")
}
if ReservedKeys[key] { if ReservedKeys[key] {
continue continue
} }
+6
View File
@@ -88,6 +88,12 @@ func TestParseBytes_EdgeCases(t *testing.T) {
if err == nil { if err == nil {
t.Error("wrong field type: expected error from ParseBytes (stage must be string)") t.Error("wrong field type: expected error from ParseBytes (stage must be string)")
} }
// Null/empty YAML key (e.g. bare "?"): job name cannot be empty.
_, err = ParseBytes([]byte("?"))
if err == nil {
t.Error("null key: expected error from ParseBytes (job name cannot be empty)")
}
} }
// TestParse_ParseBytesError exercises the Parse → ParseBytes error path (line 18). // TestParse_ParseBytesError exercises the Parse → ParseBytes error path (line 18).
@@ -0,0 +1,2 @@
go test fuzz v1
[]byte("?")
+62
View File
@@ -0,0 +1,62 @@
# glint — GitLab CI/CD Catalog component
#
# Validates a pipeline file with glint before the rest of the pipeline runs.
#
# Usage (after publishing to a GitLab instance as a Catalog component):
#
# include:
# - component: $CI_SERVER_FQDN/k3nny/glint/check@v0.2.28
# inputs:
# stage: validate # optional — see inputs below
#
# Or as a plain remote include (no Catalog required):
#
# include:
# - remote: https://raw.githubusercontent.com/.../templates/check.yml
#
# Or copy this file into your repository and use a local include.
spec:
inputs:
stage:
description: "Stage in which to run the glint validation job."
default: validate
pipeline_file:
description: "Path to the pipeline file to validate."
default: .gitlab-ci.yml
version:
description: >-
glint release tag to download (e.g. 'v0.2.28'). Use 'latest' to
always pull the newest release — not recommended for production
pipelines since it may break on a new release.
default: latest
allow_failure:
description: "Set to true to let the job fail without blocking the pipeline."
default: false
extra_args:
description: "Additional arguments passed to 'glint check' (e.g. '--format sarif')."
default: ""
---
glint:check:
stage: $[[ inputs.stage ]]
image: alpine:3.19
variables:
GLINT_VERSION: "$[[ inputs.version ]]"
GLINT_FILE: "$[[ inputs.pipeline_file ]]"
GLINT_ARGS: "$[[ inputs.extra_args ]]"
before_script:
- apk add --no-cache curl
- |
if [ "$GLINT_VERSION" = "latest" ]; then
GLINT_VERSION=$(curl -sf \
"https://git.k3nny.fr/api/v1/repos/k3nny/glint/releases?limit=1" \
| grep '"tag_name"' | head -1 | cut -d'"' -f4)
fi
URL="https://git.k3nny.fr/k3nny/glint/releases/download/${GLINT_VERSION}/glint-${GLINT_VERSION}-linux-amd64"
curl -sfL "$URL" -o /usr/local/bin/glint
chmod +x /usr/local/bin/glint
glint --version
script:
- glint check $GLINT_ARGS "$GLINT_FILE"
allow_failure: $[[ inputs.allow_failure ]]