Compare commits

..

8 Commits

Author SHA1 Message Date
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
22 changed files with 1597 additions and 88 deletions
+66
View File
@@ -0,0 +1,66 @@
# .glint.yml — glint project configuration
#
# Place this file anywhere between your .gitlab-ci.yml and the repository root.
# glint searches upward from the pipeline file and stops at the first .git
# boundary, so the repo root is the typical location.
#
# All keys are optional. Omit or comment out anything you don't need.
# ── Rule suppression ──────────────────────────────────────────────────────────
#
# Suppress rules globally for this project. Suppressed rules produce no output
# and do not affect the exit code.
#
ignore:
- GL007 # only:/except: used (migrating from legacy syntax)
- GL032 # rules:if: references undeclared variable (injected at runtime)
# ── Severity overrides ────────────────────────────────────────────────────────
#
# Override the default severity of any rule.
# Valid values: error | warning | ignore
# "ignore" is equivalent to listing the rule in `ignore:` above.
#
severity:
GL004: warning # demote "unknown stage" to warning during a stage migration
GL035: error # promote absolute-path warning to a hard error
GL007: ignore # equivalent to adding GL007 to ignore:
# ── Extra stage names ─────────────────────────────────────────────────────────
#
# Declare stage names that are valid for this project beyond what is listed in
# the pipeline's own `stages:` block. Jobs referencing these stages will not
# be flagged by GL004. Useful when stages are defined in a shared parent
# template that glint cannot reach.
#
stages:
- quality
- security
- compliance
# ── GitLab token ──────────────────────────────────────────────────────────────
#
# Default personal access token (read_api scope) used to fetch `include:
# project:` templates. This is the lowest-priority token source; it is
# overridden by the --token flag and the GITLAB_TOKEN / CI_JOB_TOKEN /
# GITLAB_PRIVATE_TOKEN environment variables.
#
# Avoid committing real tokens — use environment variables instead.
#
# token: glpat-xxxxxxxxxxxxxxxxxxxx
# ── GitLab instance URL ───────────────────────────────────────────────────────
#
# Default GitLab instance URL, used when fetching project: and component:
# includes. Overridden by --gitlab-url, CI_SERVER_URL, and GITLAB_URL.
#
# url: https://gitlab.example.com
# ── Include cache directory ───────────────────────────────────────────────────
#
# Default directory for caching fetched remote includes (project: and
# component:). The directory is created on first use. Overridden by
# --cache-dir. When --offline is given without --cache-dir, glint defaults
# to ~/.cache/glint regardless of this setting.
#
# cache_dir: ~/.cache/glint
+11
View File
@@ -0,0 +1,11 @@
- id: glint
name: glint — validate GitLab CI pipeline
description: >-
Lint .gitlab-ci.yml with glint before committing. Catches misconfigured
stages, invalid keywords, broken needs: graphs, deprecated patterns, and
more — without a GitLab server.
entry: glint check
language: golang
files: '(^|/)\.gitlab-ci\.yml$'
pass_filenames: true
minimum_pre_commit_version: '3.0.0'
+24
View File
@@ -5,6 +5,30 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
This project uses [Semantic Versioning](https://semver.org). This project uses [Semantic Versioning](https://semver.org).
## [0.2.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
+59 -2
View File
@@ -6,7 +6,7 @@
<p align="center"> <p align="center">
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-blue.svg" alt="License"></a> <a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-blue.svg" alt="License"></a>
<a href="CHANGELOG.md"><img src="https://img.shields.io/badge/release-v0.2.26-blue.svg" alt="Release"></a> <a href="CHANGELOG.md"><img src="https://img.shields.io/badge/release-v0.2.29-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.
@@ -21,6 +21,7 @@ A local tool to validate and lint `.gitlab-ci.yml` pipelines without needing a G
- **Multiple output formats** — `--format text` (default, ruff-style), `json`, `sarif` (GitHub Code Scanning / GitLab SAST), `junit`, `github` (PR annotations) - **Multiple output formats** — `--format text` (default, ruff-style), `json`, `sarif` (GitHub Code Scanning / GitLab SAST), `junit`, `github` (PR annotations)
- **Project config** — `.glint.yml` for rule suppression, severity overrides, token/URL defaults; `# glint: ignore RULE` for per-job inline suppression - **Project config** — `.glint.yml` for rule suppression, severity overrides, token/URL defaults; `# glint: ignore RULE` for per-job inline suppression
- **Graph visualization** — `glint graph` prints a terminal job tree; `glint graph pipeline` renders a GitLab CI-style SVG/PNG; `--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; `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
- **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.
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.
@@ -32,7 +33,7 @@ See [FEATURES.md](FEATURES.md) for the complete feature reference and lint rules
## Installation ## Installation
```bash ```bash
git clone https://git.k3nny.fr/glint git clone https://git.k3nny.fr/k3nny/glint
cd glint cd glint
go build -o glint ./cmd/glint/... go build -o glint ./cmd/glint/...
``` ```
@@ -52,12 +53,61 @@ Commands:
check Lint a pipeline file — exits 0 (clean) or 1 (errors found) check Lint a pipeline file — exits 0 (clean) or 1 (errors found)
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 Print description and fix for a lint rule
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 [USAGE.md](USAGE.md) for full
examples covering output formats, context simulation, remote includes, cache, examples covering output formats, context simulation, remote includes, cache,
graph modes, and project configuration. graph modes, and project configuration.
## Integrations
### Pre-commit hook
Add to `.pre-commit-config.yaml` in your repository to run glint automatically whenever `.gitlab-ci.yml` changes:
```yaml
repos:
- repo: https://git.k3nny.fr/k3nny/glint
rev: v0.2.28
hooks:
- id: glint
```
Requires [pre-commit](https://pre-commit.com) and Go 1.21+. On first run, pre-commit builds glint from source automatically.
### GitLab CI component
Copy [`templates/check.yml`](templates/check.yml) into your repository and include it as a local file, or publish this repository to a GitLab CI/CD Catalog and reference it as a component:
```yaml
# As a local include (copy templates/check.yml to your repo first):
include:
- local: .gitlab/glint-check.yml
# As a Catalog component (after publishing to a GitLab instance):
include:
- component: $CI_SERVER_FQDN/k3nny/glint/check@v0.2.28
inputs:
stage: validate # optional, default: validate
allow_failure: true # optional, default: false
```
The component downloads the glint Linux binary, runs `glint check`, and respects all inputs defined in the `spec:` block.
### GitHub Actions
Copy [`action.yml`](action.yml) from this repository, or mirror this repo to GitHub as `k3nny/glint` and reference it directly:
```yaml
- uses: k3nny/glint@v0.2.28
with:
file: .gitlab-ci.yml # optional, default: .gitlab-ci.yml
args: '--format sarif' # optional
```
The action downloads the glint Linux binary into `$RUNNER_TEMP` and runs `glint check`. Only Linux runners are supported (matches the available release binary).
## Development ## Development
This project uses [Task](https://taskfile.dev) as a task runner. This project uses [Task](https://taskfile.dev) as a task runner.
@@ -69,11 +119,18 @@ task test # run Go unit tests
task lint-go # run go vet task lint-go # run go vet
task validate # run the binary against all testdata fixtures task validate # run the binary against all testdata fixtures
task ci # full check: vet → test → build → validate task ci # full check: vet → test → build → validate
task fuzz # run fuzz tests for the YAML parser (Ctrl-C to stop; FUZZ_TIME=60s to set duration)
task changelog # regenerate CHANGELOG.md from git history via git-cliff
task changelog-next # preview unreleased section (dry-run, no file written)
task build-windows # cross-compile for Windows x64 (requires a tagged commit → glint-<tag>.exe) task build-windows # cross-compile for Windows x64 (requires a tagged commit → glint-<tag>.exe)
task build-linux # cross-compile for Linux x64 (requires a tagged commit → glint-<tag>-linux-amd64) task build-linux # cross-compile for Linux x64 (requires a tagged commit → glint-<tag>-linux-amd64)
task clean # remove build artifacts task clean # remove build artifacts
``` ```
**Optional tools:**
- [git-cliff](https://git-cliff.org) — changelog generator used by `task changelog`. Install with `brew install git-cliff` or `cargo install git-cliff`.
## Project structure ## Project structure
``` ```
+68 -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,36 @@ 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
--- ---
## 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 +69,51 @@ 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
--- ---
## 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] **`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)
**Remaining improvements** - [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)
- ~~**`needs: optional: true` false-positive errors**~~ — ✓ shipped post-v0.2.0; optional missing needs are downgraded to `[WARNING]`
- ~~**`extends:` jobs with missing script false errors**~~ — ✓ shipped post-v0.2.0; jobs using `extends:` that have no `script` after resolution emit `[WARNING]` (the script may come from an unfetchable remote base)
- ~~**`rules:if:` static reachability (GL042)**~~ — ✓ shipped v0.2.20; warns when all `rules:if:` conditions evaluate to false given declared variable values (only fires when all referenced vars are declared in YAML)
--- ---
## CI / editor integration ## CI / editor integration
- **GitLab CI template** — a `.gitlab-ci.yml` snippet that runs `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` - [ ] **VS Code extension** — thin wrapper around the LSP server with syntax highlighting for `.gitlab-ci.yml`
--- ---
## 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
--- ---
## 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)
+17
View File
@@ -175,6 +175,23 @@ tasks:
generates: generates:
- "{{.BINARY}}-{{.TAG}}-linux-amd64" - "{{.BINARY}}-{{.TAG}}-linux-amd64"
fuzz:
desc: "Run all fuzz targets (set FUZZ_TIME=60s to control per-target duration, default 30s)"
cmds:
- "{{.GO}} test -fuzz=FuzzParseBytes -fuzztime=${FUZZ_TIME:-30s} ./internal/model/"
- "{{.GO}} test -fuzz=FuzzSanitizeYAMLEscapes -fuzztime=${FUZZ_TIME:-30s} ./internal/model/"
- "{{.GO}} test -fuzz=FuzzEvalIf -fuzztime=${FUZZ_TIME:-30s} ./internal/cicontext/"
- "{{.GO}} test -fuzz=FuzzExpandVarRefs -fuzztime=${FUZZ_TIME:-30s} ./internal/cicontext/"
- "{{.GO}} test -fuzz=FuzzLint -fuzztime=${FUZZ_TIME:-30s} ./internal/linter/"
changelog:
desc: "Regenerate CHANGELOG.md from git history (requires git-cliff — see README)"
cmd: git cliff --config cliff.toml --output CHANGELOG.md
changelog-next:
desc: "Preview unreleased changelog entries without writing (requires git-cliff)"
cmd: git cliff --config cliff.toml --unreleased
clean: clean:
desc: Remove build artifacts desc: Remove build artifacts
cmd: rm -f {{.BINARY}} {{.BINARY}}-*.exe {{.BINARY}}-*-linux-amd64 cmd: rm -f {{.BINARY}} {{.BINARY}}-*.exe {{.BINARY}}-*-linux-amd64
+62
View File
@@ -0,0 +1,62 @@
# GitHub Actions composite action — glint pipeline validator
#
# To use this action, mirror this repository to GitHub as k3nny/glint, then:
#
# - uses: k3nny/glint@v0.2.28
# with:
# file: .gitlab-ci.yml # optional
#
# Alternatively, copy this file into your own repository and reference it
# as a local action:
#
# - uses: ./.github/actions/glint
name: 'glint'
description: 'Validate a GitLab CI pipeline file with glint'
author: 'k3nny'
branding:
icon: 'check-circle'
color: 'orange'
inputs:
version:
description: >-
glint release tag to install (e.g. 'v0.2.28'). Defaults to 'latest'
which resolves to the newest published release.
required: false
default: 'latest'
file:
description: 'Path to the pipeline file to validate.'
required: false
default: '.gitlab-ci.yml'
args:
description: 'Additional arguments passed to glint check (e.g. --format sarif).'
required: false
default: ''
runs:
using: 'composite'
steps:
- name: Install glint
shell: bash
env:
GLINT_VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
if [ "$GLINT_VERSION" = "latest" ]; then
GLINT_VERSION=$(curl -sf \
"https://git.k3nny.fr/api/v1/repos/k3nny/glint/releases?limit=1" \
| grep '"tag_name"' | head -1 | cut -d'"' -f4)
fi
DEST="$RUNNER_TEMP/glint-bin"
mkdir -p "$DEST"
URL="https://git.k3nny.fr/k3nny/glint/releases/download/${GLINT_VERSION}/glint-${GLINT_VERSION}-linux-amd64"
curl -sfL "$URL" -o "$DEST/glint"
chmod +x "$DEST/glint"
echo "$DEST" >> "$GITHUB_PATH"
"$DEST/glint" --version
- name: Run glint check
shell: bash
run: glint check ${{ inputs.args }} "${{ inputs.file }}"
+65
View File
@@ -0,0 +1,65 @@
# git-cliff configuration for glint
# Install: brew install git-cliff OR cargo install git-cliff
# Usage: task changelog -- regenerate full CHANGELOG.md
# task changelog-next -- preview unreleased section (dry-run)
[changelog]
header = """
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
This project uses [Semantic Versioning](https://semver.org).
"""
body = """
{% if version %}\
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
{% else %}\
## [Unreleased]
{% endif %}\
{% for group, commits in commits | group_by(attribute="group") %}\
### {{ group | upper_first }}
{% for commit in commits %}\
- {% if commit.scope %}**{{ commit.scope }}**: {% endif %}\
{{ commit.message | upper_first }}\
{% if commit.breaking %} **[BREAKING]**{% endif %}
{% endfor %}\
{% endfor %}\
"""
trim = true
footer = ""
postprocessors = []
[git]
conventional_commits = true
filter_unconventional = true
split_commits = false
commit_preprocessors = [
# Drop Co-Authored-By trailers (should not appear in subjects, but guard anyway).
{ pattern = "Co-Authored-By:.*", replace = "" },
]
commit_parsers = [
# Breaking changes (type! or scope!) — promote above everything else.
{ message = "^[a-z]+\\([a-z-]+\\)!:|^[a-z]+!:", group = "Breaking Changes" },
{ message = "^feat", group = "Added" },
{ message = "^fix", group = "Fixed" },
{ message = "^perf", group = "Changed" },
{ message = "^refactor", group = "Changed" },
# Maintenance commits — omit from the changelog body.
{ message = "^docs", skip = true },
{ message = "^style", skip = true },
{ message = "^test", skip = true },
{ message = "^chore", skip = true },
{ message = "^build", skip = true },
{ message = "^claude", skip = true },
]
protect_breaking_commits = false
filter_commits = true
tag_pattern = "v[0-9].*"
topo_order = false
sort_commits = "oldest"
+71
View File
@@ -0,0 +1,71 @@
package main
import (
"flag"
"fmt"
"os"
"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")
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.
-h, --help
Print help
Examples:
glint lsp
glint lsp --token glpat-xxxx --cache-dir ~/.cache/glint
glint lsp --offline
`)
}
_ = fs.Parse(args)
resolvedCacheDir := *cacheDir
if resolvedCacheDir == "" {
resolvedCacheDir = defaultCacheDir()
}
if *offline && resolvedCacheDir == "" {
resolvedCacheDir = defaultCacheDir()
}
cfg := fetcher.AutoConfig().WithOverrides(*gitlabURL, *token, resolvedCacheDir, *offline)
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)
}
}
+3
View File
@@ -69,6 +69,7 @@ Commands:
check Lint a pipeline file — exits 0 (clean) or 1 (errors found) check Lint a pipeline file — exits 0 (clean) or 1 (errors found)
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
@@ -90,6 +91,8 @@ func main() {
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)
+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=
+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)
})
}
+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)
})
}
+332
View File
@@ -0,0 +1,332 @@
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
}
}
}
// 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")
}
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)
}
+383
View File
@@ -0,0 +1,383 @@
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_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)
}
})
}
+3
View File
@@ -57,6 +57,9 @@ func ParseBytes(data []byte) (*Pipeline, error) {
keyNode := root.Content[i] keyNode := root.Content[i]
valNode := root.Content[i+1] valNode := root.Content[i+1]
key := keyNode.Value key := keyNode.Value
if key == "" {
return nil, fmt.Errorf("job name cannot be empty (null or missing YAML key)")
}
if ReservedKeys[key] { if ReservedKeys[key] {
continue continue
} }
+6
View File
@@ -88,6 +88,12 @@ func TestParseBytes_EdgeCases(t *testing.T) {
if err == nil { if err == nil {
t.Error("wrong field type: expected error from ParseBytes (stage must be string)") t.Error("wrong field type: expected error from ParseBytes (stage must be string)")
} }
// Null/empty YAML key (e.g. bare "?"): job name cannot be empty.
_, err = ParseBytes([]byte("?"))
if err == nil {
t.Error("null key: expected error from ParseBytes (job name cannot be empty)")
}
} }
// TestParse_ParseBytesError exercises the Parse → ParseBytes error path (line 18). // TestParse_ParseBytesError exercises the Parse → ParseBytes error path (line 18).
@@ -0,0 +1,2 @@
go test fuzz v1
[]byte("?")
+62
View File
@@ -0,0 +1,62 @@
# glint — GitLab CI/CD Catalog component
#
# Validates a pipeline file with glint before the rest of the pipeline runs.
#
# Usage (after publishing to a GitLab instance as a Catalog component):
#
# include:
# - component: $CI_SERVER_FQDN/k3nny/glint/check@v0.2.28
# inputs:
# stage: validate # optional — see inputs below
#
# Or as a plain remote include (no Catalog required):
#
# include:
# - remote: https://raw.githubusercontent.com/.../templates/check.yml
#
# Or copy this file into your repository and use a local include.
spec:
inputs:
stage:
description: "Stage in which to run the glint validation job."
default: validate
pipeline_file:
description: "Path to the pipeline file to validate."
default: .gitlab-ci.yml
version:
description: >-
glint release tag to download (e.g. 'v0.2.28'). Use 'latest' to
always pull the newest release — not recommended for production
pipelines since it may break on a new release.
default: latest
allow_failure:
description: "Set to true to let the job fail without blocking the pipeline."
default: false
extra_args:
description: "Additional arguments passed to 'glint check' (e.g. '--format sarif')."
default: ""
---
glint:check:
stage: $[[ inputs.stage ]]
image: alpine:3.19
variables:
GLINT_VERSION: "$[[ inputs.version ]]"
GLINT_FILE: "$[[ inputs.pipeline_file ]]"
GLINT_ARGS: "$[[ inputs.extra_args ]]"
before_script:
- apk add --no-cache curl
- |
if [ "$GLINT_VERSION" = "latest" ]; then
GLINT_VERSION=$(curl -sf \
"https://git.k3nny.fr/api/v1/repos/k3nny/glint/releases?limit=1" \
| grep '"tag_name"' | head -1 | cut -d'"' -f4)
fi
URL="https://git.k3nny.fr/k3nny/glint/releases/download/${GLINT_VERSION}/glint-${GLINT_VERSION}-linux-amd64"
curl -sfL "$URL" -o /usr/local/bin/glint
chmod +x /usr/local/bin/glint
glint --version
script:
- glint check $GLINT_ARGS "$GLINT_FILE"
allow_failure: $[[ inputs.allow_failure ]]