Compare commits

..

24 Commits

Author SHA1 Message Date
k3nny ef0d7b118a docs(docs): update CHANGELOG and README badge for v0.4.1
ci / vet, staticcheck, test, build (push) Successful in 3m4s
release / Build and publish release (push) Successful in 2m52s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 23:58:14 +02:00
k3nny b222105e1f build(build): add Linux arm64, macOS amd64/arm64 to release CI
ci / vet, staticcheck, test, build (push) Successful in 2m10s
- Add build steps for linux/arm64, darwin/amd64, darwin/arm64
- Rename Windows output to glint-<tag>-windows-amd64.exe (consistent
  with Taskfile and INSTALL.md)
- Add -X main.version=<tag> to all ldflags (was missing, causing
  release binaries to report "dev" as version)
- Use $TAG variable in upload loop instead of repeating the expression

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 23:56:42 +02:00
k3nny a8fadd4dd4 docs(docs): update CHANGELOG, README, ROADMAP, Formula, and INSTALL for v0.4.0
ci / vet, staticcheck, test, build (push) Successful in 2m2s
release / Build and publish release (push) Successful in 1m17s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 23:49:23 +02:00
k3nny 2b32267015 feat(build): add multi-platform release targets, Homebrew formula, and INSTALL.md
ci / vet, staticcheck, test, build (push) Successful in 2m13s
Taskfile:
- Add build-linux-arm64, build-darwin-amd64, build-darwin-arm64 targets
- Rename build-linux to build-linux-amd64 (keep build-linux alias)
- Rename Windows output to glint-<tag>-windows-amd64.exe for consistency
- Add build-release task that builds all five platforms in one shot

Formula/glint.rb:
- Homebrew source-build formula; depends_on "go" => :build
- tap: brew tap k3nny/glint https://github.com/k3nny/homebrew-glint
- Includes basic test block (--version + lint a trivial pipeline)

INSTALL.md:
- Pre-built binary download instructions for all five platforms
- Homebrew tap setup and formula update procedure
- go install one-liner
- Link to README integrations section

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 23:45:20 +02:00
k3nny 4f6855b9ab feat(cli): inject GitLab predefined variables into the simulation context
ci / vet, staticcheck, test, build (push) Successful in 2m5s
Two categories of predefined variables are now injected automatically:

1. Always-available (CI=true, GITLAB_CI=true): set at lowest priority for
   every non-empty context so that rules:if: expressions like '$CI == "true"'
   evaluate correctly without requiring --var.

2. MR-specific (CI_MERGE_REQUEST_IID, CI_MERGE_REQUEST_SOURCE_BRANCH_NAME,
   CI_MERGE_REQUEST_TARGET_BRANCH_NAME, …): injected as placeholder values
   when --source merge_request_event is given, so MR-gated jobs evaluate
   as active rather than silently skipped. CI_MERGE_REQUEST_SOURCE_BRANCH_NAME
   is derived from --branch when provided.

All injected defaults are non-pinned: --var overrides them, and pipeline
variables: blocks can also override via Inject(). The empty context (no
flags at all) is unchanged — no predefined vars are injected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 23:30:25 +02:00
k3nny 3b4f49bbe5 feat(cli): auto-detect git branch as default context
ci / vet, staticcheck, test, build (push) Successful in 2m28s
When no --branch, --tag, --source, or --var flags are given, glint now
runs "git rev-parse --abbrev-ref HEAD" in the pipeline file's directory
to determine the current branch. Falls back to "main" when the directory
is not inside a git repository or the repo is in detached-HEAD state.

This makes implicit context simulation accurate without requiring users
to pass --branch on every invocation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 23:18:51 +02:00
k3nny 1df72a5124 docs(docs): update CHANGELOG and README badge for v0.3.1
ci / vet, staticcheck, test, build (push) Successful in 2m29s
release / Build and publish release (push) Successful in 1m13s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 22:54:15 +02:00
k3nny 5ace9d5756 feat(cli): make tree the sole default for glint graph
ci / vet, staticcheck, test, build (push) Successful in 2m16s
Previously glint graph with no mode printed tree + separator + includes
Mermaid. Now glint graph defaults to tree only; use glint graph includes
for the Mermaid include-dependency output. Simplifies the common case
and makes the default output immediately actionable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 22:51:44 +02:00
k3nny 8449a9317c docs(cli): document missing --proxy, --cache-dir, --offline flags in --help
ci / vet, staticcheck, test, build (push) Successful in 2m8s
glint check --help was missing --proxy.
glint graph --help was missing --cache-dir, --offline, and --proxy.
All three flags were already implemented; only the Usage text was absent.

Also added --no-warn, --no-skipped, and --proxy examples to the
respective command example sections.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 22:49:39 +02:00
k3nny 263bbbd1ed docs(docs): sync README and FEATURES with current CLI
ci / vet, staticcheck, test, build (push) Successful in 1m55s
README: add render command, fix exit code descriptions (2/10 not 1),
bump integration version references to v0.3.0, fix Usage command list.

FEATURES: document glint render section, colorized text output format,
exit code table, --no-warn, --no-skipped, --list-vars in developer
tools table, bump JSON schema example to v0.3.0 with column field.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 22:47:08 +02:00
k3nny a68993d26f test(config): skip permission test when running as root
ci / vet, staticcheck, test, build (push) Successful in 1m58s
The Gitea CI runner executes as root, which bypasses filesystem
permission checks. The TestLoad_ReadError test creates a mode-0000 file
and expects a read error, but root can always read files regardless of
mode. Skip the test under root instead of removing it so it still runs
in restricted environments.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 22:41:30 +02:00
k3nny 7f404f3492 docs(docs): update README, CHANGELOG, ROADMAP, Taskfile for v0.3.0
ci / vet, staticcheck, test, build (push) Failing after 2m10s
release / Build and publish release (push) Successful in 1m12s
Document glint render, --no-warn, exit codes 2/10, --no-skipped, and
colorized output. Fix Taskfile validate entries: fixtures that now exit
10 (warnings only) require ignore_error: true to pass the validate task.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 22:35:55 +02:00
k3nny 1615655c00 feat(cli): --no-warn, new exit codes, glint render, and graph --no-skipped
Exit codes (breaking change from exit 1):
- 0  clean (no findings)
- 2  one or more errors
- 10 one or more warnings, no errors
Errors take precedence over warnings.

glint check --no-warn:
  Discard warning findings before output and exit-code calculation.
  Mixed pipelines (errors + warnings) still exit 2 but only errors print.
  Warnings-only pipelines exit 0 with "OK" when --no-warn is set.

glint render <PIPELINE>:
  New subcommand. Resolves all include: and extends: chains, then writes
  a single flat .gitlab-ci.yml (default: rendered.gitlab-ci.yml, or
  stdout with --output -). Strips consumed keys (include:, extends:).
  Template jobs (.) are retained. Flags: --output, --token, --gitlab-url,
  --cache-dir, --offline, --proxy (all with .glint.yml fallback).
  To support render, Resolve() now writes the merged raw map back to
  p.RawJobs[name] and also preserves j.Column from the original job.

glint graph --no-skipped:
  Removes jobs that evaluate to JobSkipped in the given context before
  any graph function sees the pipeline. Works for tree, pipeline (SVG,
  HTML, Mermaid), includes, and all modes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 22:27:12 +02:00
k3nny 8c3605ed52 feat(cli): colorized, columnized text output with line:col locations
Replace the per-line fmt.Println loop with writeTextFindings() in
cmd/glint/output.go. The new renderer:

- Aligns all findings into four space-separated columns: location,
  rule ID, severity, message — widths computed from the full finding
  set so all lines are flush
- Colors severity words when stdout is a TTY and NO_COLOR is not set:
  red+bold for "error", orange+bold for "warning"; location dimmed;
  rule ID bold
- Formats location as file:line:col when column is known, file:line
  otherwise, falling back to just file

To populate column numbers, add Column int to model.Job (set from
keyNode.Column in the YAML parser) and Finding.Column (set during
the checkJob source-location attachment pass and in the ten cross-job
check sites that explicitly set Line: job.Line).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 22:13:52 +02:00
k3nny f79c64cd44 feat(security): security hardening, proxy support, and GL045 HTTP include warning
ci / vet, staticcheck, test, build (push) Failing after 2m32s
release / Build and publish release (push) Successful in 1m17s
Security fixes:
- Path traversal guard in include: local: — paths with ../ that escape
  the repo root are rejected instead of reading arbitrary host files
- HTTP timeout (30 s) on all fetcher requests to prevent indefinite hangs
- Response size cap (10 MiB) via io.LimitReader to prevent memory exhaustion
- Cache directory and file permissions tightened to 0700/0600
- LSP Content-Length cap (64 MiB) to guard against DoS from a malicious client

New feature:
- --proxy flag on check, graph, and lsp subcommands; also proxy: key in
  .glint.yml; overrides system HTTP_PROXY / HTTPS_PROXY env vars when set;
  cmdGraph and cmdLSP now also load .glint.yml for proxy/token/url fallbacks

New lint rule:
- GL045 (Warning): include: remote: using plain http:// instead of https://

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 21:52:57 +02:00
k3nny 4972adb213 feat(cli): add VS Code extension wrapping glint lsp
ci / vet, staticcheck, test, build (push) Failing after 2m27s
release / Build and publish release (push) Successful in 1m19s
editors/vscode/ is a TypeScript VS Code extension that starts glint lsp
as a child process and connects to it via vscode-languageclient. The
documentSelector restricts LSP processing to **/.gitlab-ci.yml so other
YAML files are unaffected. The glint.executablePath setting controls the
binary location (default: glint on PATH). Build with task ext-compile;
package as .vsix with task ext-package. Taskfile tasks added:
ext-install, ext-compile, ext-package. Root .gitignore updated to
exclude node_modules/, out/, and *.vsix from the extension directory.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 21:26:23 +02:00
k3nny 2c45b343c2 feat(lsp): add Language Server Protocol server (glint lsp)
ci / vet, staticcheck, test, build (push) Failing after 2m8s
release / Build and publish release (push) Successful in 1m7s
New internal/lsp package implements a minimal JSON-RPC 2.0 LSP server
over stdin/stdout with Content-Length framing. Supported lifecycle:
initialize → initialized → shutdown → exit. Document sync: Full (sends
complete text on every change). Handles textDocument/didOpen,
didChange, didSave, didClose; publishes textDocument/publishDiagnostics
after every change. Rule IDs surface as the diagnostic `code` field
with `"glint"` as source. Parse errors produce an Error diagnostic at
the top of the document. Include resolution is best-effort (GITLAB_TOKEN
env var; ~/.cache/glint default cache). CLI: glint lsp [--token]
[--gitlab-url] [--cache-dir] [--offline].

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 01:01:41 +02:00
k3nny 8e76caddb2 chore(build): update Go dependencies
ci / vet, staticcheck, test, build (push) Failing after 1m53s
- honnef.co/go/tools v0.7.0 → v0.8.0-rc.1 (staticcheck)
- golang.org/x/mod v0.31.0 → v0.35.0
- golang.org/x/sync v0.19.0 → v0.20.0
- golang.org/x/tools → v0.44.1-20260420
- gopkg.in/yaml.v3 promoted to direct require

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 00:22:42 +02:00
k3nny 6f8d47a8de docs(docs): convert ROADMAP from strikethrough to checkbox format
ci / vet, staticcheck, test, build (push) Failing after 1m55s
Replace ~~text~~ / ✓ shipped notation with [x] / [ ] GitHub-flavoured
markdown checkboxes throughout ROADMAP.md. Completed items are [x],
pending items are [ ]. Content and version references unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 00:16:39 +02:00
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
59 changed files with 5880 additions and 230 deletions
+44 -4
View File
@@ -32,18 +32,53 @@ jobs:
GOARCH: amd64 GOARCH: amd64
CGO_ENABLED: "0" CGO_ENABLED: "0"
run: | run: |
go build -trimpath -ldflags="-s -w" \ go build -trimpath \
-ldflags="-s -w -X main.version=${{ github.ref_name }}" \
-o glint-${{ github.ref_name }}-linux-amd64 \ -o glint-${{ github.ref_name }}-linux-amd64 \
./cmd/glint/... ./cmd/glint/...
- name: Build Linux (arm64)
env:
GOOS: linux
GOARCH: arm64
CGO_ENABLED: "0"
run: |
go build -trimpath \
-ldflags="-s -w -X main.version=${{ github.ref_name }}" \
-o glint-${{ github.ref_name }}-linux-arm64 \
./cmd/glint/...
- name: Build macOS (amd64)
env:
GOOS: darwin
GOARCH: amd64
CGO_ENABLED: "0"
run: |
go build -trimpath \
-ldflags="-s -w -X main.version=${{ github.ref_name }}" \
-o glint-${{ github.ref_name }}-darwin-amd64 \
./cmd/glint/...
- name: Build macOS (arm64)
env:
GOOS: darwin
GOARCH: arm64
CGO_ENABLED: "0"
run: |
go build -trimpath \
-ldflags="-s -w -X main.version=${{ github.ref_name }}" \
-o glint-${{ github.ref_name }}-darwin-arm64 \
./cmd/glint/...
- name: Build Windows (amd64) - name: Build Windows (amd64)
env: env:
GOOS: windows GOOS: windows
GOARCH: amd64 GOARCH: amd64
CGO_ENABLED: "0" CGO_ENABLED: "0"
run: | run: |
go build -trimpath -ldflags="-s -w" \ go build -trimpath \
-o glint-${{ github.ref_name }}.exe \ -ldflags="-s -w -X main.version=${{ github.ref_name }}" \
-o glint-${{ github.ref_name }}-windows-amd64.exe \
./cmd/glint/... ./cmd/glint/...
- name: Create release and upload assets - name: Create release and upload assets
@@ -60,7 +95,12 @@ jobs:
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"draft\":false,\"prerelease\":false}" \ -d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"draft\":false,\"prerelease\":false}" \
| jq -r .id) | jq -r .id)
for file in glint-${{ github.ref_name }}-linux-amd64 glint-${{ github.ref_name }}.exe; do for file in \
glint-${TAG}-linux-amd64 \
glint-${TAG}-linux-arm64 \
glint-${TAG}-darwin-amd64 \
glint-${TAG}-darwin-arm64 \
glint-${TAG}-windows-amd64.exe; do
curl -sf -X POST \ curl -sf -X POST \
-H "Authorization: token $TOKEN" \ -H "Authorization: token $TOKEN" \
-H "Content-Type: application/octet-stream" \ -H "Content-Type: application/octet-stream" \
+5
View File
@@ -33,6 +33,11 @@ coverage.txt
*.swo *.swo
*~ *~
# VS Code extension build artifacts
editors/vscode/node_modules/
editors/vscode/out/
editors/vscode/*.vsix
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db
+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'
+102
View File
@@ -5,6 +5,108 @@ 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.4.1] - 2026-06-26
### Fixed
- **Release CI** — added Linux arm64, macOS Intel, and macOS Apple Silicon build steps; renamed Windows output to `glint-<tag>-windows-amd64.exe` for consistency; added missing `-X main.version=<tag>` ldflags so release binaries report the correct version instead of `"dev"`.
## [0.4.0] - 2026-06-26
### Added
- **Git branch auto-detection** — `glint check` and `glint graph` now run `git rev-parse --abbrev-ref HEAD` in the pipeline file's directory to determine the current branch when no `--branch`/`--tag`/`--source`/`--var` flags are given. Falls back to `main` when not inside a git repository or in detached-HEAD state (e.g. CI runners).
- **GitLab predefined variable injection** — two categories of predefined variables are now present in the simulation context automatically:
- `CI=true` and `GITLAB_CI=true` are injected for every non-empty context, so expressions like `$CI == "true"` evaluate correctly without `--var`.
- MR-specific variables (`CI_MERGE_REQUEST_IID`, `CI_MERGE_REQUEST_SOURCE_BRANCH_NAME`, `CI_MERGE_REQUEST_TARGET_BRANCH_NAME`, and four others) are injected as placeholders when `--source merge_request_event` is given. `CI_MERGE_REQUEST_SOURCE_BRANCH_NAME` is derived from `--branch` when provided. All injected defaults can be overridden with `--var`.
- **Multi-platform release binaries** — `task build-release` now builds for all five platforms at once: Linux amd64, Linux arm64, macOS Intel, macOS Apple Silicon, Windows x86-64. Individual targets: `task build-linux-amd64`, `task build-linux-arm64`, `task build-darwin-amd64`, `task build-darwin-arm64`, `task build-windows`.
- **Homebrew formula** — `Formula/glint.rb` is a source-build Homebrew formula. Set up a tap at `k3nny/homebrew-glint` on GitHub, then install with `brew tap k3nny/glint https://github.com/k3nny/homebrew-glint && brew install glint`.
- **`INSTALL.md`** — installation guide covering pre-built binary download for all platforms, Homebrew tap, `go install`, and build-from-source with `/usr/local/bin` placement.
## [0.3.1] - 2026-06-26
### Changed
- **`glint graph` default mode** — no-mode invocation now prints the job tree only (previously printed tree + `---` separator + Mermaid include graph). Use `glint graph includes` for the Mermaid include-dependency output.
### Fixed
- **`glint check --help`** — `--proxy` option was implemented but missing from the help text.
- **`glint graph --help`** — `--cache-dir`, `--offline`, and `--proxy` options were implemented but missing from the help text.
- **CI test** — `TestLoad_ReadError` skipped when running as root; Gitea runners execute as root which bypasses file permission checks, causing the test to fail.
## [0.3.0] - 2026-06-26
### Added
- **`glint render` subcommand** — resolves all `include:` and `extends:` chains and writes the fully flattened pipeline to a single YAML file (default: `rendered.gitlab-ci.yml`; use `--output -` for stdout). Strips the consumed `include:` and `extends:` keys; retains template jobs (`.name`). Accepts the same network flags as `glint check` (`--token`, `--gitlab-url`, `--cache-dir`, `--offline`, `--proxy`). Useful for inspecting what GitLab CI actually sees or running further local tooling.
- **`glint check --no-warn`** — discard all warning findings before output and exit-code calculation. Mixed pipelines (errors + warnings) still exit 2 but only errors are printed. Warnings-only pipelines report "OK" and exit 0.
- **`glint graph --no-skipped`** — remove jobs that evaluate to `skipped` in the given context (or the implicit `branch=main` default) from all graph output: tree, SVG, HTML, and Mermaid.
- **Colorized, columnized text output** — `glint check` (text format) now renders findings in four aligned columns: location, rule ID, severity, message. `error` is printed in bold red; `warning` in bold orange. Colors are auto-detected (stdout must be a terminal) and suppressed when `NO_COLOR` is set. Location uses `file:line:col` format when column information is available.
- **`line:col` locations** — `Column int` added to `model.Job` (set from the YAML parser's `yaml.Node.Column`) and propagated to `Finding.Column` across all linter rules. Plain-text and `Finding.String()` now emit `file:line:col` when column is known.
### Changed
- **Exit codes** *(breaking)*`glint check` now exits `2` when one or more error findings are present (previously `1`) and `10` when findings contain only warnings. Exit `0` remains for a clean pipeline. Errors take precedence over warnings. Scripts that test `[ $? -eq 1 ]` need to be updated to `[ $? -eq 2 ]`.
## [0.2.31] - 2026-06-26
### Added
- **Proxy support** — `--proxy <URL>` flag on `glint check`, `glint graph`, and `glint lsp`; also configurable via `proxy:` in `.glint.yml`. When set it takes precedence over `HTTP_PROXY` / `HTTPS_PROXY` env vars; when unset, system proxy settings are honoured automatically. Covers all remote include fetches and GitLab API calls.
- **GL045: HTTP remote include warning** — new pipeline-level lint rule that warns when `include: remote:` uses a plain `http://` URL. CI templates fetched over unencrypted HTTP are at risk of in-transit tampering; `https://` is always preferred.
### Fixed
- **Path traversal in local includes** — `include: local:` paths containing `../` sequences that would escape the repository root (e.g. `../../etc/passwd`) are now rejected with a warning instead of reading arbitrary files from the host.
- **HTTP timeout on remote fetches** — all HTTP calls in `internal/fetcher` now use a 30-second timeout; previously the client had no timeout and could hang indefinitely on slow or unresponsive servers.
- **Unbounded response size** — remote include and GitLab API responses are now capped at 10 MiB using `io.LimitReader`; previously an arbitrarily large response could exhaust process memory.
- **Cache file permissions** — the cache directory is created with mode `0700` (was `0755`) and cache files with `0600` (was `0644`), preventing other local users from reading cached GitLab tokens or pipeline content.
- **LSP Content-Length DoS** — `glint lsp` now rejects incoming messages whose `Content-Length` header exceeds 64 MiB, preventing memory exhaustion from a malicious or misbehaving LSP client.
## [0.2.30] - 2026-06-26
### Added
- **VS Code extension** (`editors/vscode/`) — TypeScript extension that starts `glint lsp` as a language server and connects to it via `vscode-languageclient`. Activates on YAML files; the `documentSelector` restricts LSP processing to `**/.gitlab-ci.yml` so other YAML files are unaffected. Inline error/warning squiggles appear on every save or edit. The `glint.executablePath` setting controls the binary path (default: `glint` on `PATH`). Build: `task ext-compile`; package as `.vsix`: `task ext-package`.
## [0.2.29] - 2026-06-26
### Added
- **LSP server** (`glint lsp`) — new `internal/lsp` package and `glint lsp` subcommand that starts a Language Server Protocol server over stdin/stdout using Content-Lengthframed JSON-RPC 2.0. Editors (VS Code, Neovim, Emacs, JetBrains, etc.) can connect with any generic LSP client configuration. Supported methods: `initialize`, `initialized`, `shutdown`, `exit`, `textDocument/didOpen`, `textDocument/didChange`, `textDocument/didSave`, `textDocument/didClose`. On every document open or change the server runs the full glint lint pipeline and publishes diagnostics via `textDocument/publishDiagnostics`; each diagnostic carries the rule ID as its `code` field and `"glint"` as `source`. Parse errors are surfaced as an Error diagnostic at the top of the document. Include resolution is best-effort (uses `GITLAB_TOKEN` / `GITLAB_URL` env vars; default cache dir `~/.cache/glint`). CLI flags: `--token`, `--gitlab-url`, `--cache-dir`, `--offline`.
## [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 ## [0.2.26] - 2026-06-25
### Changed ### Changed
+56 -11
View File
@@ -7,7 +7,7 @@ For planned work see [ROADMAP.md](ROADMAP.md).
## Lint rules ## Lint rules
Every finding carries a stable rule ID (`GL001` `GL043`) that can be used to Every finding carries a stable rule ID (`GL001` `GL045`) that can be used to
suppress, filter, or look up the check. Run `glint explain <ID>` for a suppress, filter, or look up the check. Run `glint explain <ID>` for a
description, bad-YAML example, and fix. description, bad-YAML example, and fix.
@@ -19,6 +19,7 @@ description, bad-YAML example, and fix.
| GL002 | ERR | `workflow.rules[*].when` must be `always` or `never` | | GL002 | ERR | `workflow.rules[*].when` must be `always` or `never` |
| GL036 | ERR | `default.timeout` is not a valid GitLab CI duration string | | GL036 | ERR | `default.timeout` is not a valid GitLab CI duration string |
| GL040 | WARN | A stage name appears more than once in `stages:` | | GL040 | WARN | A stage name appears more than once in `stages:` |
| GL045 | WARN | `include: remote:` uses plain `http://` — CI templates fetched unencrypted; prefer `https://` |
### Job structure ### Job structure
@@ -117,6 +118,30 @@ Jobs whose name starts with `.` are reusable templates; most rules are skipped f
--- ---
## Pipeline rendering (`glint render`)
`glint render <PIPELINE>` resolves all `include:` and `extends:` chains and
writes the fully flattened pipeline to a single YAML file. This is what GitLab
CI processes server-side.
```bash
glint render .gitlab-ci.yml # writes rendered.gitlab-ci.yml
glint render --output merged.yml .gitlab-ci.yml # custom output path
glint render --output - .gitlab-ci.yml | yq . # stream to stdout
glint render --offline --cache-dir ~/.cache/glint .gitlab-ci.yml
```
**Output order:** `stages`, `variables`, `default`, `workflow`, template jobs
(`.name`, alphabetical), then regular jobs (alphabetical). The `include:` key
(consumed by resolution) and `extends:` keys (merged into each job) are
stripped. All other fields are preserved verbatim.
Accepts the same network flags as `glint check`: `--token`, `--gitlab-url`,
`--cache-dir`, `--offline`, `--proxy`. When writing to a file (not stdout)
a summary line is printed to stderr: `rendered: <file> (N job(s), M stage(s))`.
---
## Context simulation ## Context simulation
Pass `--branch`, `--tag`, `--source`, or `--var` to evaluate `rules:if:` and Pass `--branch`, `--tag`, `--source`, or `--var` to evaluate `rules:if:` and
@@ -183,27 +208,37 @@ test-job active active
--- ---
## Output formats ## Output formats (`glint check`)
Pass `--format` to `glint check`. In structured formats the summary line is Pass `--format` to `glint check`. In structured formats the summary line is
written to stderr so stdout contains only the machine-readable payload. written to stderr so stdout contains only the machine-readable payload.
| Format | Flag | Description | | Format | Flag | Description |
|--------|------|-------------| |--------|------|-------------|
| Text (default) | `--format text` | Ruff-style `file:line: RULE [sev] message` | | Text (default) | `--format text` | Four aligned columns (location, rule, severity, message); `error` in bold red, `warning` in bold orange; colors auto-detected (suppressed when `NO_COLOR` is set or stdout is not a terminal) |
| JSON | `--format json` | Stable schema (version 1); `findings` array + `summary` block | | JSON | `--format json` | Stable schema (version 1); `findings` array + `summary` block |
| SARIF 2.1.0 | `--format sarif` | Consumed by GitHub Code Scanning and GitLab SAST | | SARIF 2.1.0 | `--format sarif` | Consumed by GitHub Code Scanning and GitLab SAST |
| JUnit XML | `--format junit` | CI test-report artifact (`artifacts:reports:junit`) | | JUnit XML | `--format junit` | CI test-report artifact (`artifacts:reports:junit`) |
| GitHub annotations | `--format github` | `::error file=…,line=…,title=RULE::message` inline PR comments | | GitHub annotations | `--format github` | `::error file=…,line=…,title=RULE::message` inline PR comments |
**Exit codes:**
| Code | Meaning |
|------|---------|
| `0` | No findings (clean pipeline) |
| `2` | One or more error findings |
| `10` | One or more warning findings, no errors |
Use `--no-warn` to suppress all warnings; a pipeline with only warnings then exits `0`.
**JSON schema (`schema_version: 1`):** **JSON schema (`schema_version: 1`):**
```json ```json
{ {
"schema_version": 1, "schema_version": 1,
"glint_version": "v0.2.20", "glint_version": "v0.3.0",
"pipeline": ".gitlab-ci.yml", "pipeline": ".gitlab-ci.yml",
"findings": [ "findings": [
{"rule":"GL004","severity":"error","file":".gitlab-ci.yml","line":14, {"rule":"GL004","severity":"error","file":".gitlab-ci.yml","line":14,"column":1,
"job":"deploy","message":"stage \"production\" is not defined in 'stages'"} "job":"deploy","message":"stage \"production\" is not defined in 'stages'"}
], ],
"summary": {"total": 1, "errors": 1, "warnings": 0} "summary": {"total": 1, "errors": 1, "warnings": 0}
@@ -243,9 +278,14 @@ url: https://gitlab.example.com
# Default cache directory. # Default cache directory.
cache_dir: ~/.cache/glint cache_dir: ~/.cache/glint
# HTTP proxy for remote includes and GitLab API calls.
# Overrides HTTP_PROXY / HTTPS_PROXY env vars when set.
# Leave empty to use system proxy settings.
proxy: http://proxy.example.com:8080
``` ```
**Priority chain:** `--token`/`--gitlab-url` flags > `.glint.yml` > environment variables. **Priority chain:** `--token`/`--gitlab-url`/`--proxy` flags > `.glint.yml` > environment variables.
### Inline suppression (`# glint: ignore`) ### Inline suppression (`# glint: ignore`)
@@ -277,10 +317,10 @@ jobs or pipeline-level findings.
| Mode | Output | | Mode | Output |
|------|--------| |------|--------|
| `tree` (default) | Terminal job tree: stages as branches, jobs as leaves; annotated with `[manual]`, `[delayed]`, `[trigger]` where applicable | | `tree` *(default)* | Terminal job tree: stages as branches, jobs as leaves; annotated with `[manual]`, `[delayed]`, `[trigger]` where applicable |
| `includes` | Mermaid flowchart to stdout; colour-coded nodes by include type (local, remote, project, component, template) | | `includes` | Mermaid flowchart of include dependencies to stdout; colour-coded by include type (local, remote, project, component, template) |
| `pipeline` | GitLab CI-style SVG/PNG written to `--out` directory (default: `glint-out/`); converted to PNG when `rsvg-convert`, `inkscape`, or `magick` is available | | `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` Mermaid to stdout + `pipeline` SVG/PNG path to stderr |
**`glint graph pipeline --format <FORMAT>`** **`glint graph pipeline --format <FORMAT>`**
@@ -290,6 +330,8 @@ jobs or pipeline-level findings.
| `mermaid` | Print Mermaid flowchart to stdout (paste into [mermaid.live](https://mermaid.live)) | | `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 | | `html` | Write self-contained HTML to `--out` with mouse pan/zoom and a click-to-open job-detail sidebar |
**Context flags** (`--branch`, `--tag`, `--source`, `--var`, `--changes`, `--changes-from`) work on all modes and annotate or colour jobs based on their evaluated state. Use `--no-skipped` to remove jobs that would not run in the given context from all output entirely (tree, SVG, HTML, and Mermaid).
**Visual distinctions in SVG and HTML output:** **Visual distinctions in SVG and HTML output:**
- **Regular** — blue circle with checkmark - **Regular** — blue circle with checkmark
@@ -297,7 +339,7 @@ jobs or pipeline-level findings.
- **Trigger** — purple circle with chevron - **Trigger** — purple circle with chevron
- **Delayed** — yellow circle with clock - **Delayed** — yellow circle with clock
- **`when: on_failure`** — red circle (`#d9534f`) with X mark; dashed chip border - **`when: on_failure`** — red circle (`#d9534f`) with X mark; dashed chip border
- **Skipped** (with `--branch`/`--tag`/`--source` context flags) — grey circle, dimmed job name - **Skipped** (with context flags, without `--no-skipped`) — 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. In classic mode a bus-bar pattern (vertical rail + per-job Bézier connectors. In classic mode a bus-bar pattern (vertical rail + per-job
@@ -312,7 +354,10 @@ shown as a tooltip in SVG viewers and as a sidebar panel in HTML output.
| Tool | Description | | Tool | Description |
|------|-------------| |------|-------------|
| `glint render <PIPELINE>` | Resolve all includes and extends; write the fully merged pipeline to a single YAML file. See [Pipeline rendering](#pipeline-rendering-glint-render) above. |
| `glint explain <RULE>` | Print description, rationale, bad-YAML example, and fix for a rule. Case-insensitive (`gl007` = `GL007`). | | `glint explain <RULE>` | Print description, rationale, bad-YAML example, and fix for a rule. Case-insensitive (`gl007` = `GL007`). |
| `glint explain` | List all rules with ID, severity, and title. | | `glint explain` | List all rules with ID, severity, and title. |
| `--list-vars` | Print all resolved pipeline variables (pipeline + workflow rules + context) to stderr before linting. | | `--no-warn` | (`glint check`) Discard all warning findings before output and exit-code calculation. |
| `--no-skipped` | (`glint graph`) Remove skipped jobs from graph output entirely. |
| `--list-vars` | (`glint check`, `glint graph`) Print all resolved pipeline variables to stderr before continuing. |
| `--version` / `-v` | Print the compiled version string. | | `--version` / `-v` | Print the compiled version string. |
+29
View File
@@ -0,0 +1,29 @@
class Glint < Formula
desc "Local linter and validator for .gitlab-ci.yml pipelines"
homepage "https://git.k3nny.fr/k3nny/glint"
url "https://git.k3nny.fr/k3nny/glint/archive/v0.4.0.tar.gz"
# Update sha256 on each release: sha256sum glint-vX.Y.Z.tar.gz
sha256 ""
license "Apache-2.0"
head "https://git.k3nny.fr/k3nny/glint.git", branch: "main"
depends_on "go" => :build
def install
system "go", "build",
"-ldflags", "-X main.version=#{version}",
"-o", bin/"glint",
"./cmd/glint/..."
end
test do
assert_match version.to_s, shell_output("#{bin}/glint --version")
(testpath/".gitlab-ci.yml").write <<~YAML
stages: [build]
build-job:
stage: build
script: echo ok
YAML
system bin/"glint", "check", ".gitlab-ci.yml"
end
end
+139
View File
@@ -0,0 +1,139 @@
# Installing glint
## Requirements
- Go 1.21 or later (for building from source)
- Any 64-bit Linux, macOS, or Windows system (for pre-built binaries)
---
## Option 1 — Build from source
```bash
git clone https://git.k3nny.fr/k3nny/glint
cd glint
go build -o glint ./cmd/glint/...
sudo mv glint /usr/local/bin/
```
Or with [Task](https://taskfile.dev):
```bash
task build
sudo mv glint /usr/local/bin/
```
---
## Option 2 — Download a pre-built binary
Pre-built binaries are attached to each [release](https://git.k3nny.fr/k3nny/glint/releases).
| Platform | File |
|----------|------|
| Linux x86-64 | `glint-vX.Y.Z-linux-amd64` |
| Linux ARM64 | `glint-vX.Y.Z-linux-arm64` |
| macOS Intel | `glint-vX.Y.Z-darwin-amd64` |
| macOS Apple Silicon | `glint-vX.Y.Z-darwin-arm64` |
| Windows x86-64 | `glint-vX.Y.Z-windows-amd64.exe` |
### Linux (amd64)
```bash
VERSION=v0.4.0
curl -Lo glint https://git.k3nny.fr/k3nny/glint/releases/download/${VERSION}/glint-${VERSION}-linux-amd64
chmod +x glint
sudo mv glint /usr/local/bin/
```
### Linux (ARM64 — Raspberry Pi 4, AWS Graviton, …)
```bash
VERSION=v0.4.0
curl -Lo glint https://git.k3nny.fr/k3nny/glint/releases/download/${VERSION}/glint-${VERSION}-linux-arm64
chmod +x glint
sudo mv glint /usr/local/bin/
```
### macOS (Apple Silicon — M1/M2/M3)
```bash
VERSION=v0.4.0
curl -Lo glint https://git.k3nny.fr/k3nny/glint/releases/download/${VERSION}/glint-${VERSION}-darwin-arm64
chmod +x glint
sudo mv glint /usr/local/bin/
```
### macOS (Intel)
```bash
VERSION=v0.4.0
curl -Lo glint https://git.k3nny.fr/k3nny/glint/releases/download/${VERSION}/glint-${VERSION}-darwin-amd64
chmod +x glint
sudo mv glint /usr/local/bin/
```
### Verify the installation
```bash
glint --version
```
---
## Option 3 — Homebrew (macOS and Linux)
A Homebrew tap is available at `k3nny/glint`.
> **First-time setup:** create a GitHub repository named `homebrew-glint`
> under your account and copy [`Formula/glint.rb`](Formula/glint.rb) into it.
> Users then install via the tap as shown below.
```bash
brew tap k3nny/glint https://github.com/k3nny/homebrew-glint
brew install glint
```
To upgrade:
```bash
brew upgrade glint
```
The formula builds glint from source using Go, which Homebrew provides
automatically as a build dependency. No pre-built binary download is needed.
### Updating the formula on a new release
After tagging a new release, update `Formula/glint.rb`:
1. Compute the tarball checksum:
```bash
curl -sL https://git.k3nny.fr/k3nny/glint/archive/vX.Y.Z.tar.gz | sha256sum
```
2. Update `url` and `sha256` in the formula.
3. Commit and push to `homebrew-glint`.
---
## Option 4 — Go install
If you already have Go 1.21+:
```bash
go install git.k3nny.fr/k3nny/glint/cmd/glint@latest
```
The binary is placed in `$(go env GOPATH)/bin/`. Add that directory to your
`PATH` if it is not already there:
```bash
export PATH="$PATH:$(go env GOPATH)/bin"
```
---
## Integrations
For editor and CI integrations (pre-commit hook, GitLab CI component, GitHub
Actions, VS Code extension) see [README.md](README.md#integrations).
+113 -21
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.26-blue.svg" alt="Release"></a> <a href="CHANGELOG.md"><img src="https://img.shields.io/badge/release-v0.4.1-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.
@@ -15,48 +15,126 @@ A local tool to validate and lint `.gitlab-ci.yml` pipelines without needing a G
## What it does ## What it does
- **Lints** — 43 rules covering pipeline structure, keyword constraints, `needs:`/`dependencies:` graphs, expression reachability, and deprecations (GL001GL043); run `glint explain <ID>` for any rule - **Lints** — 45 rules covering pipeline structure, keyword constraints, `needs:`/`dependencies:` graphs, expression reachability, and deprecations (GL001GL045); run `glint explain <ID>` for any rule
- **Resolves includes** — local files, HTTPS URLs, GitLab project templates, and CI/CD Catalog components, with offline cache support - **Resolves includes** — local files, HTTPS URLs, GitLab project templates, and CI/CD Catalog components, with offline cache support and HTTP proxy support (`--proxy` flag or `proxy:` in `.glint.yml`)
- **Renders merged pipeline** — `glint render` resolves all includes and `extends:` chains into a single flat YAML file, matching what GitLab CI actually processes
- **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, colorized and column-aligned), `json`, `sarif` (GitHub Code Scanning / GitLab SAST), `junit`, `github` (PR annotations); exits `2` on errors, `10` on warnings only
- **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/proxy defaults; `# glint: ignore RULE` for per-job inline suppression; `--no-warn` flag to suppress all warnings
- **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 - **Graph visualization** — `glint graph` prints a terminal job tree (default); `glint graph includes` emits a Mermaid include-dependency graph; `glint graph pipeline` renders a GitLab CI-style SVG/PNG; `--format mermaid` or `--format html` for alternative pipeline output; `--no-skipped` hides jobs that would not run in the given context
- **LSP server** — `glint lsp` starts a Language Server Protocol server over stdin/stdout; connect with any LSP client to get inline diagnostics (rule ID as code, error/warning severity) in VS Code, Neovim, Emacs, JetBrains, etc.
- **VS Code extension** — `editors/vscode/` wraps the LSP server; inline squiggles for every glint rule directly in the editor
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.
## Requirements
- Go 1.21 or later
- [Task](https://taskfile.dev) (optional, for development tasks)
## Installation ## Installation
See [INSTALL.md](INSTALL.md) for all options: pre-built binaries (Linux amd64/arm64, macOS Intel/Apple Silicon, Windows), Homebrew tap, and building from source.
Quick start (Linux/macOS, building from source):
```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/...
sudo mv glint /usr/local/bin/
``` ```
Or with Task: Homebrew:
```bash ```bash
task build brew tap k3nny/glint https://github.com/k3nny/homebrew-glint
brew install glint
``` ```
## Requirements
Go 1.21 or later (when building from source). Pre-built binaries have no runtime dependencies.
## Usage ## Usage
``` ```
glint [OPTIONS] <COMMAND> glint [OPTIONS] <COMMAND>
Commands: Commands:
check Lint a pipeline file — exits 0 (clean) or 1 (errors found) check Lint a pipeline file — exits 0 (clean), 2 (errors), or 10 (warnings only)
render Resolve all includes and extends into a single merged YAML file
graph Visualise the pipeline as a job tree or Mermaid graph graph Visualise the pipeline as a job tree or Mermaid graph
explain Print description and fix for a lint rule explain Show description and fix for a lint rule (e.g. glint explain GL007)
lsp Start a Language Server Protocol server (stdin/stdout)
``` ```
Run `glint <command> --help` for all flags. See [USAGE.md](USAGE.md) for full Run `glint <command> --help` for all flags. See [FEATURES.md](FEATURES.md) for the
examples covering output formats, context simulation, remote includes, cache, complete feature reference.
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.3.0
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.3.0
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.3.0
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).
### VS Code extension
Clone this repository and load the extension from `editors/vscode/`:
```bash
cd editors/vscode
npm install # install dependencies (once)
npm run compile # compile TypeScript → out/
```
Then in VS Code: **Run → Start Debugging** (F5) — this opens an Extension Development Host with glint diagnostics active for any `.gitlab-ci.yml` you open.
Make sure `glint` is on your `PATH`, or set `glint.executablePath` in VS Code settings to the full path of the binary.
To package a `.vsix` for local installation:
```bash
task ext-package # produces glint-X.Y.Z.vsix
code --install-extension glint-X.Y.Z.vsix
```
## Development ## Development
@@ -69,11 +147,25 @@ task test # run Go unit tests
task lint-go # run go vet task lint-go # run go vet
task validate # run the binary against all testdata fixtures task validate # run the binary against all testdata fixtures
task ci # full check: vet → test → build → validate task ci # full check: vet → test → build → validate
task build-windows # cross-compile for Windows x64 (requires a tagged commit → glint-<tag>.exe) task fuzz # run fuzz tests for the YAML parser (Ctrl-C to stop; FUZZ_TIME=60s to set duration)
task build-linux # cross-compile for Linux x64 (requires a tagged commit → glint-<tag>-linux-amd64) task changelog # regenerate CHANGELOG.md from git history via git-cliff
task changelog-next # preview unreleased section (dry-run, no file written)
task ext-install # install VS Code extension npm dependencies
task ext-compile # compile the VS Code extension TypeScript source
task ext-package # package the VS Code extension as a .vsix
task build-linux-amd64 # cross-compile for Linux x86-64 (requires a tagged commit)
task build-linux-arm64 # cross-compile for Linux ARM64 (requires a tagged commit)
task build-darwin-amd64 # cross-compile for macOS Intel (requires a tagged commit)
task build-darwin-arm64 # cross-compile for macOS Apple Silicon (requires a tagged commit)
task build-windows # cross-compile for Windows x86-64 (requires a tagged commit)
task build-release # build all platform binaries at once (requires a tagged commit)
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
``` ```
+91 -72
View File
@@ -8,23 +8,23 @@ This document tracks planned improvements to `glint`. Items are grouped by theme
Pass `--branch`, `--tag`, `--source`, or `--var` to `glint check` or `glint graph` to evaluate `rules:if:` expressions and `only`/`except` filters against a specific pipeline event. Pass `--branch`, `--tag`, `--source`, or `--var` to `glint check` or `glint graph` to evaluate `rules:if:` expressions and `only`/`except` filters against a specific pipeline event.
- ~~**Single-context simulation**~~ — ✓ shipped v0.2.0; `--branch`, `--tag`, `--source`, `--var` flags on both subcommands; jobs classified as active / manual / skipped - [x] **Single-context simulation** shipped v0.2.0; `--branch`, `--tag`, `--source`, `--var` flags on both subcommands; jobs classified as active / manual / skipped
- ~~**`workflow:rules:variables:` propagation**~~ — ✓ shipped post-v0.2.0; variables from the matching workflow rule entry injected into the evaluation context before job `rules:if:` expressions are evaluated - [x] **`workflow:rules:variables:` propagation** shipped post-v0.2.0; variables from the matching workflow rule entry injected into the evaluation context before job `rules:if:` expressions are evaluated
- ~~**Expression evaluator: multi-line `if:` values**~~ — ✓ shipped post-v0.2.0; newlines in block-scalar and folded YAML `if:` values treated as whitespace - [x] **Expression evaluator: multi-line `if:` values** shipped post-v0.2.0; newlines in block-scalar and folded YAML `if:` values treated as whitespace
- ~~**Expression evaluator: `${VAR}` syntax**~~ — ✓ shipped post-v0.2.0; `${CI_COMMIT_BRANCH}` equivalent to `$CI_COMMIT_BRANCH` everywhere - [x] **Expression evaluator: `${VAR}` syntax** shipped post-v0.2.0; `${CI_COMMIT_BRANCH}` equivalent to `$CI_COMMIT_BRANCH` everywhere
- ~~**Expression evaluator: regex flags**~~ — ✓ shipped post-v0.2.0; `/pattern/i`, `/pattern/m`, `/pattern/s` supported - [x] **Expression evaluator: regex flags** shipped post-v0.2.0; `/pattern/i`, `/pattern/m`, `/pattern/s` supported
- ~~**Expression evaluator: variable as regex RHS**~~ — ✓ shipped post-v0.2.0; `$BRANCH =~ $PATTERN` where `$PATTERN` holds a `/regex/` string evaluates correctly - [x] **Expression evaluator: variable as regex RHS** shipped post-v0.2.0; `$BRANCH =~ $PATTERN` where `$PATTERN` holds a `/regex/` string evaluates correctly
- ~~**Expression evaluator: bare `true`/`false` and integer literals**~~ — ✓ shipped post-v0.2.0; `$FLAG == true`, `$COUNT == 4` compare as decimal strings matching GitLab CI behaviour - [x] **Expression evaluator: bare `true`/`false` and integer literals** shipped post-v0.2.0; `$FLAG == true`, `$COUNT == 4` compare as decimal strings matching GitLab CI behaviour
- ~~**Implicit default context**~~ — ✓ shipped v0.2.11; defaults to `--branch main --source push` when no context flag is given, so `rules:if:` is always evaluated - [x] **Implicit default context** shipped v0.2.11; defaults to `--branch main --source push` when no context flag is given, so `rules:if:` is always evaluated
- ~~**`--list-vars` debug flag**~~ — ✓ shipped v0.2.11; prints sorted `KEY=VALUE` of all collected pipeline variables (root file + includes + workflow-rule union + effective context) to stderr - [x] **`--list-vars` debug flag** shipped v0.2.11; prints sorted `KEY=VALUE` of all collected pipeline variables (root file + includes + workflow-rule union + effective context) to stderr
- ~~**Variable expansion**~~ — ✓ shipped v0.2.13; `$VAR`/`${VAR}` references within variable values expanded after all sources are merged; transitive chains resolved; visible in `--list-vars` - [x] **Variable expansion** shipped v0.2.13; `$VAR`/`${VAR}` references within variable values expanded after all sources are merged; transitive chains resolved; visible in `--list-vars`
- ~~**Non-string scalar variables**~~ — ✓ shipped v0.2.13; `BUILD: true`, `RETRIES: 3` rendered and injected correctly instead of being silently dropped - [x] **Non-string scalar variables** shipped v0.2.13; `BUILD: true`, `RETRIES: 3` rendered and injected correctly instead of being silently dropped
- ~~**YAML `\/` escape in double-quoted strings**~~ — ✓ shipped v0.2.13; regex patterns like `/^us\//` in double-quoted `if:` blocks no longer cause a parse error - [x] **YAML `\/` escape in double-quoted strings** shipped v0.2.13; regex patterns like `/^us\//` in double-quoted `if:` blocks no longer cause a parse error
- ~~**Workflow rule strict evaluation**~~ — ✓ shipped v0.2.14; unparseable `if:` skips the rule instead of matching everything; prevents wrong variables being injected - [x] **Workflow rule strict evaluation** shipped v0.2.14; unparseable `if:` skips the rule instead of matching everything; prevents wrong variables being injected
- ~~**Single `=` operator**~~ — ✓ shipped v0.2.14; bare `=` accepted as alias for `==` in `rules:if:` expressions - [x] **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 - [x] **`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 - [x] **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**~~ — ✓ 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 - [x] **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
--- ---
@@ -32,36 +32,37 @@ Pass `--branch`, `--tag`, `--source`, or `--var` to `glint check` or `glint grap
The current rule set covers the most common sources of broken pipelines. These are the gaps most likely to matter in practice. The current rule set covers the most common sources of broken pipelines. These are the gaps most likely to matter in practice.
- ~~**Variable reference validation (GL032)**~~ — ✓ shipped v0.2.11; warns when a `rules:if:` expression references `$VAR` / `${VAR}` not declared anywhere in pipeline YAML; predefined GitLab namespaces (`CI_*`, `GITLAB_*`, …) exempt; variables from included files are also considered - [x] **Variable reference validation (GL032)** shipped v0.2.11; warns when a `rules:if:` expression references `$VAR` / `${VAR}` not declared anywhere in pipeline YAML; predefined GitLab namespaces (`CI_*`, `GITLAB_*`, …) exempt; variables from included files are also considered
- ~~**`rules:if:` static reachability (GL033)**~~ — ✓ shipped v0.2.15; warns when every rule in a job's `rules:` block has `when: never`, making the job permanently excluded from any pipeline run; no `if:` evaluation required - [x] **`rules:if:` static reachability (GL033)** shipped v0.2.15; warns when every rule in a job's `rules:` block has `when: never`, making the job permanently excluded from any pipeline run; no `if:` evaluation required
- ~~**`services:` validation (GL034)**~~ — ✓ shipped v0.2.16; map form requires `name`; `alias` must be a valid DNS label - [x] **`services:` validation (GL034)** shipped v0.2.16; map form requires `name`; `alias` must be a valid DNS label
- ~~**`rules:changes` / `rules:exists` absolute path detection (GL035)**~~ — ✓ shipped v0.2.16; warns when a path starts with `/`; GitLab CI paths are always relative to the repo root - [x] **`rules:changes` / `rules:exists` absolute path detection (GL035)** shipped v0.2.16; warns when a path starts with `/`; GitLab CI paths are always relative to the repo root
- ~~**`timeout` format validation (GL036)**~~ — ✓ shipped v0.2.16; validates job-level and `default.timeout` against recognised GitLab CI duration strings - [x] **`timeout` format validation (GL036)** shipped v0.2.16; validates job-level and `default.timeout` against recognised GitLab CI duration strings
- ~~**`id_tokens:` / `secrets:` required-key checks (GL037, GL038)**~~ — ✓ shipped v0.2.16; `id_tokens` entries must have `aud`; `secrets` entries must declare a provider - [x] **`id_tokens:` / `secrets:` required-key checks (GL037, GL038)** shipped v0.2.16; `id_tokens` entries must have `aud`; `secrets` entries must declare a provider
- ~~**`pages:publish` + `artifacts.paths` consistency (GL039)**~~ — ✓ shipped v0.2.16; warns when the publish directory is missing from `artifacts.paths` - [x] **`pages:publish` + `artifacts.paths` consistency (GL039)** shipped v0.2.16; warns when the publish directory is missing from `artifacts.paths`
- ~~**Duplicate stage names (GL040)**~~ — ✓ shipped v0.2.16; warns when a stage appears more than once in `stages:` - [x] **Duplicate stage names (GL040)** shipped v0.2.16; warns when a stage appears more than once in `stages:`
- ~~**`cache:key:files` must be exact paths (GL041)**~~ — ✓ shipped v0.2.16; warns when entries look like glob patterns - [x] **`cache:key:files` must be exact paths (GL041)** shipped v0.2.16; warns when entries look like glob patterns
- ~~**Unreachable jobs**~~ — covered by GL033 (shipped v0.2.15); every-`when:never` rules block is statically dead - [x] **Unreachable jobs** — covered by GL033 (shipped v0.2.15); every-`when:never` rules block is statically dead
- ~~**`inherit:` completeness (GL043)**~~ — ✓ shipped v0.2.20; warns when `inherit: default:` is declared but there's no `default:` block, or list form names fields not set in `default:` - [x] **`inherit:` completeness (GL043)** shipped v0.2.20; warns when `inherit: default:` is declared but there's no `default:` block, or list form names fields not set in `default:`
--- ---
## Include resolution ## Include resolution
- ~~**`include: local:`** full resolution~~ — ✓ shipped in v0.2.0; local files are read from disk, recursively resolved, and merged before linting - [x] **`include: local:` full resolution** — shipped v0.2.0; local files are read from disk, recursively resolved, and merged before linting
- ~~**`include: remote:`** (URL)~~ — ✓ shipped post-v0.2.0; plain HTTPS URLs are fetched (unauthenticated), parsed, and merged; sub-includes are resolved recursively; unreachable URLs emit `[WARNING]` and linting continues - [x] **`include: remote:` (URL)** — shipped post-v0.2.0; plain HTTPS URLs are fetched (unauthenticated), parsed, and merged; sub-includes are resolved recursively; unreachable URLs emit `[WARNING]` and linting continues
- ~~**Recursive include depth limit**~~ — ✓ shipped v0.2.17; depth capped at 100 (matching GitLab); project/component includes now tracked in visited set to prevent cross-file cycles - [x] **Recursive include depth limit** shipped v0.2.17; depth capped at 100 (matching GitLab); project/component includes now tracked in visited set to prevent cross-file cycles
- ~~**Offline mode / cache**~~ — ✓ shipped v0.2.17; `--cache-dir DIR` persists fetched templates; `--offline` serves from cache only; default cache dir (`~/.cache/glint`) used automatically with `--offline` - [x] **Offline mode / cache** shipped v0.2.17; `--cache-dir DIR` persists fetched templates; `--offline` serves from cache only; default cache dir (`~/.cache/glint`) used automatically with `--offline`
- ~~**`include: inputs:`**~~ — ✓ shipped v0.2.17; `$[[ inputs.KEY ]]` and `$[[ inputs.KEY | default(…) ]]` placeholders in fetched component YAML are substituted from the include's `with:` block before parsing - [x] **`include: inputs:`** shipped v0.2.17; `$[[ inputs.KEY ]]` and `$[[ inputs.KEY | default(…) ]]` placeholders in fetched component YAML are substituted from the include's `with:` block before parsing
- [x] **`glint render` subcommand** — shipped v0.3.0; resolves all `include:` and `extends:` chains into a single flat YAML file; strips consumed keys; accepts same network flags as `glint check`; default output `rendered.gitlab-ci.yml`, use `--output -` for stdout
--- ---
## Output formats — ✓ shipped v0.2.18 ## Output formats
- ~~**JSON** (`--format json`)~~ — ✓ shipped v0.2.18; machine-readable findings with stable schema (version 1) - [x] **JSON** (`--format json`) shipped v0.2.18; machine-readable findings with stable schema (version 1)
- ~~**SARIF** (`--format sarif`)~~ — ✓ shipped v0.2.18; SARIF 2.1.0; consumed natively by GitHub Code Scanning and GitLab SAST - [x] **SARIF** (`--format sarif`) shipped v0.2.18; SARIF 2.1.0; consumed natively by GitHub Code Scanning and GitLab SAST
- ~~**JUnit XML** (`--format junit`)~~ — ✓ shipped v0.2.18; lets CI pipelines publish lint results as a test report artifact - [x] **JUnit XML** (`--format junit`) shipped v0.2.18; lets CI pipelines publish lint results as a test report artifact
- ~~**GitHub annotation format** (`--format github`)~~ — ✓ shipped v0.2.18; emits `::error file=…,line=…,title=RULE::message` lines so findings appear as inline comments in PR diffs - [x] **GitHub annotation format** (`--format github`) shipped v0.2.18; emits `::error file=…,line=…,title=RULE::message` lines so findings appear as inline comments in PR diffs
--- ---
@@ -69,55 +70,73 @@ The current rule set covers the most common sources of broken pipelines. These a
The SVG renderer and terminal tree cover the basic layout. These would bring it closer to GitLab's full interactive view. The SVG renderer and terminal tree cover the basic layout. These would bring it closer to GitLab's full interactive view.
- ~~**Terminal job tree**~~ shipped in v0.2.0 as `glint graph tree`; stages as branches, jobs as leaves, context-aware annotations - [x] **Terminal job tree** — shipped 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 - [x] **`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**~~ — ✓ 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 - [x] **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**~~ — ✓ 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 - [x] **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**~~ — ✓ shipped v0.2.25; dashed chip border + X-mark icon + red circle (`#d9534f`); legend entry added; Mermaid `on_failure` class wired - [x] **`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**~~ — ✓ 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 - [x] **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 - [x] **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) - [x] **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 - [x] **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 - [x] **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
- [x] **`glint graph --no-skipped`** — shipped v0.3.0; removes jobs evaluated as `skipped` in the given context from tree, SVG, HTML, and Mermaid output
--- ---
## Findings quality — ✓ file and line numbers shipped post-v0.2.0; ruff-style format shipped v0.2.11 ## Findings quality
~~**File and line numbers on findings**~~ shipped post-v0.2.0; every finding includes the source file and exact line of the job key. Works across local includes, remote project templates, and fetched component templates. - [x] **File and line numbers on findings** — shipped post-v0.2.0; every finding includes the source file and exact line of the job key; works across local includes, remote project templates, and fetched component templates
- [x] **Ruff-style output format** — shipped v0.2.11; findings follow `file:line: RULEID [severity] message` matching the convention used by ruff and other modern linters
~~**Ruff-style output format**~~ shipped v0.2.11; findings follow `file:line: RULEID [severity] message` matching the convention used by ruff and other modern linters. - [x] **Colorized, columnized text output** — shipped v0.3.0; four aligned columns (location, rule, severity, message); `error` in bold red, `warning` in bold orange; auto-detected terminal color (respects `NO_COLOR`)
- [x] **`line:col` locations** — shipped v0.3.0; `Column int` on `model.Job` and `Finding`; text output and `Finding.String()` emit `file:line:col` when column is known
**Remaining improvements** - [x] **`needs: optional: true` false-positive errors** — shipped post-v0.2.0; optional missing needs are downgraded to `[WARNING]`
- [x] **`extends:` jobs with missing script false errors** — shipped post-v0.2.0; jobs using `extends:` that have no `script` after resolution emit `[WARNING]` (the script may come from an unfetchable remote base)
- ~~**`needs: optional: true` false-positive errors**~~ — ✓ shipped post-v0.2.0; optional missing needs are downgraded to `[WARNING]` - [x] **`rules:if:` static reachability (GL042)** — shipped v0.2.20; warns when all `rules:if:` conditions evaluate to false given declared variable values (only fires when all referenced vars are declared in YAML)
- ~~**`extends:` jobs with missing script false errors**~~ — ✓ shipped post-v0.2.0; jobs using `extends:` that have no `script` after resolution emit `[WARNING]` (the script may come from an unfetchable remote base)
- ~~**`rules:if:` static reachability (GL042)**~~ — ✓ shipped v0.2.20; warns when all `rules:if:` conditions evaluate to false given declared variable values (only fires when all referenced vars are declared in YAML)
--- ---
## CI / editor integration ## CI / editor integration
- **GitLab CI template** — a `.gitlab-ci.yml` snippet that runs `glint` as a pipeline-validation job before the real pipeline executes; publishable to the GitLab CI/CD Catalog - [x] **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 - [x] **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 - [x] **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 - [x] **LSP server**shipped v0.2.29; `glint lsp` runs a JSON-RPC 2.0 LSP server over stdin/stdout; `textDocument/didOpen`, `didChange`, `didSave`, `didClose` all publish diagnostics; rule IDs appear as the diagnostic `code`; include resolution is best-effort using env-var token and default cache dir
- **VS Code extension** — thin wrapper around the LSP server with syntax highlighting for `.gitlab-ci.yml` - [x] **VS Code extension**shipped v0.2.30; `editors/vscode/` TypeScript extension wraps `glint lsp` via `vscode-languageclient`; activates on YAML files and restricts LSP processing to `**/.gitlab-ci.yml`; configurable binary path via `glint.executablePath`; build with `task ext-compile`, package with `task ext-package`
--- ---
## Configuration — ✓ shipped v0.2.19 ## Configuration
- ~~**`.glint.yml` config file**~~ — ✓ shipped v0.2.19; `ignore:`, `severity:`, `stages:`, `token:`, `url:`, `cache_dir:`; searched from the pipeline directory up to the git root - [x] **`.glint.yml` config file** shipped v0.2.19; `ignore:`, `severity:`, `stages:`, `token:`, `url:`, `cache_dir:`; searched from the pipeline directory up to the git root
- ~~**Inline suppression comments**~~ — ✓ shipped v0.2.19; `# glint: ignore GL007` before a job definition; comma/space-separated rules; `# glint: ignore all` wildcard - [x] **Inline suppression comments** shipped v0.2.19; `# glint: ignore GL007` before a job definition; comma/space-separated rules; `# glint: ignore all` wildcard
- [x] **Proxy support** — shipped v0.2.31; `--proxy` flag on `check`, `graph`, and `lsp` subcommands; also configurable via `proxy:` in `.glint.yml`; overrides `HTTP_PROXY` / `HTTPS_PROXY` env vars when set
---
## Security & hardening
- [x] **Path traversal guard for local includes** — shipped v0.2.31; `include: local:` paths containing `../../` or similar sequences are rejected instead of reading files outside the repository root
- [x] **HTTP timeout on remote fetches** — shipped v0.2.31; all HTTP calls via the fetcher use a 30-second timeout to prevent indefinite hangs on slow or unresponsive servers
- [x] **Unbounded response size cap** — shipped v0.2.31; remote include and GitLab API responses are capped at 10 MiB; responses exceeding the limit are rejected to prevent memory exhaustion
- [x] **Cache file permissions** — shipped v0.2.31; cache directories are created with mode `0700` and cache files with mode `0600`; previously used world-readable `0755`/`0644`
- [x] **GL045: HTTP remote include warning** — shipped v0.2.31; warns when `include: remote:` uses a plain `http://` URL; CI templates fetched unencrypted are at risk of tampering
- [x] **LSP Content-Length DoS cap** — shipped v0.2.31; `glint lsp` rejects messages with `Content-Length` exceeding 64 MiB to prevent memory exhaustion from a malicious client
--- ---
## 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.25v0.2.26 - [x] **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 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 - [x] **`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) - [x] **Semantic versioning and first release** — shipped 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` - [x] **Subcommand CLI** — shipped 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 - [x] **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 - [x] **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)
- [x] **`glint check --no-warn`** — shipped v0.3.0; discards all warning findings before output and exit-code calculation; mixed pipelines (errors + warnings) still exit 2 but only errors are printed; warnings-only pipelines exit 0
- [x] **Exit codes 2 / 10** *(breaking)* — shipped v0.3.0; `glint check` exits `2` when errors are present (previously `1`) and `10` when findings contain only warnings; exit `0` for clean
- [x] **Git branch auto-detection** — shipped v0.4.0; when no context flags are given, `glint check` and `glint graph` run `git rev-parse --abbrev-ref HEAD` in the pipeline file's directory and use the result as the default branch (falls back to `main`)
- [x] **GitLab predefined variable injection** — shipped v0.4.0; `CI=true` and `GITLAB_CI=true` always present in a non-empty context; MR-specific variables injected as placeholders when `--source merge_request_event`; all overridable via `--var`
- [x] **Multi-platform release binaries** — shipped v0.4.0; `task build-release` builds Linux amd64/arm64, macOS Intel/Apple Silicon, and Windows amd64 in one shot
- [x] **Homebrew formula** — shipped v0.4.0; `Formula/glint.rb` source-build formula; tap at `k3nny/homebrew-glint`
- [x] **`INSTALL.md`** — shipped v0.4.0; installation guide for all platforms and methods
+126 -33
View File
@@ -68,31 +68,31 @@ tasks:
- cmd: ./{{.BINARY}} check --branch feat/x testdata/rules_if_expr.yml - cmd: ./{{.BINARY}} check --branch feat/x testdata/rules_if_expr.yml
ignore_error: false ignore_error: false
- cmd: ./{{.BINARY}} check testdata/workflow_vars.yml - cmd: ./{{.BINARY}} check testdata/workflow_vars.yml
ignore_error: false ignore_error: true
- cmd: ./{{.BINARY}} check --branch main testdata/workflow_vars.yml - cmd: ./{{.BINARY}} check --branch main testdata/workflow_vars.yml
ignore_error: false ignore_error: true
- cmd: ./{{.BINARY}} check --branch develop testdata/workflow_vars.yml - cmd: ./{{.BINARY}} check --branch develop testdata/workflow_vars.yml
ignore_error: false ignore_error: true
- cmd: ./{{.BINARY}} check --branch feat/x testdata/workflow_vars.yml - cmd: ./{{.BINARY}} check --branch feat/x testdata/workflow_vars.yml
ignore_error: false ignore_error: true
- cmd: ./{{.BINARY}} check testdata/workflow_escape.yml - cmd: ./{{.BINARY}} check testdata/workflow_escape.yml
ignore_error: false ignore_error: true
- cmd: ./{{.BINARY}} check testdata/variable_refs.yml - cmd: ./{{.BINARY}} check testdata/variable_refs.yml
ignore_error: false ignore_error: false
- cmd: ./{{.BINARY}} check testdata/variable_refs_included.yml - cmd: ./{{.BINARY}} check testdata/variable_refs_included.yml
ignore_error: false ignore_error: false
- cmd: ./{{.BINARY}} check testdata/dead_rules.yml - cmd: ./{{.BINARY}} check testdata/dead_rules.yml
ignore_error: false ignore_error: true
- cmd: ./{{.BINARY}} check testdata/new_rules_valid.yml - cmd: ./{{.BINARY}} check testdata/new_rules_valid.yml
ignore_error: false ignore_error: false
- cmd: ./{{.BINARY}} check testdata/new_rules_invalid.yml - cmd: ./{{.BINARY}} check testdata/new_rules_invalid.yml
ignore_error: true ignore_error: true
- cmd: ./{{.BINARY}} check testdata/samba/.gitlab-ci.yml - cmd: ./{{.BINARY}} check testdata/samba/.gitlab-ci.yml
ignore_error: false ignore_error: true
- cmd: ./{{.BINARY}} check testdata/samba/.gitlab-ci-coverage.yml - cmd: ./{{.BINARY}} check testdata/samba/.gitlab-ci-coverage.yml
ignore_error: false ignore_error: true
- cmd: ./{{.BINARY}} check testdata/samba/.gitlab-ci-private.yml - cmd: ./{{.BINARY}} check testdata/samba/.gitlab-ci-private.yml
ignore_error: false ignore_error: true
- cmd: ./{{.BINARY}} check --format json testdata/valid.yml - cmd: ./{{.BINARY}} check --format json testdata/valid.yml
ignore_error: false ignore_error: false
- cmd: ./{{.BINARY}} check --format sarif testdata/valid.yml - cmd: ./{{.BINARY}} check --format sarif testdata/valid.yml
@@ -104,15 +104,15 @@ tasks:
- cmd: ./{{.BINARY}} check testdata/config_ignored/.gitlab-ci.yml - cmd: ./{{.BINARY}} check testdata/config_ignored/.gitlab-ci.yml
ignore_error: false ignore_error: false
- cmd: ./{{.BINARY}} check testdata/config_severity/.gitlab-ci.yml - cmd: ./{{.BINARY}} check testdata/config_severity/.gitlab-ci.yml
ignore_error: false ignore_error: true
- cmd: ./{{.BINARY}} check testdata/config_suppress/.gitlab-ci.yml - cmd: ./{{.BINARY}} check testdata/config_suppress/.gitlab-ci.yml
ignore_error: false ignore_error: true
- cmd: ./{{.BINARY}} check testdata/static_dead_rules.yml - cmd: ./{{.BINARY}} check testdata/static_dead_rules.yml
ignore_error: false ignore_error: true
- cmd: ./{{.BINARY}} check testdata/inherit_dead.yml - cmd: ./{{.BINARY}} check testdata/inherit_dead.yml
ignore_error: false ignore_error: true
- cmd: ./{{.BINARY}} check testdata/inherit_dead_fields.yml - cmd: ./{{.BINARY}} check testdata/inherit_dead_fields.yml
ignore_error: false ignore_error: true
- cmd: ./{{.BINARY}} check testdata/rules_needs_valid.yml - cmd: ./{{.BINARY}} check testdata/rules_needs_valid.yml
ignore_error: false ignore_error: false
- cmd: ./{{.BINARY}} check testdata/rules_needs_invalid.yml - cmd: ./{{.BINARY}} check testdata/rules_needs_invalid.yml
@@ -123,6 +123,8 @@ tasks:
ignore_error: false ignore_error: false
- cmd: ./{{.BINARY}} explain GL044 - cmd: ./{{.BINARY}} explain GL044
ignore_error: false ignore_error: false
- cmd: ./{{.BINARY}} check testdata/insecure_remote_include.yml
ignore_error: true
- cmd: ./{{.BINARY}} explain - cmd: ./{{.BINARY}} explain
ignore_error: false ignore_error: false
@@ -143,30 +145,15 @@ tasks:
- task: build - task: build
- task: validate - task: validate
build-windows: build-linux-amd64:
desc: Build the glint binary for Windows x64 (requires a tagged commit) desc: Build the glint binary for Linux x86-64 (requires a tagged commit)
aliases: [build-linux]
vars: vars:
TAG: TAG:
sh: git describe --tags --exact-match sh: git describe --tags --exact-match
preconditions: preconditions:
- sh: git describe --tags --exact-match - sh: git describe --tags --exact-match
msg: "Current commit is not tagged — Windows build requires a git tag" msg: "Current commit is not tagged — release build requires a git tag"
cmds:
- "GOOS=windows GOARCH=amd64 {{.GO}} build -ldflags \"-X main.version={{.TAG}}\" -o {{.BINARY}}-{{.TAG}}.exe ./cmd/glint/..."
sources:
- "**/*.go"
- go.mod
generates:
- "{{.BINARY}}-{{.TAG}}.exe"
build-linux:
desc: Build the glint binary for Linux x64 (requires a tagged commit)
vars:
TAG:
sh: git describe --tags --exact-match
preconditions:
- sh: git describe --tags --exact-match
msg: "Current commit is not tagged — Linux build requires a git tag"
cmds: cmds:
- "GOOS=linux GOARCH=amd64 {{.GO}} build -ldflags \"-X main.version={{.TAG}}\" -o {{.BINARY}}-{{.TAG}}-linux-amd64 ./cmd/glint/..." - "GOOS=linux GOARCH=amd64 {{.GO}} build -ldflags \"-X main.version={{.TAG}}\" -o {{.BINARY}}-{{.TAG}}-linux-amd64 ./cmd/glint/..."
sources: sources:
@@ -175,6 +162,112 @@ tasks:
generates: generates:
- "{{.BINARY}}-{{.TAG}}-linux-amd64" - "{{.BINARY}}-{{.TAG}}-linux-amd64"
build-linux-arm64:
desc: Build the glint binary for Linux ARM64 (requires a tagged commit)
vars:
TAG:
sh: git describe --tags --exact-match
preconditions:
- sh: git describe --tags --exact-match
msg: "Current commit is not tagged — release build requires a git tag"
cmds:
- "GOOS=linux GOARCH=arm64 {{.GO}} build -ldflags \"-X main.version={{.TAG}}\" -o {{.BINARY}}-{{.TAG}}-linux-arm64 ./cmd/glint/..."
sources:
- "**/*.go"
- go.mod
generates:
- "{{.BINARY}}-{{.TAG}}-linux-arm64"
build-darwin-amd64:
desc: Build the glint binary for macOS Intel (requires a tagged commit)
vars:
TAG:
sh: git describe --tags --exact-match
preconditions:
- sh: git describe --tags --exact-match
msg: "Current commit is not tagged — release build requires a git tag"
cmds:
- "GOOS=darwin GOARCH=amd64 {{.GO}} build -ldflags \"-X main.version={{.TAG}}\" -o {{.BINARY}}-{{.TAG}}-darwin-amd64 ./cmd/glint/..."
sources:
- "**/*.go"
- go.mod
generates:
- "{{.BINARY}}-{{.TAG}}-darwin-amd64"
build-darwin-arm64:
desc: Build the glint binary for macOS Apple Silicon (requires a tagged commit)
vars:
TAG:
sh: git describe --tags --exact-match
preconditions:
- sh: git describe --tags --exact-match
msg: "Current commit is not tagged — release build requires a git tag"
cmds:
- "GOOS=darwin GOARCH=arm64 {{.GO}} build -ldflags \"-X main.version={{.TAG}}\" -o {{.BINARY}}-{{.TAG}}-darwin-arm64 ./cmd/glint/..."
sources:
- "**/*.go"
- go.mod
generates:
- "{{.BINARY}}-{{.TAG}}-darwin-arm64"
build-windows:
desc: Build the glint binary for Windows x86-64 (requires a tagged commit)
vars:
TAG:
sh: git describe --tags --exact-match
preconditions:
- sh: git describe --tags --exact-match
msg: "Current commit is not tagged — release build requires a git tag"
cmds:
- "GOOS=windows GOARCH=amd64 {{.GO}} build -ldflags \"-X main.version={{.TAG}}\" -o {{.BINARY}}-{{.TAG}}-windows-amd64.exe ./cmd/glint/..."
sources:
- "**/*.go"
- go.mod
generates:
- "{{.BINARY}}-{{.TAG}}-windows-amd64.exe"
build-release:
desc: Build release binaries for all supported platforms (requires a tagged commit)
cmds:
- task: build-linux-amd64
- task: build-linux-arm64
- task: build-darwin-amd64
- task: build-darwin-arm64
- task: build-windows
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
ext-install:
desc: Install VS Code extension npm dependencies (run once after checkout)
dir: editors/vscode
cmd: npm install
ext-compile:
desc: Compile the VS Code extension TypeScript source
dir: editors/vscode
cmd: npm run compile
ext-package:
desc: Package the VS Code extension into a .vsix file
dir: editors/vscode
deps: [ext-compile]
cmd: npm run package
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"
+100
View File
@@ -0,0 +1,100 @@
package main
import (
"flag"
"fmt"
"os"
"git.k3nny.fr/glint/internal/config"
"git.k3nny.fr/glint/internal/fetcher"
"git.k3nny.fr/glint/internal/lsp"
)
func cmdLSP(args []string) {
fs := flag.NewFlagSet("glint lsp", flag.ExitOnError)
token := fs.String("token", "", "GitLab personal access token (overrides GITLAB_TOKEN)")
gitlabURL := fs.String("gitlab-url", "", "GitLab instance URL (overrides CI_SERVER_URL / GITLAB_URL)")
cacheDir := fs.String("cache-dir", "", "directory for caching fetched remote includes")
offline := fs.Bool("offline", false, "skip all network calls; serve only from --cache-dir")
proxy := fs.String("proxy", "", "HTTP proxy URL for remote includes and GitLab API calls (e.g. http://proxy:8080); overrides system proxy env vars")
fs.Usage = func() {
fmt.Fprintf(os.Stderr, "glint %s\n\n", version)
fmt.Fprint(os.Stderr, `Start a Language Server Protocol server for .gitlab-ci.yml files.
Reads JSON-RPC 2.0 messages from stdin and writes responses to stdout using
the standard Content-Length framing. Connect with any LSP client (VS Code,
Neovim, Emacs, etc.).
Usage: glint lsp [OPTIONS]
Options:
--token <TOKEN>
GitLab personal access token used for resolving project: and
component: includes. Defaults to GITLAB_TOKEN env var.
--gitlab-url <URL>
GitLab instance URL for resolving remote includes.
[env: CI_SERVER_URL | GITLAB_URL] [default: https://gitlab.com]
--cache-dir <DIR>
Cache directory for fetched remote includes. Defaults to
~/.cache/glint so subsequent opens are served from cache.
--offline
Do not make any network calls; resolve only local includes.
Implies --cache-dir default (~/.cache/glint) when not set.
--proxy <URL>
HTTP proxy URL for remote includes and GitLab API calls
(e.g. http://proxy:8080). Overrides system proxy env vars
(HTTP_PROXY / HTTPS_PROXY). Also configurable via proxy: in
.glint.yml.
-h, --help
Print help
Examples:
glint lsp
glint lsp --token glpat-xxxx --cache-dir ~/.cache/glint
glint lsp --offline
glint lsp --proxy http://proxy.example.com:8080
`)
}
_ = fs.Parse(args)
// Load project config from the working directory (the project root from
// which the LSP server is launched). CLI flags take priority.
wd, _ := os.Getwd()
glintCfg, cfgErr := config.Load(wd)
if cfgErr != nil {
fmt.Fprintf(os.Stderr, "glint lsp: [warning] %s: %v\n", config.Filename, cfgErr)
}
fetcherToken := *token
if fetcherToken == "" {
fetcherToken = glintCfg.Token
}
fetcherURL := *gitlabURL
if fetcherURL == "" {
fetcherURL = glintCfg.URL
}
resolvedCacheDir := *cacheDir
if resolvedCacheDir == "" {
resolvedCacheDir = glintCfg.CacheDir
}
if resolvedCacheDir == "" {
resolvedCacheDir = defaultCacheDir()
}
resolvedProxy := *proxy
if resolvedProxy == "" {
resolvedProxy = glintCfg.Proxy
}
cfg := fetcher.AutoConfig().WithOverrides(fetcherURL, fetcherToken, resolvedCacheDir, *offline).WithProxy(resolvedProxy)
srv := lsp.New(os.Stdin, os.Stdout, cfg, version)
if err := srv.Run(); err != nil {
fmt.Fprintf(os.Stderr, "glint lsp: %v\n", err)
exit(2)
}
}
+180 -35
View File
@@ -44,6 +44,29 @@ var execCommandOutput = func(name string, args ...string) ([]byte, error) {
return exec.Command(name, args...).Output() return exec.Command(name, args...).Output()
} }
// gitBranchInDir is a variable so tests can mock branch detection.
var gitBranchInDir = func(dir string) ([]byte, error) {
cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD")
cmd.Dir = dir
return cmd.Output()
}
// detectGitBranch returns the current git branch name by running
// "git rev-parse --abbrev-ref HEAD" in dir. Returns "" when dir is not
// inside a git repository, when the repo is in detached-HEAD state, or
// when git is not available.
func detectGitBranch(dir string) string {
out, err := gitBranchInDir(dir)
if err != nil {
return ""
}
b := strings.TrimSpace(string(out))
if b == "" || b == "HEAD" {
return ""
}
return b
}
// gitDiffFiles runs "git diff --name-only <ref>" and returns the list of changed // gitDiffFiles runs "git diff --name-only <ref>" and returns the list of changed
// file paths. Returns nil + error when the command fails (e.g. not in a git repo // file paths. Returns nil + error when the command fails (e.g. not in a git repo
// or the ref doesn't exist). // or the ref doesn't exist).
@@ -66,9 +89,11 @@ const globalUsage = `glint: Lint and visualise GitLab CI pipelines locally.
Usage: glint [OPTIONS] <COMMAND> Usage: glint [OPTIONS] <COMMAND>
Commands: Commands:
check Lint a pipeline file — exits 0 (clean) or 1 (errors found) check Lint a pipeline file — exits 0 (clean), 2 (errors), or 10 (warnings only)
render Resolve all includes and extends into a single merged YAML file
graph Visualise the pipeline as a job tree or Mermaid graph graph Visualise the pipeline as a job tree or Mermaid graph
explain Show description and fix for a lint rule (e.g. glint explain GL007) explain Show description and fix for a lint rule (e.g. glint explain GL007)
lsp Start a Language Server Protocol server (stdin/stdout)
Options: Options:
-h, --help Print help -h, --help Print help
@@ -86,10 +111,14 @@ func main() {
switch os.Args[1] { switch os.Args[1] {
case "check": case "check":
cmdCheck(os.Args[2:]) cmdCheck(os.Args[2:])
case "render":
cmdRender(os.Args[2:])
case "graph": case "graph":
cmdGraph(os.Args[2:]) cmdGraph(os.Args[2:])
case "explain": case "explain":
cmdExplain(os.Args[2:]) cmdExplain(os.Args[2:])
case "lsp":
cmdLSP(os.Args[2:])
case "-h", "--help", "help": case "-h", "--help", "help":
fmt.Fprintf(os.Stderr, "glint %s\n\n", version) fmt.Fprintf(os.Stderr, "glint %s\n\n", version)
fmt.Fprint(os.Stderr, globalUsage) fmt.Fprint(os.Stderr, globalUsage)
@@ -116,7 +145,9 @@ func cmdCheck(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")
proxy := fs.String("proxy", "", "HTTP proxy URL for remote includes and GitLab API calls (e.g. http://proxy:8080); overrides system proxy env vars")
format := fs.String("format", "text", "output format: text, json, sarif, junit, github") format := fs.String("format", "text", "output format: text, json, sarif, junit, github")
noWarn := fs.Bool("no-warn", false, "suppress warning findings; only errors are shown and affect the exit code")
branch := fs.String("branch", "", "simulate a branch push (sets CI_COMMIT_BRANCH, …)") branch := fs.String("branch", "", "simulate a branch push (sets CI_COMMIT_BRANCH, …)")
tag := fs.String("tag", "", "simulate a tag push (sets CI_COMMIT_TAG, …)") tag := fs.String("tag", "", "simulate a tag push (sets CI_COMMIT_TAG, …)")
source := fs.String("source", "", "set CI_PIPELINE_SOURCE") source := fs.String("source", "", "set CI_PIPELINE_SOURCE")
@@ -133,7 +164,11 @@ func cmdCheck(args []string) {
fmt.Fprint(os.Stderr, `Lint a GitLab CI pipeline file. fmt.Fprint(os.Stderr, `Lint a GitLab CI pipeline file.
Resolves local includes and extends chains, then runs all lint rules. Resolves local includes and extends chains, then runs all lint rules.
Exits 0 when no errors are found, 1 when at least one error is reported.
Exit codes:
0 no findings (clean)
2 one or more errors
10 one or more warnings, no errors
Usage: glint check [OPTIONS] <PIPELINE> Usage: glint check [OPTIONS] <PIPELINE>
@@ -145,6 +180,10 @@ Options:
Output format for findings. Output format for findings.
[default: text] [possible values: text, json, sarif, junit, github] [default: text] [possible values: text, json, sarif, junit, github]
--no-warn
Suppress warning findings. Only errors are printed and counted toward
the exit code; warnings are ignored entirely.
--token <TOKEN> --token <TOKEN>
GitLab personal access token. Required to fetch project: includes; GitLab personal access token. Required to fetch project: includes;
component: includes are attempted unauthenticated. component: includes are attempted unauthenticated.
@@ -165,6 +204,12 @@ Options:
having no token). Implies the default cache dir (~/.cache/glint) when having no token). Implies the default cache dir (~/.cache/glint) when
--cache-dir is not set. --cache-dir is not set.
--proxy <URL>
HTTP proxy URL for remote includes and GitLab API calls
(e.g. http://proxy:8080). Overrides system proxy env vars
(HTTP_PROXY / HTTPS_PROXY). Also configurable via proxy: in
.glint.yml.
--branch <NAME> --branch <NAME>
Simulate a branch push. Populates: CI_COMMIT_BRANCH, Simulate a branch push. Populates: CI_COMMIT_BRANCH,
CI_COMMIT_REF_NAME, CI_COMMIT_REF_SLUG, CI_PIPELINE_SOURCE=push. CI_COMMIT_REF_NAME, CI_COMMIT_REF_SLUG, CI_PIPELINE_SOURCE=push.
@@ -210,8 +255,13 @@ Options:
Print help Print help
Note: when none of --branch, --tag, --source, or --var are given, glint Note: when none of --branch, --tag, --source, or --var are given, glint
defaults to --branch main --source push so that rules:if: expressions are detects the current git branch automatically and uses it as the default
always evaluated. (falling back to 'main' when not inside a git repository or in detached-HEAD
state). --source defaults to 'push' so that rules:if: expressions are always
evaluated. GitLab's always-available predefined variables (CI=true,
GITLAB_CI=true) are injected automatically; MR-specific variables
(CI_MERGE_REQUEST_IID, CI_MERGE_REQUEST_SOURCE_BRANCH_NAME, …) are injected
when --source merge_request_event is given. Override any with --var.
Examples: Examples:
glint check .gitlab-ci.yml glint check .gitlab-ci.yml
@@ -222,26 +272,24 @@ Examples:
glint check --branch develop .gitlab-ci.yml glint check --branch develop .gitlab-ci.yml
glint check --tag v1.0.0 .gitlab-ci.yml glint check --tag v1.0.0 .gitlab-ci.yml
glint check --source merge_request_event .gitlab-ci.yml glint check --source merge_request_event .gitlab-ci.yml
glint check --source merge_request_event --branch feature/my-branch .gitlab-ci.yml
glint check --source merge_request_event --var CI_MERGE_REQUEST_IID=42 .gitlab-ci.yml
glint check --list-vars .gitlab-ci.yml glint check --list-vars .gitlab-ci.yml
GITLAB_TOKEN=glpat-xxxx glint check .gitlab-ci.yml GITLAB_TOKEN=glpat-xxxx glint check .gitlab-ci.yml
glint check --token glpat-xxxx --gitlab-url https://gitlab.example.com .gitlab-ci.yml glint check --token glpat-xxxx --gitlab-url https://gitlab.example.com .gitlab-ci.yml
glint check --branch main --var DEPLOY_ENV=production .gitlab-ci.yml glint check --branch main --var DEPLOY_ENV=production .gitlab-ci.yml
glint check --cache-dir ~/.cache/glint .gitlab-ci.yml glint check --cache-dir ~/.cache/glint .gitlab-ci.yml
glint check --offline --cache-dir ~/.cache/glint .gitlab-ci.yml glint check --offline --cache-dir ~/.cache/glint .gitlab-ci.yml
glint check --no-warn .gitlab-ci.yml
glint check --changes src/main.go --changes Dockerfile .gitlab-ci.yml glint check --changes src/main.go --changes Dockerfile .gitlab-ci.yml
glint check --changes-from origin/main .gitlab-ci.yml glint check --changes-from origin/main .gitlab-ci.yml
glint check --context branch=main --context branch=develop .gitlab-ci.yml glint check --context branch=main --context branch=develop .gitlab-ci.yml
glint check --context branch=main --context tag=v1.0.0 --context source=schedule .gitlab-ci.yml glint check --context branch=main --context tag=v1.0.0 --context source=schedule .gitlab-ci.yml
glint check --proxy http://proxy.example.com:8080 .gitlab-ci.yml
`) `)
} }
_ = fs.Parse(args) _ = fs.Parse(args)
// Apply implicit defaults only in single-context mode when no flags are given.
if len(contexts) == 0 && *branch == "" && *tag == "" && *source == "" && len(vars) == 0 {
*branch = "main"
*source = "push"
}
validFormats := map[string]bool{ validFormats := map[string]bool{
"text": true, "json": true, "sarif": true, "junit": true, "github": true, "text": true, "json": true, "sarif": true, "junit": true, "github": true,
} }
@@ -259,6 +307,17 @@ Examples:
path := fs.Arg(0) path := fs.Arg(0)
rootDir := filepath.Dir(filepath.Clean(path)) rootDir := filepath.Dir(filepath.Clean(path))
// Apply implicit defaults only in single-context mode when no flags are given.
// Prefer the actual git branch of the repository; fall back to "main".
if len(contexts) == 0 && *branch == "" && *tag == "" && *source == "" && len(vars) == 0 {
if detected := detectGitBranch(rootDir); detected != "" {
*branch = detected
} else {
*branch = "main"
}
*source = "push"
}
// Load project config (.glint.yml), searching from the pipeline directory // Load project config (.glint.yml), searching from the pipeline directory
// up to the git root. // up to the git root.
glintCfg, cfgErr := config.Load(rootDir) glintCfg, cfgErr := config.Load(rootDir)
@@ -283,8 +342,12 @@ Examples:
if *offline && resolvedCacheDir == "" { if *offline && resolvedCacheDir == "" {
resolvedCacheDir = defaultCacheDir() resolvedCacheDir = defaultCacheDir()
} }
resolvedProxy := *proxy
if resolvedProxy == "" {
resolvedProxy = glintCfg.Proxy
}
cfg := fetcher.AutoConfig().WithOverrides(fetcherURL, fetcherToken, resolvedCacheDir, *offline) cfg := fetcher.AutoConfig().WithOverrides(fetcherURL, fetcherToken, resolvedCacheDir, *offline).WithProxy(resolvedProxy)
p, err := model.Parse(path) p, err := model.Parse(path)
if err != nil { if err != nil {
@@ -394,7 +457,19 @@ Examples:
findings := linter.Lint(p, skipped) findings := linter.Lint(p, skipped)
findings = applyConfig(findings, glintCfg, p.Suppressions) findings = applyConfig(findings, glintCfg, p.Suppressions)
errCount, _ := countSeverities(findings)
// --no-warn: discard warnings before any output or exit-code calculation.
if *noWarn {
kept := findings[:0]
for _, f := range findings {
if f.Severity != linter.Warning {
kept = append(kept, f)
}
}
findings = kept
}
errCount, warnCount := countSeverities(findings)
// In structured formats the summary line goes to stderr so stdout is clean. // In structured formats the summary line goes to stderr so stdout is clean.
summaryOut := os.Stdout summaryOut := os.Stdout
@@ -412,19 +487,27 @@ Examples:
case "github": case "github":
writeGitHub(os.Stdout, findings) writeGitHub(os.Stdout, findings)
default: // "text" default: // "text"
for _, f := range findings { writeTextFindings(os.Stdout, findings)
fmt.Println(f)
}
} }
if len(findings) == 0 { if len(findings) == 0 {
fmt.Fprintf(summaryOut, "OK: %s — no issues found (%d job(s), %d stage(s))\n", path, len(p.Jobs), len(p.Stages)) fmt.Fprintf(summaryOut, "OK: %s — no issues found (%d job(s), %d stage(s))\n", path, len(p.Jobs), len(p.Stages))
} else { } else {
switch {
case errCount > 0 && warnCount > 0:
fmt.Fprintf(summaryOut, "%d finding(s): %d error(s), %d warning(s)\n", len(findings), errCount, warnCount)
case errCount > 0:
fmt.Fprintf(summaryOut, "%d finding(s): %d error(s)\n", len(findings), errCount) fmt.Fprintf(summaryOut, "%d finding(s): %d error(s)\n", len(findings), errCount)
default:
fmt.Fprintf(summaryOut, "%d finding(s): %d warning(s)\n", len(findings), warnCount)
}
} }
if errCount > 0 { switch {
exit(1) case errCount > 0:
exit(2)
case warnCount > 0:
exit(10)
} }
} }
@@ -445,16 +528,17 @@ 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")
proxy := fs.String("proxy", "", "HTTP proxy URL for remote includes and GitLab API calls (e.g. http://proxy:8080)")
out := fs.String("out", "glint-out", "output directory for rendered 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") 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 or graph.
Usage: glint graph [MODE] [OPTIONS] <PIPELINE> Usage: glint graph [MODE] [OPTIONS] <PIPELINE>
Arguments: Arguments:
[MODE] Graph mode; must appear before options [default: tree+includes] [MODE] Graph mode; must appear before options [default: tree]
[possible values: tree, includes, pipeline, all] [possible values: tree, includes, pipeline, all]
<PIPELINE> Path to the .gitlab-ci.yml file <PIPELINE> Path to the .gitlab-ci.yml file
@@ -481,6 +565,22 @@ Options:
GitLab instance URL. GitLab instance URL.
[env: CI_SERVER_URL | GITLAB_URL] [default: https://gitlab.com] [env: CI_SERVER_URL | GITLAB_URL] [default: https://gitlab.com]
--cache-dir <DIR>
Cache fetched remote templates (project: and component: includes) in
DIR. The directory is created on first use. Subsequent runs read from
cache first, avoiding repeated network calls.
--offline
Do not make any network calls. All remote includes must already be
present in --cache-dir; missing entries emit a warning. Implies the
default cache dir (~/.cache/glint) when --cache-dir is not set.
--proxy <URL>
HTTP proxy URL for remote includes and GitLab API calls
(e.g. http://proxy:8080). Overrides system proxy env vars
(HTTP_PROXY / HTTPS_PROXY). Also configurable via proxy: in
.glint.yml.
--branch <NAME> --branch <NAME>
Simulate a branch push. Jobs in tree output are annotated with their Simulate a branch push. Jobs in tree output are annotated with their
evaluated state ([skipped] or [manual]; no tag means active). evaluated state ([skipped] or [manual]; no tag means active).
@@ -506,6 +606,12 @@ Options:
Run "git diff --name-only <REF>" to determine changed files for Run "git diff --name-only <REF>" to determine changed files for
rules:changes: evaluation. rules:changes: evaluation.
--no-skipped
Omit jobs that would be skipped in the given context. Requires at
least one context flag (--branch, --tag, --source, --var) or the
implicit default context (branch=main). Skipped jobs are removed
from the tree, SVG, HTML, and Mermaid output entirely.
--list-vars --list-vars
Print all pipeline-level variables collected from the root file and Print all pipeline-level variables collected from the root file and
every included file (sorted KEY=VALUE) to stderr, then continue every included file (sorted KEY=VALUE) to stderr, then continue
@@ -515,27 +621,35 @@ Options:
Print help Print help
Note: when none of --branch, --tag, --source, or --var are given, glint Note: when none of --branch, --tag, --source, or --var are given, glint
defaults to --branch main --source push so that rules:if: expressions are detects the current git branch automatically and uses it as the default
always evaluated. (falling back to 'main' when not inside a git repository or in detached-HEAD
state). --source defaults to 'push' so that rules:if: expressions are always
evaluated. GitLab's always-available predefined variables (CI=true,
GITLAB_CI=true) are injected automatically; MR-specific variables
(CI_MERGE_REQUEST_IID, CI_MERGE_REQUEST_SOURCE_BRANCH_NAME, …) are injected
when --source merge_request_event is given. Override any with --var.
Examples: Examples:
glint graph .gitlab-ci.yml glint graph .gitlab-ci.yml
glint graph tree .gitlab-ci.yml glint graph tree .gitlab-ci.yml
glint graph includes .gitlab-ci.yml > includes.mmd
glint graph tree --branch develop .gitlab-ci.yml glint graph tree --branch develop .gitlab-ci.yml
glint graph tree --tag v1.0.0 .gitlab-ci.yml glint graph tree --tag v1.0.0 .gitlab-ci.yml
glint graph tree --list-vars .gitlab-ci.yml glint graph tree --list-vars .gitlab-ci.yml
glint graph tree --changes src/main.go .gitlab-ci.yml glint graph tree --changes src/main.go .gitlab-ci.yml
glint graph includes .gitlab-ci.yml > includes.mmd
glint graph pipeline .gitlab-ci.yml glint graph pipeline .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 mermaid .gitlab-ci.yml
glint graph pipeline --format html .gitlab-ci.yml glint graph pipeline --format html .gitlab-ci.yml
glint graph tree --no-skipped --branch main .gitlab-ci.yml
glint graph pipeline --no-skipped --branch develop .gitlab-ci.yml
glint graph all .gitlab-ci.yml > includes.mmd glint graph all .gitlab-ci.yml > includes.mmd
`) `)
} }
branch := fs.String("branch", "", "simulate a branch push (sets CI_COMMIT_BRANCH, …)") branch := fs.String("branch", "", "simulate a branch push (sets CI_COMMIT_BRANCH, …)")
tag := fs.String("tag", "", "simulate a tag push (sets CI_COMMIT_TAG, …)") tag := fs.String("tag", "", "simulate a tag push (sets CI_COMMIT_TAG, …)")
source := fs.String("source", "", "set CI_PIPELINE_SOURCE") source := fs.String("source", "", "set CI_PIPELINE_SOURCE")
noSkipped := fs.Bool("no-skipped", false, "hide jobs that would be skipped in the given context (requires a context)")
listVars := fs.Bool("list-vars", false, "print all collected pipeline variables to stderr, then continue") listVars := fs.Bool("list-vars", false, "print all collected pipeline variables to stderr, then continue")
var vars multiFlag var vars multiFlag
fs.Var(&vars, "var", "set a CI variable as KEY=VALUE; repeatable") fs.Var(&vars, "var", "set a CI variable as KEY=VALUE; repeatable")
@@ -544,25 +658,51 @@ Examples:
changesFrom := fs.String("changes-from", "", "git ref to diff against for rules:changes: evaluation (e.g. HEAD~1, origin/main)") changesFrom := fs.String("changes-from", "", "git ref to diff against for rules:changes: evaluation (e.g. HEAD~1, origin/main)")
_ = fs.Parse(args) _ = fs.Parse(args)
// Apply implicit defaults when no context flag is given at all.
if *branch == "" && *tag == "" && *source == "" && len(vars) == 0 {
*branch = "main"
*source = "push"
}
if fs.NArg() != 1 { if fs.NArg() != 1 {
fs.Usage() fs.Usage()
exit(2) exit(2)
return return
} }
path := fs.Arg(0) path := fs.Arg(0)
rootDir := filepath.Dir(filepath.Clean(path))
// Apply implicit defaults when no context flag is given at all.
// Prefer the actual git branch of the repository; fall back to "main".
if *branch == "" && *tag == "" && *source == "" && len(vars) == 0 {
if detected := detectGitBranch(rootDir); detected != "" {
*branch = detected
} else {
*branch = "main"
}
*source = "push"
}
glintCfg, cfgErr := config.Load(rootDir)
if cfgErr != nil {
fmt.Fprintf(os.Stderr, "%s: [warning] %s: %v\n", path, config.Filename, cfgErr)
}
fetcherToken := *token
if fetcherToken == "" {
fetcherToken = glintCfg.Token
}
fetcherURL := *gitlabURL
if fetcherURL == "" {
fetcherURL = glintCfg.URL
}
resolvedCacheDir := *cacheDir resolvedCacheDir := *cacheDir
if resolvedCacheDir == "" {
resolvedCacheDir = glintCfg.CacheDir
}
if *offline && resolvedCacheDir == "" { if *offline && resolvedCacheDir == "" {
resolvedCacheDir = defaultCacheDir() resolvedCacheDir = defaultCacheDir()
} }
resolvedProxy := *proxy
if resolvedProxy == "" {
resolvedProxy = glintCfg.Proxy
}
cfg := fetcher.AutoConfig().WithOverrides(*gitlabURL, *token, resolvedCacheDir, *offline) cfg := fetcher.AutoConfig().WithOverrides(fetcherURL, fetcherToken, resolvedCacheDir, *offline).WithProxy(resolvedProxy)
p, err := model.Parse(path) p, err := model.Parse(path)
if err != nil { if err != nil {
@@ -571,7 +711,6 @@ Examples:
return return
} }
rootDir := filepath.Dir(filepath.Clean(path))
resolver.ResolveIncludes(p, cfg, rootDir) //nolint:errcheck resolver.ResolveIncludes(p, cfg, rootDir) //nolint:errcheck
resolver.Resolve(p) //nolint:errcheck resolver.Resolve(p) //nolint:errcheck
@@ -604,12 +743,18 @@ Examples:
printVars(p, ctx) printVars(p, ctx)
} }
// --no-skipped: remove jobs that evaluate to skipped in the current context
// before handing the pipeline to any graph function.
if *noSkipped && !ctx.IsEmpty() {
for name, job := range p.Jobs {
if !strings.HasPrefix(name, ".") && cicontext.EvalJob(job, ctx) == cicontext.JobSkipped {
delete(p.Jobs, name)
}
}
}
switch mode { switch mode {
case "default": case "default", "tree":
fmt.Print(graph.Tree(p, ctx))
fmt.Println("---")
fmt.Print(graph.Includes(path, p.Include, cfg))
case "tree":
fmt.Print(graph.Tree(p, ctx)) fmt.Print(graph.Tree(p, ctx))
case "includes": case "includes":
fmt.Print(graph.Includes(path, p.Include, cfg)) fmt.Print(graph.Includes(path, p.Include, cfg))
+50 -22
View File
@@ -112,7 +112,7 @@ func TestCmdCheck_MissingFile(t *testing.T) {
if *code != 2 { t.Errorf("missing file: want exit(2), got %d", *code) } if *code != 2 { t.Errorf("missing file: want exit(2), got %d", *code) }
} }
func TestCmdCheck_WithErrors_ExitsOne(t *testing.T) { func TestCmdCheck_WithErrors_ExitsTwo(t *testing.T) {
code := captureExit(t) code := captureExit(t)
// Pipeline with an error finding (invalid stage reference) // Pipeline with an error finding (invalid stage reference)
content := ` content := `
@@ -123,7 +123,7 @@ test-job:
` `
path := writePipeline(t, content) path := writePipeline(t, content)
cmdCheck([]string{path}) cmdCheck([]string{path})
if *code != 1 { t.Errorf("pipeline with errors: want exit(1), got %d", *code) } if *code != 2 { t.Errorf("pipeline with errors: want exit(2), got %d", *code) }
} }
func TestCmdCheck_FormatJSON(t *testing.T) { func TestCmdCheck_FormatJSON(t *testing.T) {
@@ -703,10 +703,10 @@ func TestCmdCheck_ChangesFrom_Fails(t *testing.T) {
code := captureExit(t) code := captureExit(t)
path := writePipeline(t, minimalPipeline) path := writePipeline(t, minimalPipeline)
// Should warn but not crash; pipeline is clean → no exit(1). // Should warn but not crash; pipeline is clean → no exit(2).
cmdCheck([]string{"--changes-from", "origin/main", path}) cmdCheck([]string{"--changes-from", "origin/main", path})
if *code == 1 { if *code == 2 {
t.Errorf("expected no exit(1) when --changes-from fails gracefully, got %d", *code) t.Errorf("expected no exit(2) when --changes-from fails gracefully, got %d", *code)
} }
} }
@@ -753,10 +753,10 @@ build-job:
` `
path := writePipeline(t, content) path := writePipeline(t, content)
// build-job's rule fires only if src/** matches; with 0 changed files it is skipped. // build-job's rule fires only if src/** matches; with 0 changed files it is skipped.
// Pipeline is clean (no lint errors) → no exit(1). // Pipeline is clean (no lint errors) → no exit(2).
cmdCheck([]string{"--changes-from", "origin/main", path}) cmdCheck([]string{"--changes-from", "origin/main", path})
if *code == 1 { if *code == 2 {
t.Errorf("unexpected exit(1): %d", *code) t.Errorf("unexpected exit(2): %d", *code)
} }
} }
@@ -770,8 +770,8 @@ func TestCmdGraph_ChangesFrom_Fails(t *testing.T) {
code := captureExit(t) code := captureExit(t)
path := writePipeline(t, minimalPipeline) path := writePipeline(t, minimalPipeline)
cmdGraph([]string{"tree", "--changes-from", "origin/main", path}) cmdGraph([]string{"tree", "--changes-from", "origin/main", path})
if *code == 1 { if *code == 2 {
t.Errorf("unexpected exit(1) when --changes-from fails in graph mode") t.Errorf("unexpected exit(2) when --changes-from fails in graph mode")
} }
} }
@@ -785,8 +785,8 @@ func TestCmdGraph_ChangesFrom_Success(t *testing.T) {
code := captureExit(t) code := captureExit(t)
path := writePipeline(t, minimalPipeline) path := writePipeline(t, minimalPipeline)
cmdGraph([]string{"tree", "--changes-from", "origin/main", path}) cmdGraph([]string{"tree", "--changes-from", "origin/main", path})
if *code == 1 { if *code == 2 {
t.Errorf("unexpected exit(1) in graph --changes-from success path") t.Errorf("unexpected exit(2) in graph --changes-from success path")
} }
} }
@@ -801,8 +801,8 @@ func TestCmdGraph_ChangesFrom_EmptyDiff(t *testing.T) {
path := writePipeline(t, minimalPipeline) path := writePipeline(t, minimalPipeline)
// reliable=true, allChanged nil → allChanged = []string{} branch hit // reliable=true, allChanged nil → allChanged = []string{} branch hit
cmdGraph([]string{"tree", "--changes-from", "origin/main", path}) cmdGraph([]string{"tree", "--changes-from", "origin/main", path})
if *code == 1 { if *code == 2 {
t.Errorf("unexpected exit(1) in graph --changes-from empty diff") t.Errorf("unexpected exit(2) in graph --changes-from empty diff")
} }
} }
@@ -818,8 +818,8 @@ build-job:
` `
path := writePipeline(t, content) path := writePipeline(t, content)
cmdGraph([]string{"tree", "--changes", "src/app.go", path}) cmdGraph([]string{"tree", "--changes", "src/app.go", path})
if *code == 1 { if *code == 2 {
t.Errorf("unexpected exit(1) with valid pipeline and --changes flag") t.Errorf("unexpected exit(2) with valid pipeline and --changes flag")
} }
} }
@@ -848,7 +848,7 @@ deploy-job:
// by rules evaluation → needs cross-check suppressed → exit 0. // by rules evaluation → needs cross-check suppressed → exit 0.
code := captureExit(t) code := captureExit(t)
cmdCheck([]string{"--branch", "main", path}) cmdCheck([]string{"--branch", "main", path})
if *code == 1 { if *code == 2 {
t.Error("skipped job's needs: error should be suppressed in context-scoped lint") t.Error("skipped job's needs: error should be suppressed in context-scoped lint")
} }
} }
@@ -873,8 +873,8 @@ deploy-job:
code := captureExit(t) code := captureExit(t)
cmdCheck([]string{"--tag", "v1.0.0", path}) cmdCheck([]string{"--tag", "v1.0.0", path})
if *code != 1 { if *code != 2 {
t.Error("active job's bad needs: should still produce GL027 error") t.Error("active job's bad needs: should still produce GL027 error (exit 2)")
} }
} }
@@ -883,7 +883,7 @@ func TestCmdCheck_ContextScopedLinting_SkippedSet_IsNilWhenAllActive(t *testing.
code := captureExit(t) code := captureExit(t)
path := writePipeline(t, minimalPipeline) path := writePipeline(t, minimalPipeline)
cmdCheck([]string{"--branch", "main", path}) cmdCheck([]string{"--branch", "main", path})
if *code == 1 { if *code == 2 {
t.Error("valid pipeline with all-active jobs should not produce errors") t.Error("valid pipeline with all-active jobs should not produce errors")
} }
} }
@@ -1142,8 +1142,8 @@ test-job:
` `
path := writePipeline(t, content) path := writePipeline(t, content)
cmdCheck([]string{"--context", "branch=main", path}) cmdCheck([]string{"--context", "branch=main", path})
if *code != 1 { if *code != 2 {
t.Errorf("multi-context error pipeline: want exit(1), got %d", *code) t.Errorf("multi-context error pipeline: want exit(2), got %d", *code)
} }
} }
@@ -1250,3 +1250,31 @@ func TestIsSuppressed(t *testing.T) {
if !isSuppressed("all-job", "GL042", suppressions) { t.Error("wildcard should suppress") } if !isSuppressed("all-job", "GL042", suppressions) { t.Error("wildcard should suppress") }
if isSuppressed("unknown-job", "GL001", suppressions) { t.Error("unknown job: not suppressed") } if isSuppressed("unknown-job", "GL001", suppressions) { t.Error("unknown job: not suppressed") }
} }
func TestDetectGitBranch(t *testing.T) {
orig := gitBranchInDir
t.Cleanup(func() { gitBranchInDir = orig })
tests := []struct {
name string
output string
err error
want string
}{
{"normal branch", "main\n", nil, "main"},
{"branch with trailing newline", "feature/my-branch\n", nil, "feature/my-branch"},
{"detached HEAD", "HEAD\n", nil, ""},
{"git error (not a repo)", "", errors.New("exit 128"), ""},
{"empty output", "\n", nil, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gitBranchInDir = func(_ string) ([]byte, error) {
return []byte(tt.output), tt.err
}
if got := detectGitBranch("."); got != tt.want {
t.Errorf("detectGitBranch() = %q, want %q", got, tt.want)
}
})
}
}
+111
View File
@@ -0,0 +1,111 @@
package main
import (
"fmt"
"io"
"os"
"strings"
"git.k3nny.fr/glint/internal/linter"
)
// ANSI escape sequences used for colorized output.
const (
ansiReset = "\033[0m"
ansiBold = "\033[1m"
ansiDim = "\033[2m"
ansiRed = "\033[31m"
ansiOrange = "\033[33m" // rendered as orange/amber in most terminals
)
// colorEnabled reports whether ANSI color should be used when writing to w.
// Colors are suppressed when NO_COLOR is set or when w is not a terminal.
func colorEnabled(w io.Writer) bool {
if os.Getenv("NO_COLOR") != "" {
return false
}
f, ok := w.(*os.File)
if !ok {
return false
}
fi, err := f.Stat()
return err == nil && (fi.Mode()&os.ModeCharDevice) != 0
}
// writeTextFindings prints findings in a columnized format with optional
// ANSI color. All findings are scanned first to compute column widths so
// that each field aligns across all output lines.
//
// Output columns (space-separated, no borders):
//
// location RULE severity message
func writeTextFindings(w io.Writer, findings []linter.Finding) {
if len(findings) == 0 {
return
}
color := colorEnabled(w)
// Pre-compute locations and maximum location width.
locs := make([]string, len(findings))
maxLoc := 0
for i, f := range findings {
locs[i] = findingLocation(f)
if len(locs[i]) > maxLoc {
maxLoc = len(locs[i])
}
}
// Rule IDs are always 5 chars (GL001GL999).
const ruleWidth = 5
// Severity width: "warning" = 7 chars.
const sevWidth = 7
for i, f := range findings {
loc := locs[i]
rule := f.Rule
sev := strings.ToLower(string(f.Severity))
msg := f.Message
if f.Job != "" {
msg = fmt.Sprintf("job %q: %s", f.Job, msg)
}
if color {
var sevSeq string
if f.Severity == linter.Error {
sevSeq = ansiRed + ansiBold
} else {
sevSeq = ansiOrange + ansiBold
}
fmt.Fprintf(w, "%s%-*s%s %s%-*s%s %s%-*s%s %s\n",
ansiDim, maxLoc, loc, ansiReset,
ansiBold, ruleWidth, rule, ansiReset,
sevSeq, sevWidth, sev, ansiReset,
msg,
)
} else {
fmt.Fprintf(w, "%-*s %-*s %-*s %s\n",
maxLoc, loc,
ruleWidth, rule,
sevWidth, sev,
msg,
)
}
}
}
// findingLocation formats the file:line:col location string for a finding.
func findingLocation(f linter.Finding) string {
if f.File == "" {
return ""
}
switch {
case f.Line > 0 && f.Column > 0:
return fmt.Sprintf("%s:%d:%d", f.File, f.Line, f.Column)
case f.Line > 0:
return fmt.Sprintf("%s:%d", f.File, f.Line)
default:
return f.File
}
}
+269
View File
@@ -0,0 +1,269 @@
package main
import (
"flag"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"git.k3nny.fr/glint/internal/config"
"git.k3nny.fr/glint/internal/fetcher"
"git.k3nny.fr/glint/internal/model"
"git.k3nny.fr/glint/internal/resolver"
"gopkg.in/yaml.v3"
)
func cmdRender(args []string) {
fs := flag.NewFlagSet("glint render", flag.ExitOnError)
output := fs.String("output", "", "output file path (default: rendered.gitlab-ci.yml; use - for stdout)")
token := fs.String("token", "", "GitLab personal access token (overrides GITLAB_TOKEN)")
gitlabURL := fs.String("gitlab-url", "", "GitLab instance URL (overrides CI_SERVER_URL / GITLAB_URL)")
cacheDir := fs.String("cache-dir", "", "directory to cache fetched remote includes")
offline := fs.Bool("offline", false, "skip all network calls; serve only from --cache-dir")
proxy := fs.String("proxy", "", "HTTP proxy URL for remote includes (e.g. http://proxy:8080)")
fs.Usage = func() {
fmt.Fprintf(os.Stderr, "glint %s\n\n", version)
fmt.Fprint(os.Stderr, `Resolve all includes and extends into a single merged YAML file.
Performs the same include resolution and extends merging that GitLab CI
does server-side, then writes the fully flattened pipeline to a single file.
Useful for inspecting the resolved pipeline or running further local tooling.
The output file strips 'include:' (consumed by resolution) and 'extends:'
(applied to each job) keys. All other fields are preserved verbatim.
Template jobs (names starting with '.') are retained.
Usage: glint render [OPTIONS] <PIPELINE>
Arguments:
<PIPELINE> Path to the .gitlab-ci.yml file to resolve
Options:
--output <FILE>
Write the rendered pipeline to FILE.
Use '-' to write to stdout.
[default: rendered.gitlab-ci.yml]
--token <TOKEN>
GitLab personal access token for fetching project: and component:
includes.
[env: GITLAB_TOKEN | CI_JOB_TOKEN | GITLAB_PRIVATE_TOKEN]
--gitlab-url <URL>
GitLab instance URL.
[env: CI_SERVER_URL | GITLAB_URL] [default: https://gitlab.com]
--cache-dir <DIR>
Cache directory for fetched remote includes.
--offline
Do not make any network calls; use cache only.
--proxy <URL>
HTTP proxy URL for remote includes and GitLab API calls.
-h, --help
Print help
Examples:
glint render .gitlab-ci.yml
glint render --output merged.yml .gitlab-ci.yml
glint render --output - .gitlab-ci.yml | yq .
glint render --offline --cache-dir ~/.cache/glint .gitlab-ci.yml
`)
}
_ = fs.Parse(args)
if fs.NArg() != 1 {
fs.Usage()
exit(2)
return
}
path := fs.Arg(0)
rootDir := filepath.Dir(filepath.Clean(path))
glintCfg, cfgErr := config.Load(rootDir)
if cfgErr != nil {
fmt.Fprintf(os.Stderr, "%s: [warning] %s: %v\n", path, config.Filename, cfgErr)
}
fetcherToken := *token
if fetcherToken == "" {
fetcherToken = glintCfg.Token
}
fetcherURL := *gitlabURL
if fetcherURL == "" {
fetcherURL = glintCfg.URL
}
resolvedCacheDir := *cacheDir
if resolvedCacheDir == "" {
resolvedCacheDir = glintCfg.CacheDir
}
if *offline && resolvedCacheDir == "" {
resolvedCacheDir = defaultCacheDir()
}
resolvedProxy := *proxy
if resolvedProxy == "" {
resolvedProxy = glintCfg.Proxy
}
cfg := fetcher.AutoConfig().WithOverrides(fetcherURL, fetcherToken, resolvedCacheDir, *offline).WithProxy(resolvedProxy)
p, err := model.Parse(path)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
exit(2)
return
}
warnings, _ := resolver.ResolveIncludes(p, cfg, rootDir)
for _, w := range warnings {
fmt.Fprintf(os.Stderr, "%s: [warning] include %s\n", path, w)
}
extWarnings, err := resolver.Resolve(p)
if err != nil {
fmt.Fprintf(os.Stderr, "error: resolving extends: %v\n", err)
exit(2)
return
}
for _, w := range extWarnings {
fmt.Fprintf(os.Stderr, "%s: [warning] job %q extends unknown job %q; extends chain skipped\n", path, w.Job, w.Base)
}
doc, err := buildRenderDoc(p)
if err != nil {
fmt.Fprintf(os.Stderr, "error: building output: %v\n", err)
exit(2)
return
}
outPath := *output
if outPath == "" {
outPath = "rendered.gitlab-ci.yml"
}
var w interface{ Write([]byte) (int, error) }
if outPath == "-" {
w = os.Stdout
} else {
f, err := os.Create(outPath)
if err != nil {
fmt.Fprintf(os.Stderr, "error: creating output file: %v\n", err)
exit(2)
return
}
defer f.Close()
w = f
}
enc := yaml.NewEncoder(w)
enc.SetIndent(2)
if err := enc.Encode(doc); err != nil {
fmt.Fprintf(os.Stderr, "error: writing output: %v\n", err)
exit(2)
return
}
_ = enc.Close()
if outPath != "-" {
jobCount := 0
for name := range p.Jobs {
if !strings.HasPrefix(name, ".") {
jobCount++
}
}
fmt.Fprintf(os.Stderr, "rendered: %s (%d job(s), %d stage(s))\n", outPath, jobCount, len(p.Stages))
}
}
// buildRenderDoc constructs an ordered yaml.Node document from the resolved
// pipeline. Pipeline-level keys (stages, variables, default, workflow) come
// first, followed by template jobs (.name) then regular jobs, both sorted
// alphabetically. 'include:' and 'extends:' are omitted — they have been
// consumed by the resolution passes.
func buildRenderDoc(p *model.Pipeline) (*yaml.Node, error) {
root := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
addField := func(key string, val any) error {
n, err := anyToNode(val)
if err != nil {
return fmt.Errorf("encoding %q: %w", key, err)
}
root.Content = append(root.Content,
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key},
n,
)
return nil
}
if len(p.Stages) > 0 {
if err := addField("stages", p.Stages); err != nil {
return nil, err
}
}
if len(p.Variables) > 0 {
if err := addField("variables", p.Variables); err != nil {
return nil, err
}
}
if p.Default != nil {
if err := addField("default", p.Default); err != nil {
return nil, err
}
}
if p.Workflow != nil {
if err := addField("workflow", p.Workflow); err != nil {
return nil, err
}
}
// Collect and sort job names: template jobs first, then regular jobs.
var templates, regular []string
for name := range p.RawJobs {
if strings.HasPrefix(name, ".") {
templates = append(templates, name)
} else {
regular = append(regular, name)
}
}
sort.Strings(templates)
sort.Strings(regular)
for _, name := range append(templates, regular...) {
raw := p.RawJobs[name]
// Copy to avoid mutating the shared map; strip resolution-consumed keys.
cleaned := make(map[string]any, len(raw))
for k, v := range raw {
if k == "extends" {
continue
}
cleaned[k] = v
}
if err := addField(name, cleaned); err != nil {
return nil, err
}
}
doc := &yaml.Node{Kind: yaml.DocumentNode, Content: []*yaml.Node{root}}
return doc, nil
}
// anyToNode converts an arbitrary Go value to a *yaml.Node by round-tripping
// through yaml.Marshal / yaml.Unmarshal, which preserves all value types.
func anyToNode(v any) (*yaml.Node, error) {
data, err := yaml.Marshal(v)
if err != nil {
return nil, err
}
var doc yaml.Node
if err := yaml.Unmarshal(data, &doc); err != nil {
return nil, err
}
if doc.Kind == yaml.DocumentNode && len(doc.Content) > 0 {
return doc.Content[0], nil
}
return &doc, nil
}
+3
View File
@@ -0,0 +1,3 @@
node_modules/
out/
*.vsix
+8
View File
@@ -0,0 +1,8 @@
src/**
tsconfig.json
.gitignore
node_modules/**
!node_modules/vscode-languageclient/**
!node_modules/vscode-languageserver-protocol/**
!node_modules/vscode-languageserver-types/**
!node_modules/vscode-jsonrpc/**
+2532
View File
File diff suppressed because it is too large Load Diff
+56
View File
@@ -0,0 +1,56 @@
{
"name": "glint",
"displayName": "glint — GitLab CI Linter",
"description": "Inline diagnostics for .gitlab-ci.yml pipelines powered by the glint language server",
"version": "0.2.29",
"publisher": "k3nny",
"license": "Apache-2.0",
"repository": {
"type": "git",
"url": "https://git.k3nny.fr/k3nny/glint"
},
"engines": {
"vscode": "^1.82.0"
},
"categories": [
"Linters"
],
"keywords": [
"gitlab",
"ci",
"yaml",
"lint",
"pipeline"
],
"activationEvents": [
"onLanguage:yaml"
],
"main": "./out/extension.js",
"contributes": {
"configuration": {
"type": "object",
"title": "glint",
"properties": {
"glint.executablePath": {
"type": "string",
"default": "glint",
"markdownDescription": "Path to the `glint` binary. Leave as `glint` if it is on your `PATH`, or set an absolute path (e.g. `/usr/local/bin/glint`)."
}
}
}
},
"scripts": {
"compile": "tsc -p ./",
"watch": "tsc --watch -p ./",
"package": "vsce package"
},
"dependencies": {
"vscode-languageclient": "^9.0.1"
},
"devDependencies": {
"@types/node": "^20",
"@types/vscode": "^1.82.0",
"@vscode/vsce": "^2.22.0",
"typescript": "^5.3.0"
}
}
+41
View File
@@ -0,0 +1,41 @@
import * as vscode from 'vscode';
import {
LanguageClient,
LanguageClientOptions,
ServerOptions,
TransportKind,
} from 'vscode-languageclient/node';
let client: LanguageClient | undefined;
export function activate(context: vscode.ExtensionContext): void {
const cfg = vscode.workspace.getConfiguration('glint');
const glintPath = cfg.get<string>('executablePath') || 'glint';
const serverOptions: ServerOptions = {
command: glintPath,
args: ['lsp'],
transport: TransportKind.stdio,
};
const clientOptions: LanguageClientOptions = {
// Only process .gitlab-ci.yml files, not all YAML.
documentSelector: [
{ scheme: 'file', language: 'yaml', pattern: '**/.gitlab-ci.yml' },
],
};
client = new LanguageClient(
'glint',
'glint — GitLab CI Linter',
serverOptions,
clientOptions,
);
client.start();
context.subscriptions.push(client);
}
export async function deactivate(): Promise<void> {
await client?.stop();
}
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"module": "commonjs",
"target": "ES2020",
"lib": ["ES2020"],
"outDir": "out",
"rootDir": "src",
"strict": true,
"sourceMap": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src"],
"exclude": ["node_modules", "out"]
}
+6 -5
View File
@@ -2,14 +2,15 @@ module git.k3nny.fr/glint
go 1.26.4 go 1.26.4
require gopkg.in/yaml.v3 v3.0.1
require ( require (
github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c // indirect github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c // indirect
golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 // indirect golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 // indirect
golang.org/x/mod v0.31.0 // indirect golang.org/x/mod v0.35.0 // indirect
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.20.0 // indirect
golang.org/x/tools v0.40.1-0.20260108161641-ca281cf95054 // indirect golang.org/x/tools v0.44.1-0.20260420230617-19499e7caabc // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect honnef.co/go/tools v0.8.0-rc.1 // indirect
honnef.co/go/tools v0.7.0 // indirect
) )
tool honnef.co/go/tools/cmd/staticcheck tool honnef.co/go/tools/cmd/staticcheck
+13 -9
View File
@@ -1,16 +1,20 @@
github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs= github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs=
github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 h1:1P7xPZEwZMoBoz0Yze5Nx2/4pxj6nw9ZqHWXqP0iRgQ= golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 h1:1P7xPZEwZMoBoz0Yze5Nx2/4pxj6nw9ZqHWXqP0iRgQ=
golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/tools v0.40.1-0.20260108161641-ca281cf95054 h1:CHVDrNHx9ZoOrNN9kKWYIbT5Rj+WF2rlwPkhbQQ5V4U= golang.org/x/tools v0.44.1-0.20260420230617-19499e7caabc h1:vSv/HN1q9eoPD7lMyJYVJ/GPYnqtqu6adMxUmrxOB78=
golang.org/x/tools v0.40.1-0.20260108161641-ca281cf95054/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= golang.org/x/tools v0.44.1-0.20260420230617-19499e7caabc/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM=
golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU= honnef.co/go/tools v0.8.0-rc.1 h1:wqMm2kjcEXMOr+6yau+pdKqJKe6l2N1aKPkpini+Kzk=
honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc= honnef.co/go/tools v0.8.0-rc.1/go.mod h1:XA+OnlRA9EDh/ukGvXMNSZNKGwFQJ+5dER0ioUkOxks=
+49 -4
View File
@@ -14,6 +14,32 @@ type Context struct {
changedFiles []string // nil = not provided (permissive); non-nil = known set of changed files changedFiles []string // nil = not provided (permissive); non-nil = known set of changed files
} }
// gitlabAlwaysVars are GitLab predefined variables that are constant across
// every pipeline, instance, and project. Injected at the lowest priority so
// that pipeline variables: blocks and --var overrides can still win.
var gitlabAlwaysVars = map[string]string{
"CI": "true",
"GITLAB_CI": "true",
}
// gitlabMRVars are predefined variables GitLab injects only for
// CI_PIPELINE_SOURCE=merge_request_event pipelines. Placeholder values are
// used so that rules:if: expressions that gate on MR context evaluate
// correctly. All can be overridden with --var.
//
// CI_MERGE_REQUEST_SOURCE_BRANCH_NAME is left empty here and filled from
// CI_COMMIT_BRANCH (the --branch flag) when available.
var gitlabMRVars = map[string]string{
"CI_MERGE_REQUEST_ID": "1",
"CI_MERGE_REQUEST_IID": "1",
"CI_MERGE_REQUEST_SOURCE_BRANCH_NAME": "",
"CI_MERGE_REQUEST_TARGET_BRANCH_NAME": "main",
"CI_MERGE_REQUEST_PROJECT_PATH": "namespace/project",
"CI_MERGE_REQUEST_TITLE": "Draft: placeholder",
"CI_MERGE_REQUEST_LABELS": "",
"CI_OPEN_MERGE_REQUESTS": "namespace/project!1",
}
// New builds a Context from high-level shortcut values and optional KEY=VALUE // New builds a Context from high-level shortcut values and optional KEY=VALUE
// overrides. Predefined CI variables are derived from the shortcuts so callers // overrides. Predefined CI variables are derived from the shortcuts so callers
// do not need to know their exact names. // do not need to know their exact names.
@@ -21,15 +47,18 @@ type Context struct {
// Returns an empty Context (IsEmpty() == true) when all inputs are zero values, // Returns an empty Context (IsEmpty() == true) when all inputs are zero values,
// preserving the existing linting behaviour when no context flags are given. // preserving the existing linting behaviour when no context flags are given.
// //
// Override priority (highest wins): extraVars > branch/tag/source shortcuts. // Override priority (highest wins): extraVars > branch/tag/source shortcuts >
// Both shortcut-derived and extraVar variables are pinned — they will not be // pipeline variables (via Inject) > GitLab predefined defaults.
// overwritten by Inject (used for pipeline-level and workflow-rule variables).
func New(branch, tag, source string, extraVars []string) *Context { func New(branch, tag, source string, extraVars []string) *Context {
if branch == "" && tag == "" && source == "" && len(extraVars) == 0 { if branch == "" && tag == "" && source == "" && len(extraVars) == 0 {
return &Context{} return &Context{}
} }
vars := make(map[string]string) // Seed with always-available GitLab predefined variables at lowest priority.
vars := make(map[string]string, len(gitlabAlwaysVars)+8)
for k, v := range gitlabAlwaysVars {
vars[k] = v
}
pinned := make(map[string]bool) pinned := make(map[string]bool)
pin := func(k, v string) { pin := func(k, v string) {
@@ -62,6 +91,22 @@ func New(branch, tag, source string, extraVars []string) *Context {
vars["CI_DEFAULT_BRANCH"] = "main" vars["CI_DEFAULT_BRANCH"] = "main"
} }
// For MR pipelines, inject placeholder values for the predefined MR
// variables so that rules:if: expressions like '$CI_MERGE_REQUEST_IID'
// evaluate as non-empty (truthy). These are non-pinned so --var overrides
// them and pipeline variables: can also override via Inject.
if source == "merge_request_event" {
for k, v := range gitlabMRVars {
if !pinned[k] {
vars[k] = v
}
}
// Derive source branch from --branch when available.
if branch != "" && vars["CI_MERGE_REQUEST_SOURCE_BRANCH_NAME"] == "" {
vars["CI_MERGE_REQUEST_SOURCE_BRANCH_NAME"] = branch
}
}
// KEY=VALUE overrides win over shortcuts and everything else. // KEY=VALUE overrides win over shortcuts and everything else.
for _, kv := range extraVars { for _, kv := range extraVars {
k, v, ok := strings.Cut(kv, "=") k, v, ok := strings.Cut(kv, "=")
+60
View File
@@ -78,6 +78,66 @@ func TestNew_DefaultBranch(t *testing.T) {
} }
} }
func TestNew_PredefinedAlwaysVars(t *testing.T) {
ctx := New("main", "", "", nil)
if ctx.Get("CI") != "true" {
t.Errorf("CI should be 'true', got %q", ctx.Get("CI"))
}
if ctx.Get("GITLAB_CI") != "true" {
t.Errorf("GITLAB_CI should be 'true', got %q", ctx.Get("GITLAB_CI"))
}
}
func TestNew_PredefinedAlwaysVars_EmptyContext(t *testing.T) {
// Always-vars must NOT be injected when the context is empty (no flags).
ctx := New("", "", "", nil)
if ctx.Get("CI") != "" {
t.Errorf("CI should be empty in empty context, got %q", ctx.Get("CI"))
}
}
func TestNew_PredefinedAlwaysVars_OverridableByVar(t *testing.T) {
ctx := New("main", "", "", []string{"CI=false"})
if ctx.Get("CI") != "false" {
t.Errorf("--var should override CI, got %q", ctx.Get("CI"))
}
}
func TestNew_PredefinedAlwaysVars_OverridableByInject(t *testing.T) {
ctx := New("main", "", "", nil)
ctx.Inject("CI", "custom")
if ctx.Get("CI") != "custom" {
t.Errorf("Inject should override non-pinned CI, got %q", ctx.Get("CI"))
}
}
func TestNew_MRVars(t *testing.T) {
ctx := New("feature/my-branch", "", "merge_request_event", nil)
if ctx.Get("CI_MERGE_REQUEST_IID") != "1" {
t.Errorf("CI_MERGE_REQUEST_IID should be '1', got %q", ctx.Get("CI_MERGE_REQUEST_IID"))
}
if ctx.Get("CI_MERGE_REQUEST_SOURCE_BRANCH_NAME") != "feature/my-branch" {
t.Errorf("source branch should match --branch, got %q", ctx.Get("CI_MERGE_REQUEST_SOURCE_BRANCH_NAME"))
}
if ctx.Get("CI_MERGE_REQUEST_TARGET_BRANCH_NAME") != "main" {
t.Errorf("target branch should be 'main', got %q", ctx.Get("CI_MERGE_REQUEST_TARGET_BRANCH_NAME"))
}
}
func TestNew_MRVars_NotInjectedForNonMR(t *testing.T) {
ctx := New("main", "", "push", nil)
if ctx.Get("CI_MERGE_REQUEST_IID") != "" {
t.Errorf("CI_MERGE_REQUEST_IID should be empty for push pipeline, got %q", ctx.Get("CI_MERGE_REQUEST_IID"))
}
}
func TestNew_MRVars_OverridableByVar(t *testing.T) {
ctx := New("", "", "merge_request_event", []string{"CI_MERGE_REQUEST_IID=42"})
if ctx.Get("CI_MERGE_REQUEST_IID") != "42" {
t.Errorf("--var should override CI_MERGE_REQUEST_IID, got %q", ctx.Get("CI_MERGE_REQUEST_IID"))
}
}
func TestInject(t *testing.T) { func TestInject(t *testing.T) {
ctx := New("main", "", "", nil) ctx := New("main", "", "", nil)
// pinned var should not be overwritten // pinned var should not be overwritten
+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)
})
}
+6
View File
@@ -38,6 +38,12 @@ type Config struct {
// CacheDir is the default directory for caching fetched remote includes. // CacheDir is the default directory for caching fetched remote includes.
// Overridden by the --cache-dir flag. // Overridden by the --cache-dir flag.
CacheDir string `yaml:"cache_dir"` CacheDir string `yaml:"cache_dir"`
// Proxy is the HTTP proxy URL for fetching remote includes and GitLab API
// calls (e.g. "http://proxy.example.com:8080"). Overridden by the --proxy
// flag. When empty, system proxy settings (HTTP_PROXY / HTTPS_PROXY /
// NO_PROXY env vars) are used automatically.
Proxy string `yaml:"proxy"`
} }
// Load searches for a .glint.yml file starting from dir and walking up toward // Load searches for a .glint.yml file starting from dir and walking up toward
+3
View File
@@ -103,6 +103,9 @@ func TestLoad_StopsAtGitRoot(t *testing.T) {
// TestLoad_ReadError covers the !os.IsNotExist(err) branch (config.go:59-61) // TestLoad_ReadError covers the !os.IsNotExist(err) branch (config.go:59-61)
// when the file exists but is not readable. // when the file exists but is not readable.
func TestLoad_ReadError(t *testing.T) { func TestLoad_ReadError(t *testing.T) {
if os.Getuid() == 0 {
t.Skip("skipping: root bypasses file permission checks")
}
tmp := t.TempDir() tmp := t.TempDir()
cfgPath := filepath.Join(tmp, Filename) cfgPath := filepath.Join(tmp, Filename)
if err := os.WriteFile(cfgPath, []byte("ignore: []"), 0o000); err != nil { if err := os.WriteFile(cfgPath, []byte("ignore: []"), 0o000); err != nil {
+2 -2
View File
@@ -26,10 +26,10 @@ func cacheWrite(dir, key string, data []byte) {
if dir == "" { if dir == "" {
return return
} }
if err := os.MkdirAll(dir, 0o755); err != nil { if err := os.MkdirAll(dir, 0o700); err != nil {
return return
} }
_ = os.WriteFile(cachePath(dir, key), data, 0o644) _ = os.WriteFile(cachePath(dir, key), data, 0o600)
} }
// cachePath returns the filesystem path for a cache entry. // cachePath returns the filesystem path for a cache entry.
+25
View File
@@ -71,3 +71,28 @@ func TestCacheWrite_MkdirAll(t *testing.T) {
t.Errorf("directory not created: %v", err) t.Errorf("directory not created: %v", err)
} }
} }
func TestCacheWrite_FilePermissions(t *testing.T) {
dir := t.TempDir()
cacheWrite(dir, "seckey", []byte("secret"))
info, err := os.Stat(cachePath(dir, "seckey"))
if err != nil {
t.Fatalf("stat cache file: %v", err)
}
if mode := info.Mode().Perm(); mode != 0o600 {
t.Errorf("cache file mode = %04o; want 0600", mode)
}
}
func TestCacheWrite_DirPermissions(t *testing.T) {
parent := t.TempDir()
dir := filepath.Join(parent, "glint-cache")
cacheWrite(dir, "k", []byte("v"))
info, err := os.Stat(dir)
if err != nil {
t.Fatalf("stat cache dir: %v", err)
}
if mode := info.Mode().Perm(); mode != 0o700 {
t.Errorf("cache dir mode = %04o; want 0700", mode)
}
}
+51 -4
View File
@@ -8,8 +8,18 @@ import (
"net/url" "net/url"
"os" "os"
"strings" "strings"
"time"
) )
// httpClient is the shared HTTP client for all fetcher requests.
// Timeout guards against hung remote servers. Transport is intentionally nil
// so http.DefaultTransport is used dynamically — allowing tests to swap it.
var httpClient = &http.Client{Timeout: 30 * time.Second}
// maxResponseBytes is the per-response size cap. Responses larger than this
// are rejected to prevent memory exhaustion from pathological servers.
const maxResponseBytes int64 = 10 << 20 // 10 MiB
// TokenSource describes where a token was found, which determines the correct // TokenSource describes where a token was found, which determines the correct
// authentication header to use with the GitLab API. // authentication header to use with the GitLab API.
type TokenSource int type TokenSource int
@@ -27,6 +37,10 @@ type GitLabConfig struct {
Source TokenSource Source TokenSource
CacheDir string // local cache directory; empty = caching disabled CacheDir string // local cache directory; empty = caching disabled
Offline bool // when true, return an error instead of making network calls Offline bool // when true, return an error instead of making network calls
// ProxyURL overrides the HTTP proxy for all requests made with this config.
// Empty string means use system proxy settings (HTTP_PROXY / HTTPS_PROXY /
// NO_PROXY env vars honoured automatically by http.DefaultTransport).
ProxyURL string
} }
// AutoConfig builds a GitLabConfig from environment variables. // AutoConfig builds a GitLabConfig from environment variables.
@@ -56,6 +70,33 @@ func AutoConfig() GitLabConfig {
return cfg return cfg
} }
// WithProxy returns a copy of cfg with ProxyURL set.
func (cfg GitLabConfig) WithProxy(proxyURL string) GitLabConfig {
cfg.ProxyURL = proxyURL
return cfg
}
// client returns an HTTP client for this config.
// When ProxyURL is set it takes precedence over system proxy env vars;
// otherwise http.DefaultTransport's built-in ProxyFromEnvironment is used.
func (cfg GitLabConfig) client() *http.Client {
if cfg.ProxyURL == "" {
return httpClient
}
proxyURL, err := url.Parse(cfg.ProxyURL)
if err != nil {
return httpClient
}
// Clone the default transport so all TLS/dial settings are preserved.
t, ok := http.DefaultTransport.(*http.Transport)
if !ok {
return httpClient
}
transport := t.Clone()
transport.Proxy = http.ProxyURL(proxyURL)
return &http.Client{Timeout: httpClient.Timeout, Transport: transport}
}
// WithOverrides returns a copy of cfg with the provided overrides applied. // WithOverrides returns a copy of cfg with the provided overrides applied.
// Non-empty strings overwrite the corresponding field; booleans are always // Non-empty strings overwrite the corresponding field; booleans are always
// applied (so offline: false explicitly clears offline mode). // applied (so offline: false explicitly clears offline mode).
@@ -128,16 +169,19 @@ func (cfg GitLabConfig) FetchFile(project, filePath, ref string) ([]byte, error)
} }
} }
resp, err := http.DefaultClient.Do(req) resp, err := cfg.client().Do(req)
if err != nil { if err != nil {
return nil, fmt.Errorf("GET %s: %w", apiURL, err) return nil, fmt.Errorf("GET %s: %w", apiURL, err)
} }
defer resp.Body.Close() defer resp.Body.Close()
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes+1))
if err != nil { if err != nil {
return nil, fmt.Errorf("reading response body: %w", err) return nil, fmt.Errorf("reading response body: %w", err)
} }
if int64(len(body)) > maxResponseBytes {
return nil, fmt.Errorf("response body exceeds maximum size (%d MiB)", maxResponseBytes>>20)
}
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
msg := strings.TrimSpace(string(body)) msg := strings.TrimSpace(string(body))
@@ -169,15 +213,18 @@ func (cfg GitLabConfig) FetchURL(rawURL string) ([]byte, error) {
return nil, fmt.Errorf("offline mode: %s is not in the local cache (run without --offline first to populate the cache)", rawURL) return nil, fmt.Errorf("offline mode: %s is not in the local cache (run without --offline first to populate the cache)", rawURL)
} }
resp, err := http.Get(rawURL) //nolint:noctx resp, err := cfg.client().Get(rawURL)
if err != nil { if err != nil {
return nil, fmt.Errorf("GET %s: %w", rawURL, err) return nil, fmt.Errorf("GET %s: %w", rawURL, err)
} }
defer resp.Body.Close() defer resp.Body.Close()
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes+1))
if err != nil { if err != nil {
return nil, fmt.Errorf("reading body: %w", err) return nil, fmt.Errorf("reading body: %w", err)
} }
if int64(len(body)) > maxResponseBytes {
return nil, fmt.Errorf("response body exceeds maximum size (%d MiB)", maxResponseBytes>>20)
}
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GET %s: status %d", rawURL, resp.StatusCode) return nil, fmt.Errorf("GET %s: status %d", rawURL, resp.StatusCode)
} }
+44
View File
@@ -6,6 +6,7 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
"strings"
"testing" "testing"
) )
@@ -339,3 +340,46 @@ func TestFetchURL_NotOK(t *testing.T) {
_, err := cfg.FetchURL(srv.URL) _, err := cfg.FetchURL(srv.URL)
if err == nil { t.Fatal("expected error for non-200 status") } if err == nil { t.Fatal("expected error for non-200 status") }
} }
func TestFetchFile_ResponseTooLarge(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
// Write maxResponseBytes+1 bytes to exceed the cap.
chunk := make([]byte, 4096)
written := int64(0)
for written <= maxResponseBytes {
n, _ := w.Write(chunk)
written += int64(n)
}
}))
defer srv.Close()
cfg := GitLabConfig{BaseURL: srv.URL}
_, err := cfg.FetchFile("group/project", "/ci.yml", "main")
if err == nil {
t.Fatal("expected error for oversized response")
}
if !strings.Contains(err.Error(), "exceeds maximum size") {
t.Errorf("unexpected error: %v", err)
}
}
func TestFetchURL_ResponseTooLarge(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
chunk := make([]byte, 4096)
written := int64(0)
for written <= maxResponseBytes {
n, _ := w.Write(chunk)
written += int64(n)
}
}))
defer srv.Close()
cfg := GitLabConfig{}
_, err := cfg.FetchURL(srv.URL)
if err == nil {
t.Fatal("expected error for oversized response")
}
if !strings.Contains(err.Error(), "exceeds maximum size") {
t.Errorf("unexpected error: %v", err)
}
}
+2
View File
@@ -30,6 +30,7 @@ func checkDependencies(p *model.Pipeline, skipped map[string]bool) []Finding {
Job: name, Job: name,
File: job.File, File: job.File,
Line: job.Line, Line: job.Line,
Column: job.Column,
Message: fmt.Sprintf("'dependencies' references unknown job %q", dep), Message: fmt.Sprintf("'dependencies' references unknown job %q", dep),
}) })
continue continue
@@ -43,6 +44,7 @@ func checkDependencies(p *model.Pipeline, skipped map[string]bool) []Finding {
Job: name, Job: name,
File: job.File, File: job.File,
Line: job.Line, Line: job.Line,
Column: job.Column,
Message: fmt.Sprintf("'dependencies' job %q must be in an earlier stage (in %q, current job is in %q)", dep, depJob.Stage, job.Stage), Message: fmt.Sprintf("'dependencies' job %q must be in an earlier stage (in %q, current job is in %q)", dep, depJob.Stage, job.Stage),
}) })
} }
+14
View File
@@ -853,4 +853,18 @@ test:
- if: $CI_COMMIT_BRANCH == "main" - if: $CI_COMMIT_BRANCH == "main"
needs: [build, lint]`, needs: [build, lint]`,
}, },
RuleInsecureRemoteInclude: {
Title: "remote include uses plain HTTP",
Severity: Warning,
Description: "An include: remote: entry uses an http:// URL instead of https://. " +
"CI templates fetched over plain HTTP are transmitted in cleartext; an " +
"attacker with network access could intercept or modify the template " +
"before it is parsed. Use https:// so the connection is encrypted and " +
"the server's identity is verified.",
Example: `include:
- remote: http://ci-templates.example.com/build.yml`,
Fix: `include:
- remote: https://ci-templates.example.com/build.yml`,
},
} }
+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)
})
}
+1
View File
@@ -99,6 +99,7 @@ func evalRulesReachability(name string, job model.Job, jobVars map[string]string
Job: name, Job: name,
File: job.File, File: job.File,
Line: job.Line, Line: job.Line,
Column: job.Column,
Message: "rules: block can never activate: all if: conditions evaluate to false given the declared pipeline variables", Message: "rules: block can never activate: all if: conditions evaluate to false given the declared pipeline variables",
} }
} }
+31
View File
@@ -0,0 +1,31 @@
package linter
import (
"fmt"
"strings"
"git.k3nny.fr/glint/internal/model"
)
// checkInsecureRemoteInclude warns when an include: remote: entry uses plain
// HTTP (GL045). The file is still fetched and linted; the finding is a warning
// so pipelines that use HTTP for internal infra are not blocked.
func checkInsecureRemoteInclude(p *model.Pipeline) []Finding {
var findings []Finding
for _, inc := range p.Include {
m, ok := inc.(map[string]any)
if !ok {
continue
}
remote, _ := m["remote"].(string)
if strings.HasPrefix(remote, "http://") {
findings = append(findings, Finding{
Severity: Warning,
Rule: RuleInsecureRemoteInclude,
File: p.SourceFile,
Message: fmt.Sprintf("remote include %q uses plain HTTP; CI templates are fetched unencrypted — prefer HTTPS", remote),
})
}
}
return findings
}
+73
View File
@@ -0,0 +1,73 @@
package linter
import (
"testing"
"git.k3nny.fr/glint/internal/model"
)
func TestCheckInsecureRemoteInclude(t *testing.T) {
tests := []struct {
name string
include []any
wantGL string // expected rule ID, empty = no findings
}{
{
name: "http URL triggers GL045",
include: []any{map[string]any{"remote": "http://example.com/ci.yml"}},
wantGL: RuleInsecureRemoteInclude,
},
{
name: "https URL is clean",
include: []any{map[string]any{"remote": "https://example.com/ci.yml"}},
},
{
name: "local include ignored",
include: []any{map[string]any{"local": "/templates/ci.yml"}},
},
{
name: "string include ignored",
include: []any{"/templates/ci.yml"},
},
{
name: "no includes",
include: nil,
},
{
name: "mixed: http fires, https does not",
include: []any{
map[string]any{"remote": "https://ok.example.com/ci.yml"},
map[string]any{"remote": "http://bad.example.com/ci.yml"},
},
wantGL: RuleInsecureRemoteInclude,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
p := &model.Pipeline{
Include: tc.include,
Jobs: map[string]model.Job{},
}
findings := checkInsecureRemoteInclude(p)
if tc.wantGL == "" {
if len(findings) != 0 {
t.Errorf("expected no findings, got: %v", findings)
}
return
}
found := false
for _, f := range findings {
if f.Rule == tc.wantGL {
found = true
if f.Severity != Warning {
t.Errorf("severity = %v; want Warning", f.Severity)
}
}
}
if !found {
t.Errorf("expected finding %s, got: %v", tc.wantGL, findings)
}
})
}
}
+2
View File
@@ -40,6 +40,7 @@ func checkJobInheritCompleteness(p *model.Pipeline, name string, job model.Job)
Job: name, Job: name,
File: job.File, File: job.File,
Line: job.Line, Line: job.Line,
Column: job.Column,
Message: "'inherit: default:' is declared but the pipeline has no 'default:' block — declaration has no effect", Message: "'inherit: default:' is declared but the pipeline has no 'default:' block — declaration has no effect",
}} }}
} }
@@ -70,6 +71,7 @@ func checkJobInheritCompleteness(p *model.Pipeline, name string, job model.Job)
Job: name, Job: name,
File: job.File, File: job.File,
Line: job.Line, Line: job.Line,
Column: job.Column,
Message: fmt.Sprintf( Message: fmt.Sprintf(
"'inherit: default: [%s]': %s not defined in the 'default:' block — %s", "'inherit: default: [%s]': %s not defined in the 'default:' block — %s",
strings.Join(dead, ", "), strings.Join(dead, ", "),
+8 -2
View File
@@ -22,15 +22,19 @@ type Finding struct {
Job string // empty for pipeline-level findings Job string // empty for pipeline-level findings
File string // source file where the finding originates File string // source file where the finding originates
Line int // line number in File (0 = unknown) Line int // line number in File (0 = unknown)
Column int // column number in File (0 = unknown; 1-indexed)
Message string Message string
} }
func (f Finding) String() string { func (f Finding) String() string {
var loc string var loc string
if f.File != "" { if f.File != "" {
if f.Line > 0 { switch {
case f.Line > 0 && f.Column > 0:
loc = fmt.Sprintf("%s:%d:%d: ", f.File, f.Line, f.Column)
case f.Line > 0:
loc = fmt.Sprintf("%s:%d: ", f.File, f.Line) loc = fmt.Sprintf("%s:%d: ", f.File, f.Line)
} else { default:
loc = fmt.Sprintf("%s: ", f.File) loc = fmt.Sprintf("%s: ", f.File)
} }
} }
@@ -67,6 +71,7 @@ func Lint(p *model.Pipeline, skipped map[string]bool) []Finding {
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)...)
findings = append(findings, checkInsecureRemoteInclude(p)...)
slices.SortStableFunc(findings, func(a, b Finding) int { slices.SortStableFunc(findings, func(a, b Finding) int {
if c := cmp.Compare(a.File, b.File); c != 0 { if c := cmp.Compare(a.File, b.File); c != 0 {
return c return c
@@ -224,6 +229,7 @@ func checkJob(name string, job model.Job, stageSet map[string]bool) []Finding {
if findings[i].Job != "" && findings[i].File == "" { if findings[i].Job != "" && findings[i].File == "" {
findings[i].File = job.File findings[i].File = job.File
findings[i].Line = job.Line findings[i].Line = job.Line
findings[i].Column = job.Column
} }
} }
return findings return findings
+4
View File
@@ -53,6 +53,7 @@ func checkNeeds(p *model.Pipeline, skipped map[string]bool) []Finding {
Job: name, Job: name,
File: job.File, File: job.File,
Line: job.Line, Line: job.Line,
Column: job.Column,
Message: fmt.Sprintf("needs unknown job %q", entry.job), Message: fmt.Sprintf("needs unknown job %q", entry.job),
}) })
continue continue
@@ -71,6 +72,7 @@ func checkNeeds(p *model.Pipeline, skipped map[string]bool) []Finding {
Job: name, Job: name,
File: job.File, File: job.File,
Line: job.Line, Line: job.Line,
Column: job.Column,
Message: fmt.Sprintf( Message: fmt.Sprintf(
"needs %q which is in a later stage (%q after %q)", "needs %q which is in a later stage (%q after %q)",
entry.job, neededJob.Stage, job.Stage, entry.job, neededJob.Stage, job.Stage,
@@ -111,6 +113,7 @@ func checkRulesNeeds(p *model.Pipeline, skipped map[string]bool) []Finding {
Job: name, Job: name,
File: job.File, File: job.File,
Line: job.Line, Line: job.Line,
Column: job.Column,
Message: fmt.Sprintf( Message: fmt.Sprintf(
"rules[%d].needs: references unknown job %q", "rules[%d].needs: references unknown job %q",
i, entry.job, i, entry.job,
@@ -171,6 +174,7 @@ func detectNeedsCycles(graph map[string][]string, jobs map[string]model.Job) []F
Job: name, Job: name,
File: j.File, File: j.File,
Line: j.Line, Line: j.Line,
Column: j.Column,
Message: fmt.Sprintf("circular dependency in needs: %v → %s", path, name), Message: fmt.Sprintf("circular dependency in needs: %v → %s", path, name),
}) })
} }
+5
View File
@@ -158,4 +158,9 @@ const (
// GL044: a rules:needs: entry references a job that does not exist in the pipeline. // 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+). // rules:needs: overrides the top-level needs: when a specific rule matches (GitLab CI 16.4+).
RuleRulesNeedsUnknown = "GL044" RuleRulesNeedsUnknown = "GL044"
// GL045: an include: remote: entry uses plain HTTP instead of HTTPS.
// CI templates fetched over HTTP are transmitted in cleartext and can be
// intercepted or modified in transit.
RuleInsecureRemoteInclude = "GL045"
) )
+1
View File
@@ -148,6 +148,7 @@ func checkVariableRefs(p *model.Pipeline) []Finding {
Job: name, Job: name,
File: job.File, File: job.File,
Line: job.Line, Line: job.Line,
Column: job.Column,
Message: fmt.Sprintf("rules[%d].if: $%s is not declared in pipeline or job variables:", i, varName), Message: fmt.Sprintf("rules[%d].if: $%s is not declared in pipeline or job variables:", i, varName),
}) })
} }
+340
View File
@@ -0,0 +1,340 @@
package lsp
import (
"bufio"
"encoding/json"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"git.k3nny.fr/glint/internal/fetcher"
"git.k3nny.fr/glint/internal/linter"
"git.k3nny.fr/glint/internal/model"
"git.k3nny.fr/glint/internal/resolver"
)
// Server is a minimal Language Server Protocol server that publishes glint
// diagnostics for .gitlab-ci.yml files opened in an editor.
//
// Transport: JSON-RPC 2.0 over stdin/stdout with Content-Length framing.
// Sync mode: Full — the client sends the complete document text on every change.
type Server struct {
in *bufio.Reader
out io.Writer
cfg fetcher.GitLabConfig
version string
docs map[string]string // uri → current document text
// Exit is called with the process exit code when the LSP client sends the
// "exit" notification. Defaults to os.Exit; replace in tests.
Exit func(int)
shutdownReceived bool
}
// New creates a Server reading from r and writing to w.
func New(r io.Reader, w io.Writer, cfg fetcher.GitLabConfig, version string) *Server {
return &Server{
in: bufio.NewReader(r),
out: w,
cfg: cfg,
version: version,
docs: make(map[string]string),
Exit: os.Exit,
}
}
// Run processes LSP messages until the connection closes or a fatal error occurs.
// It returns nil on a clean EOF (client disconnected) and a non-nil error for
// unrecoverable protocol failures.
func (s *Server) Run() error {
for {
msg, err := s.readMessage()
if err == io.EOF {
return nil
}
if err != nil {
return fmt.Errorf("reading LSP message: %w", err)
}
if err := s.dispatch(msg); err != nil {
return err
}
}
}
// maxLSPMessageBytes caps the body size accepted from an LSP client.
// A legitimate editor message is never this large; enforcing the cap prevents
// a crafted Content-Length from triggering a multi-gigabyte allocation.
const maxLSPMessageBytes = 64 << 20 // 64 MiB
// readMessage reads one Content-Lengthframed JSON-RPC message from the stream.
func (s *Server) readMessage() (*Message, error) {
var contentLength int
for {
line, err := s.in.ReadString('\n')
if err != nil {
if err == io.EOF && line == "" {
return nil, io.EOF
}
return nil, err
}
line = strings.TrimRight(line, "\r\n")
if line == "" {
break // blank line separates headers from body
}
if strings.HasPrefix(line, "Content-Length: ") {
n, parseErr := strconv.Atoi(strings.TrimPrefix(line, "Content-Length: "))
if parseErr != nil {
return nil, fmt.Errorf("invalid Content-Length: %w", parseErr)
}
contentLength = n
}
}
if contentLength == 0 {
return nil, fmt.Errorf("missing or zero Content-Length header")
}
if contentLength > maxLSPMessageBytes {
return nil, fmt.Errorf("Content-Length %d exceeds maximum %d", contentLength, maxLSPMessageBytes)
}
body := make([]byte, contentLength)
if _, err := io.ReadFull(s.in, body); err != nil {
return nil, fmt.Errorf("reading message body: %w", err)
}
var msg Message
if err := json.Unmarshal(body, &msg); err != nil {
return nil, fmt.Errorf("unmarshalling message: %w", err)
}
return &msg, nil
}
// writeMessage encodes v as JSON and sends it with a Content-Length header.
func (s *Server) writeMessage(v any) error {
body, err := json.Marshal(v)
if err != nil {
return err
}
header := fmt.Sprintf("Content-Length: %d\r\n\r\n", len(body))
if _, err := io.WriteString(s.out, header); err != nil {
return err
}
_, err = s.out.Write(body)
return err
}
func (s *Server) respond(id json.RawMessage, result any) error {
raw, err := json.Marshal(result)
if err != nil {
return err
}
return s.writeMessage(struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Result json.RawMessage `json:"result"`
}{"2.0", id, raw})
}
func (s *Server) respondError(id json.RawMessage, code int, message string) error {
return s.writeMessage(struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Error RPCError `json:"error"`
}{"2.0", id, RPCError{Code: code, Message: message}})
}
func (s *Server) notify(method string, params any) error {
raw, err := json.Marshal(params)
if err != nil {
return err
}
return s.writeMessage(struct {
JSONRPC string `json:"jsonrpc"`
Method string `json:"method"`
Params json.RawMessage `json:"params"`
}{"2.0", method, raw})
}
// isRequest reports whether msg is a JSON-RPC request (has a non-null id).
func isRequest(msg *Message) bool {
return len(msg.ID) > 0 && string(msg.ID) != "null"
}
func (s *Server) dispatch(msg *Message) error {
switch msg.Method {
case "initialize":
return s.handleInitialize(msg)
case "initialized":
return nil // notification; no response required
case "shutdown":
s.shutdownReceived = true
if isRequest(msg) {
return s.respond(msg.ID, nil)
}
return nil
case "exit":
code := 1
if s.shutdownReceived {
code = 0
}
s.Exit(code)
return nil
case "textDocument/didOpen":
return s.handleDidOpen(msg)
case "textDocument/didChange":
return s.handleDidChange(msg)
case "textDocument/didSave":
return s.handleDidSave(msg)
case "textDocument/didClose":
return s.handleDidClose(msg)
default:
if isRequest(msg) {
return s.respondError(msg.ID, -32601, "method not found: "+msg.Method)
}
return nil
}
}
func (s *Server) handleInitialize(msg *Message) error {
return s.respond(msg.ID, InitializeResult{
Capabilities: ServerCapabilities{TextDocumentSync: 1},
ServerInfo: &ServerInfo{Name: "glint", Version: s.version},
})
}
func (s *Server) handleDidOpen(msg *Message) error {
var p DidOpenTextDocumentParams
if err := json.Unmarshal(msg.Params, &p); err != nil {
return nil // ignore malformed notifications
}
s.docs[p.TextDocument.URI] = p.TextDocument.Text
return s.lintAndPublish(p.TextDocument.URI, p.TextDocument.Text)
}
func (s *Server) handleDidChange(msg *Message) error {
var p DidChangeTextDocumentParams
if err := json.Unmarshal(msg.Params, &p); err != nil {
return nil
}
if len(p.ContentChanges) == 0 {
return nil
}
// Full sync: the last change event holds the complete new text.
text := p.ContentChanges[len(p.ContentChanges)-1].Text
s.docs[p.TextDocument.URI] = text
return s.lintAndPublish(p.TextDocument.URI, text)
}
func (s *Server) handleDidSave(msg *Message) error {
var p DidSaveTextDocumentParams
if err := json.Unmarshal(msg.Params, &p); err != nil {
return nil
}
text := s.docs[p.TextDocument.URI]
if p.Text != nil {
text = *p.Text
s.docs[p.TextDocument.URI] = text
}
if text == "" {
return nil
}
return s.lintAndPublish(p.TextDocument.URI, text)
}
func (s *Server) handleDidClose(msg *Message) error {
var p DidCloseTextDocumentParams
if err := json.Unmarshal(msg.Params, &p); err != nil {
return nil
}
delete(s.docs, p.TextDocument.URI)
// Clear diagnostics so the editor doesn't show stale squiggles.
return s.notify("textDocument/publishDiagnostics", PublishDiagnosticsParams{
URI: p.TextDocument.URI,
Diagnostics: []Diagnostic{},
})
}
func (s *Server) lintAndPublish(uri, text string) error {
diags := s.lintDocument(uri, text)
return s.notify("textDocument/publishDiagnostics", PublishDiagnosticsParams{
URI: uri,
Diagnostics: diags,
})
}
// lintDocument parses text and runs all lint rules, returning LSP Diagnostics.
// Findings that originate from included files (not the root document) are
// excluded; their URIs are not tracked so line numbers would be incorrect.
func (s *Server) lintDocument(uri, text string) []Diagnostic {
path := uriToPath(uri)
if path == "" {
return []Diagnostic{}
}
rootDir := filepath.Dir(filepath.Clean(path))
pipeline, err := model.ParseBytes([]byte(text))
if err != nil {
return []Diagnostic{{
Range: Range{Start: Position{}, End: Position{}},
Severity: 1,
Source: "glint",
Message: "YAML parse error: " + err.Error(),
}}
}
pipeline.SourceFile = path
pipeline.SetJobOrigin(path)
// Include resolution is best-effort: network failures produce warnings that
// are intentionally discarded here. The linter operates on whatever was
// successfully resolved.
_, _ = resolver.ResolveIncludes(pipeline, s.cfg, rootDir)
_, _ = resolver.Resolve(pipeline)
findings := linter.Lint(pipeline, nil)
diags := make([]Diagnostic, 0, len(findings))
for _, f := range findings {
// Skip findings from included files — their line numbers reference
// a different document URI that the server has not opened.
if f.File != path && f.File != "" {
continue
}
line := 0
if f.Line > 0 {
line = f.Line - 1 // glint uses 1-based lines; LSP uses 0-based
}
sev := 1 // DiagnosticSeverity: Error
if f.Severity == linter.Warning {
sev = 2 // DiagnosticSeverity: Warning
}
msg := f.Message
if f.Job != "" {
msg = fmt.Sprintf("job %q: %s", f.Job, f.Message)
}
diags = append(diags, Diagnostic{
Range: Range{
Start: Position{Line: line},
End: Position{Line: line},
},
Severity: sev,
Code: f.Rule,
Source: "glint",
Message: msg,
})
}
return diags
}
// uriToPath converts a file:// URI to a local filesystem path.
// Returns an empty string for non-file URIs or on parse error.
func uriToPath(uri string) string {
u, err := url.Parse(uri)
if err != nil || u.Scheme != "file" {
return ""
}
return filepath.FromSlash(u.Path)
}
+397
View File
@@ -0,0 +1,397 @@
package lsp
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"testing"
"git.k3nny.fr/glint/internal/fetcher"
)
// frame encodes v as a Content-Lengthframed LSP message.
func frame(t *testing.T, v any) []byte {
t.Helper()
body, err := json.Marshal(v)
if err != nil {
t.Fatal(err)
}
hdr := fmt.Sprintf("Content-Length: %d\r\n\r\n", len(body))
return append([]byte(hdr), body...)
}
// readMsg reads one Content-Lengthframed JSON object from r.
func readMsg(t *testing.T, r *bufio.Reader) map[string]json.RawMessage {
t.Helper()
var contentLength int
for {
line, err := r.ReadString('\n')
if err != nil {
t.Fatalf("reading header: %v", err)
}
line = strings.TrimRight(line, "\r\n")
if line == "" {
break
}
if strings.HasPrefix(line, "Content-Length: ") {
n, err := strconv.Atoi(strings.TrimPrefix(line, "Content-Length: "))
if err != nil {
t.Fatalf("invalid Content-Length: %v", err)
}
contentLength = n
}
}
body := make([]byte, contentLength)
if _, err := io.ReadFull(r, body); err != nil {
t.Fatalf("reading body: %v", err)
}
var m map[string]json.RawMessage
if err := json.Unmarshal(body, &m); err != nil {
t.Fatalf("unmarshal: %v", err)
}
return m
}
// newTestServer returns a Server with a captured exit code and a bufio.Reader
// wrapping the output buffer so tests can read back server messages.
func newTestServer(input []byte) (*Server, *bytes.Buffer, *int) {
var out bytes.Buffer
exitCode := -1
srv := New(bytes.NewReader(input), &out, fetcher.GitLabConfig{}, "test")
srv.Exit = func(code int) { exitCode = code }
return srv, &out, &exitCode
}
func TestServer_Initialize(t *testing.T) {
input := frame(t, map[string]any{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": map[string]any{},
})
srv, out, _ := newTestServer(input)
srv.Run() //nolint:errcheck
resp := readMsg(t, bufio.NewReader(out))
if string(resp["id"]) != "1" {
t.Errorf("response id = %s; want 1", resp["id"])
}
var result InitializeResult
if err := json.Unmarshal(resp["result"], &result); err != nil {
t.Fatalf("unmarshal result: %v", err)
}
if result.Capabilities.TextDocumentSync != 1 {
t.Errorf("textDocumentSync = %d; want 1", result.Capabilities.TextDocumentSync)
}
if result.ServerInfo == nil || result.ServerInfo.Name != "glint" {
t.Errorf("serverInfo.name = %v; want glint", result.ServerInfo)
}
}
func TestServer_ShutdownExit(t *testing.T) {
var buf bytes.Buffer
buf.Write(frame(t, map[string]any{
"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": map[string]any{},
}))
buf.Write(frame(t, map[string]any{
"jsonrpc": "2.0", "method": "initialized", "params": map[string]any{},
}))
buf.Write(frame(t, map[string]any{
"jsonrpc": "2.0", "id": 2, "method": "shutdown",
}))
buf.Write(frame(t, map[string]any{
"jsonrpc": "2.0", "method": "exit",
}))
srv, out, exitCode := newTestServer(buf.Bytes())
srv.Run() //nolint:errcheck
r := bufio.NewReader(out)
initResp := readMsg(t, r)
if string(initResp["id"]) != "1" {
t.Errorf("init response id = %s; want 1", initResp["id"])
}
shutResp := readMsg(t, r)
if string(shutResp["id"]) != "2" {
t.Errorf("shutdown response id = %s; want 2", shutResp["id"])
}
if string(shutResp["result"]) != "null" {
t.Errorf("shutdown result = %s; want null", shutResp["result"])
}
if *exitCode != 0 {
t.Errorf("exit code = %d; want 0", *exitCode)
}
}
func TestServer_ExitWithoutShutdown(t *testing.T) {
input := frame(t, map[string]any{
"jsonrpc": "2.0", "method": "exit",
})
srv, _, exitCode := newTestServer(input)
srv.Run() //nolint:errcheck
if *exitCode != 1 {
t.Errorf("exit code = %d; want 1 (no prior shutdown)", *exitCode)
}
}
func TestServer_MethodNotFound(t *testing.T) {
input := frame(t, map[string]any{
"jsonrpc": "2.0", "id": 99, "method": "workspace/unknownMethod",
})
srv, out, _ := newTestServer(input)
srv.Run() //nolint:errcheck
resp := readMsg(t, bufio.NewReader(out))
if resp["error"] == nil {
t.Errorf("expected error response for unknown method, got: %v", resp)
}
var rpcErr RPCError
if err := json.Unmarshal(resp["error"], &rpcErr); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if rpcErr.Code != -32601 {
t.Errorf("error code = %d; want -32601", rpcErr.Code)
}
}
func TestServer_UnknownNotificationIgnored(t *testing.T) {
// Notifications (no id) for unknown methods must be silently ignored.
input := frame(t, map[string]any{
"jsonrpc": "2.0", "method": "$/setTrace", "params": map[string]any{"value": "off"},
})
srv, out, _ := newTestServer(input)
srv.Run() //nolint:errcheck
if out.Len() > 0 {
t.Errorf("server wrote %d bytes for unknown notification; want 0", out.Len())
}
}
func TestServer_DidOpen_CleanPipeline(t *testing.T) {
yaml := `stages: [build]
build-job:
stage: build
script: echo hello
`
// Use a pseudo file:// URI that maps to the tmp path; include resolution
// will fail silently (no network, no local includes) which is fine.
uri := "file:///tmp/test.gitlab-ci.yml"
input := frame(t, map[string]any{
"jsonrpc": "2.0",
"method": "textDocument/didOpen",
"params": map[string]any{
"textDocument": map[string]any{
"uri": uri, "languageId": "yaml", "version": 1, "text": yaml,
},
},
})
srv, out, _ := newTestServer(input)
srv.Run() //nolint:errcheck
notif := readMsg(t, bufio.NewReader(out))
if string(notif["method"]) != `"textDocument/publishDiagnostics"` {
t.Fatalf("method = %s; want textDocument/publishDiagnostics", notif["method"])
}
var params PublishDiagnosticsParams
if err := json.Unmarshal(notif["params"], &params); err != nil {
t.Fatalf("unmarshal params: %v", err)
}
if params.URI != uri {
t.Errorf("uri = %q; want %q", params.URI, uri)
}
// A clean pipeline should produce no diagnostics (or only warnings from
// include resolution being skipped — but those are filtered since they
// originate from a different file path).
for _, d := range params.Diagnostics {
if d.Severity == 1 {
t.Errorf("unexpected error diagnostic: %s", d.Message)
}
}
}
func TestServer_DidOpen_WithErrors(t *testing.T) {
// A pipeline with a job in an undeclared stage triggers GL004.
yaml := `stages: [build]
bad-job:
stage: missing-stage
script: echo hi
`
uri := "file:///tmp/bad.gitlab-ci.yml"
input := frame(t, map[string]any{
"jsonrpc": "2.0",
"method": "textDocument/didOpen",
"params": map[string]any{
"textDocument": map[string]any{
"uri": uri, "languageId": "yaml", "version": 1, "text": yaml,
},
},
})
srv, out, _ := newTestServer(input)
srv.Run() //nolint:errcheck
notif := readMsg(t, bufio.NewReader(out))
var params PublishDiagnosticsParams
if err := json.Unmarshal(notif["params"], &params); err != nil {
t.Fatalf("unmarshal params: %v", err)
}
if len(params.Diagnostics) == 0 {
t.Error("expected diagnostics for pipeline with unknown stage, got none")
}
found := false
for _, d := range params.Diagnostics {
if d.Code == "GL004" {
found = true
if d.Severity != 1 {
t.Errorf("GL004 severity = %d; want 1 (Error)", d.Severity)
}
}
}
if !found {
t.Errorf("expected GL004 diagnostic, got: %v", params.Diagnostics)
}
}
func TestServer_DidOpen_ParseError(t *testing.T) {
uri := "file:///tmp/broken.gitlab-ci.yml"
input := frame(t, map[string]any{
"jsonrpc": "2.0",
"method": "textDocument/didOpen",
"params": map[string]any{
"textDocument": map[string]any{
"uri": uri, "languageId": "yaml", "version": 1,
"text": "?", // bare ? yields empty job name → parse error
},
},
})
srv, out, _ := newTestServer(input)
srv.Run() //nolint:errcheck
notif := readMsg(t, bufio.NewReader(out))
var params PublishDiagnosticsParams
if err := json.Unmarshal(notif["params"], &params); err != nil {
t.Fatalf("unmarshal params: %v", err)
}
if len(params.Diagnostics) == 0 {
t.Fatal("expected parse-error diagnostic, got none")
}
d := params.Diagnostics[0]
if d.Severity != 1 {
t.Errorf("severity = %d; want 1 (Error)", d.Severity)
}
if !strings.Contains(d.Message, "YAML parse error") {
t.Errorf("message = %q; want YAML parse error", d.Message)
}
}
func TestServer_DidChange(t *testing.T) {
uri := "file:///tmp/ci.gitlab-ci.yml"
var buf bytes.Buffer
// Open with clean content.
buf.Write(frame(t, map[string]any{
"jsonrpc": "2.0", "method": "textDocument/didOpen",
"params": map[string]any{"textDocument": map[string]any{
"uri": uri, "languageId": "yaml", "version": 1,
"text": "stages: [build]\nbuild: {stage: build, script: echo}\n",
}},
}))
// Change to content with an error.
buf.Write(frame(t, map[string]any{
"jsonrpc": "2.0", "method": "textDocument/didChange",
"params": map[string]any{
"textDocument": map[string]any{"uri": uri, "version": 2},
"contentChanges": []map[string]any{
{"text": "stages: [build]\nbad: {stage: gone, script: hi}\n"},
},
},
}))
srv, out, _ := newTestServer(buf.Bytes())
srv.Run() //nolint:errcheck
r := bufio.NewReader(out)
_ = readMsg(t, r) // first publishDiagnostics (clean)
second := readMsg(t, r)
var params PublishDiagnosticsParams
if err := json.Unmarshal(second["params"], &params); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(params.Diagnostics) == 0 {
t.Error("expected diagnostics after change to broken content, got none")
}
}
func TestServer_DidClose_ClearsdiAgnostics(t *testing.T) {
uri := "file:///tmp/toclose.gitlab-ci.yml"
var buf bytes.Buffer
buf.Write(frame(t, map[string]any{
"jsonrpc": "2.0", "method": "textDocument/didOpen",
"params": map[string]any{"textDocument": map[string]any{
"uri": uri, "languageId": "yaml", "version": 1,
"text": "stages: [build]\nj: {stage: build, script: echo}\n",
}},
}))
buf.Write(frame(t, map[string]any{
"jsonrpc": "2.0", "method": "textDocument/didClose",
"params": map[string]any{"textDocument": map[string]any{"uri": uri}},
}))
srv, out, _ := newTestServer(buf.Bytes())
srv.Run() //nolint:errcheck
r := bufio.NewReader(out)
_ = readMsg(t, r) // publishDiagnostics from didOpen
closeNotif := readMsg(t, r)
var params PublishDiagnosticsParams
if err := json.Unmarshal(closeNotif["params"], &params); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if params.URI != uri {
t.Errorf("uri = %q; want %q", params.URI, uri)
}
if len(params.Diagnostics) != 0 {
t.Errorf("expected empty diagnostics on close, got %v", params.Diagnostics)
}
}
func TestServer_ContentLengthTooLarge(t *testing.T) {
// Craft a header with a content-length that exceeds the cap.
// The server must reject it before allocating a giant buffer.
header := fmt.Sprintf("Content-Length: %d\r\n\r\n", maxLSPMessageBytes+1)
srv, _, _ := newTestServer([]byte(header))
err := srv.Run()
if err == nil {
t.Fatal("expected error for oversized Content-Length, got nil")
}
if !strings.Contains(err.Error(), "exceeds maximum") {
t.Errorf("unexpected error: %v", err)
}
}
func TestServer_UriToPath(t *testing.T) {
tests := []struct {
uri string
want string
}{
{"file:///tmp/ci.yml", "/tmp/ci.yml"},
{"file:///home/user/project/.gitlab-ci.yml", "/home/user/project/.gitlab-ci.yml"},
{"https://example.com/file.yml", ""},
{"not-a-uri", ""},
}
for _, tc := range tests {
got := uriToPath(tc.uri)
if got != tc.want {
t.Errorf("uriToPath(%q) = %q; want %q", tc.uri, got, tc.want)
}
}
}
+112
View File
@@ -0,0 +1,112 @@
// Package lsp implements a minimal Language Server Protocol server for glint.
package lsp
import "encoding/json"
// Message is a JSON-RPC 2.0 message (request, response, or notification).
type Message struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id,omitempty"`
Method string `json:"method,omitempty"`
Params json.RawMessage `json:"params,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
Error *RPCError `json:"error,omitempty"`
}
// RPCError is a JSON-RPC 2.0 error object.
type RPCError struct {
Code int `json:"code"`
Message string `json:"message"`
}
// InitializeResult is the server's response to the initialize request.
type InitializeResult struct {
Capabilities ServerCapabilities `json:"capabilities"`
ServerInfo *ServerInfo `json:"serverInfo,omitempty"`
}
// ServerCapabilities advertises what the server supports.
type ServerCapabilities struct {
// TextDocumentSync: 1 = Full (send entire document on every change).
TextDocumentSync int `json:"textDocumentSync"`
}
// ServerInfo identifies the server to the client.
type ServerInfo struct {
Name string `json:"name"`
Version string `json:"version,omitempty"`
}
// TextDocumentItem is a text document opened by the client.
type TextDocumentItem struct {
URI string `json:"uri"`
LanguageID string `json:"languageId"`
Version int `json:"version"`
Text string `json:"text"`
}
// TextDocumentIdentifier references a text document by URI.
type TextDocumentIdentifier struct {
URI string `json:"uri"`
}
// VersionedTextDocumentIdentifier includes a version number.
type VersionedTextDocumentIdentifier struct {
URI string `json:"uri"`
Version int `json:"version"`
}
// TextDocumentContentChangeEvent is a single content change event.
// With Full sync the Text field contains the complete new document text.
type TextDocumentContentChangeEvent struct {
Text string `json:"text"`
}
// DidOpenTextDocumentParams is the params for textDocument/didOpen.
type DidOpenTextDocumentParams struct {
TextDocument TextDocumentItem `json:"textDocument"`
}
// DidChangeTextDocumentParams is the params for textDocument/didChange.
type DidChangeTextDocumentParams struct {
TextDocument VersionedTextDocumentIdentifier `json:"textDocument"`
ContentChanges []TextDocumentContentChangeEvent `json:"contentChanges"`
}
// DidSaveTextDocumentParams is the params for textDocument/didSave.
type DidSaveTextDocumentParams struct {
TextDocument TextDocumentIdentifier `json:"textDocument"`
Text *string `json:"text,omitempty"`
}
// DidCloseTextDocumentParams is the params for textDocument/didClose.
type DidCloseTextDocumentParams struct {
TextDocument TextDocumentIdentifier `json:"textDocument"`
}
// PublishDiagnosticsParams is the params for textDocument/publishDiagnostics.
type PublishDiagnosticsParams struct {
URI string `json:"uri"`
Diagnostics []Diagnostic `json:"diagnostics"`
}
// Diagnostic is a lint finding expressed in LSP terms.
type Diagnostic struct {
Range Range `json:"range"`
Severity int `json:"severity"` // 1=Error, 2=Warning, 3=Information, 4=Hint
Code string `json:"code,omitempty"`
Source string `json:"source,omitempty"`
Message string `json:"message"`
}
// Range is a zero-based line/character range within a text document.
type Range struct {
Start Position `json:"start"`
End Position `json:"end"`
}
// Position is a zero-based line and character offset.
type Position struct {
Line int `json:"line"`
Character int `json:"character"`
}
+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)
}
})
}
+4
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
} }
@@ -81,6 +84,7 @@ func ParseBytes(data []byte) (*Pipeline, error) {
} }
j.Name = key j.Name = key
j.Line = keyNode.Line // exact line of the job name key j.Line = keyNode.Line // exact line of the job name key
j.Column = keyNode.Column // exact column of the job name key
p.Jobs[key] = j p.Jobs[key] = j
} }
+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).
+1
View File
@@ -48,6 +48,7 @@ type Job struct {
Name string // set by parser, not from YAML Name string // set by parser, not from YAML
File string // source file; set by Parse / resolver File string // source file; set by Parse / resolver
Line int // line of the job key in its source file; set by parser Line int // line of the job key in its source file; set by parser
Column int // column of the job key (1-indexed); set by parser
Stage string `yaml:"stage"` Stage string `yaml:"stage"`
Script any `yaml:"script"` // []string or string (block scalar) Script any `yaml:"script"` // []string or string (block scalar)
Run any `yaml:"run"` // alternative to script (CI steps) Run any `yaml:"run"` // alternative to script (CI steps)
@@ -0,0 +1,2 @@
go test fuzz v1
[]byte("?")
+4 -1
View File
@@ -80,12 +80,15 @@ func Resolve(p *model.Pipeline) ([]ExtendWarning, error) {
return extWarnings, fmt.Errorf("job %q: re-decoding merged definition: %w", name, err) return extWarnings, fmt.Errorf("job %q: re-decoding merged definition: %w", name, err)
} }
j.Name = name j.Name = name
// Preserve source location — File/Line are not part of the YAML map // Preserve source location — these fields are not part of the YAML map
// and are lost during the encode/decode round-trip. // and are lost during the encode/decode round-trip.
orig := p.Jobs[name] orig := p.Jobs[name]
j.File = orig.File j.File = orig.File
j.Line = orig.Line j.Line = orig.Line
j.Column = orig.Column
p.Jobs[name] = j p.Jobs[name] = j
// Write merged raw map back so p.RawJobs always reflects post-extends state.
p.RawJobs[name] = merged
} }
return extWarnings, nil return extWarnings, nil
+6
View File
@@ -133,6 +133,12 @@ func resolveLocalInclude(p *model.Pipeline, rawPath string, cfg fetcher.GitLabCo
absPath := filepath.Join(rootDir, relPath) absPath := filepath.Join(rootDir, relPath)
label := "local " + rawPath label := "local " + rawPath
// Guard against path traversal: reject any path that escapes rootDir.
rel, err := filepath.Rel(rootDir, absPath)
if err != nil || strings.HasPrefix(rel, "..") {
return []IncludeWarning{{Label: label, Err: fmt.Errorf("path escapes repository root: %s", rawPath)}}, nil
}
if visited[absPath] { if visited[absPath] {
return nil, nil return nil, nil
} }
+27
View File
@@ -6,6 +6,7 @@ import (
"net/http/httptest" "net/http/httptest"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"git.k3nny.fr/glint/internal/fetcher" "git.k3nny.fr/glint/internal/fetcher"
@@ -332,6 +333,32 @@ func TestResolveLocalInclude_WithNestedIncludes(t *testing.T) {
} }
} }
func TestResolveLocalInclude_PathTraversal(t *testing.T) {
dir := t.TempDir()
p := &model.Pipeline{Jobs: map[string]model.Job{}, RawJobs: map[string]map[string]any{}}
// A path that escapes the repository root via "../.." must produce a warning,
// not silently read an arbitrary file.
warnings, _ := resolveLocalInclude(p, "../../etc/passwd", fetcher.GitLabConfig{}, dir, map[string]bool{}, 0)
if len(warnings) == 0 {
t.Fatal("expected warning for path traversal, got none")
}
if !strings.Contains(warnings[0].Err.Error(), "path escapes") {
t.Errorf("unexpected warning: %v", warnings[0])
}
if len(p.Jobs) != 0 {
t.Error("no jobs should be merged when path escapes root")
}
}
func TestResolveLocalInclude_PathTraversalWithLeadingSlash(t *testing.T) {
dir := t.TempDir()
p := &model.Pipeline{Jobs: map[string]model.Job{}, RawJobs: map[string]map[string]any{}}
warnings, _ := resolveLocalInclude(p, "/../../etc/shadow", fetcher.GitLabConfig{}, dir, map[string]bool{}, 0)
if len(warnings) == 0 {
t.Fatal("expected warning for path traversal via leading slash, got none")
}
}
// ── resolveRemoteInclude ────────────────────────────────────────────────────── // ── resolveRemoteInclude ──────────────────────────────────────────────────────
func TestResolveRemoteInclude_Success(t *testing.T) { func TestResolveRemoteInclude_Success(t *testing.T) {
+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 ]]
+8
View File
@@ -0,0 +1,8 @@
stages: [build]
include:
- remote: http://ci-templates.example.invalid/template.yml
build-job:
stage: build
script: echo hello