From 46a1cf3c083fd2fa03e30d9d9a0d5f50d2d68ad1 Mon Sep 17 00:00:00 2001 From: k3nny Date: Thu, 11 Jun 2026 23:56:09 +0200 Subject: [PATCH] feat: add go lint --- .gitea/workflows/ci.yml | 40 +++++++++++++++++++ CHANGELOG.md | 2 + Taskfile.yml | 15 +++++-- go.mod | 12 +++++- go.sum | 13 ++++++ internal/graph/includes.go | 4 +- internal/linter/keywords.go | 6 +-- internal/linter/samba_test.go | 8 ++-- internal/resolver/includes.go | 17 +++++++- .../samba}/.gitlab-ci-coverage-runners.yml | 0 .../samba}/.gitlab-ci-coverage.yml | 0 .../samba}/.gitlab-ci-default-runners.yml | 0 .../samba}/.gitlab-ci-default.yml | 0 .../samba}/.gitlab-ci-main.yml | 0 .../samba}/.gitlab-ci-private.yml | 0 .../samba}/.gitlab-ci.yml | 0 .../samba}/.gitleaks.toml | 0 .../samba}/bootstrap/.gitlab-ci.yml | 0 testdata/variable_refs_included.yml | 25 ++++++++++++ testdata/variable_refs_included_template.yml | 6 +++ 20 files changed, 131 insertions(+), 17 deletions(-) create mode 100644 .gitea/workflows/ci.yml rename {samba-testdata => testdata/samba}/.gitlab-ci-coverage-runners.yml (100%) rename {samba-testdata => testdata/samba}/.gitlab-ci-coverage.yml (100%) rename {samba-testdata => testdata/samba}/.gitlab-ci-default-runners.yml (100%) rename {samba-testdata => testdata/samba}/.gitlab-ci-default.yml (100%) rename {samba-testdata => testdata/samba}/.gitlab-ci-main.yml (100%) rename {samba-testdata => testdata/samba}/.gitlab-ci-private.yml (100%) rename {samba-testdata => testdata/samba}/.gitlab-ci.yml (100%) rename {samba-testdata => testdata/samba}/.gitleaks.toml (100%) rename {samba-testdata => testdata/samba}/bootstrap/.gitlab-ci.yml (100%) create mode 100644 testdata/variable_refs_included.yml create mode 100644 testdata/variable_refs_included_template.yml diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..f6e2b3d --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,40 @@ +name: ci + +on: + push: + branches: + - '**' + pull_request: + +jobs: + ci: + name: vet, staticcheck, test, build + runs-on: ubuntu-latest + container: + image: golang:1.26-alpine + + steps: + - name: Install tools + run: apk add --no-cache git + + - name: Checkout + env: + TOKEN: ${{ secrets.GITHUB_TOKEN }} + SERVER_URL: ${{ github.server_url }} + REPO: ${{ github.repository }} + REF: ${{ github.ref_name }} + run: | + git clone --depth 1 --branch "$REF" \ + "$(echo "$SERVER_URL" | sed "s|https://|https://oauth2:${TOKEN}@|")/${REPO}.git" . + + - name: vet + run: go vet ./... + + - name: staticcheck + run: go tool staticcheck ./... + + - name: test + run: go test ./... + + - name: build + run: go build ./cmd/glint/... diff --git a/CHANGELOG.md b/CHANGELOG.md index d2f2fc5..4e6a1ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ This project uses [Semantic Versioning](https://semver.org). - **Variable reference validation (GL032)** — glint now warns when a `rules:if:` expression references a variable (`$VAR` or `${VAR}`) that is not declared anywhere in the pipeline YAML: pipeline-level `variables:`, the job's own `variables:`, or any `workflow:rules:variables:` block. Predefined GitLab CI variable namespaces (`CI_*`, `GITLAB_*`, `FF_*`, `RUNNER_*`, `TRIGGER_*`, `CHAT_*`) are exempt. Because variables can also be set in GitLab CI/CD project settings (invisible to glint), the finding is a `[WARNING]` rather than an error. Each undeclared variable is reported at most once per job to keep the output concise. +- **Included-file variables now visible to all lint rules** — `variables:` blocks declared in included files (local, remote, project, and component includes) are now merged into the pipeline's variable namespace before linting. This eliminates false-positive GL032 warnings for variables declared in shared CI templates. Root-pipeline variables take precedence over included-file variables when the same key appears in both (matching GitLab's own override behaviour). + - **Structured rule IDs** — every finding now carries a stable `GL###` identifier (e.g. `GL003`) that appears in the output between the location and the message: `[ERROR] job "deploy" (file.yml:14) GL003: missing required field 'script'`. IDs are assigned per check function across 31 rules (GL001–GL031) and are stable across versions. The `linter.Finding` struct exposes the ID as a `Rule string` field for programmatic consumers. The README lint rules table is updated with ID columns. - **`workflow:rules:variables:` now propagate to job rule evaluation** — when a `workflow:rules:` entry matches, any `variables:` it defines are injected into the evaluation context so job `rules:if:` expressions can reference them. Pipeline-level `variables:` are also available as defaults (lower priority). Variable priority order, highest first: `--var` CLI overrides → `--branch`/`--tag`/`--source` shortcuts → workflow-rule variables → pipeline-level variable defaults. This means `$DEPLOY_TARGET == "production"` in a job rule correctly evaluates when a workflow rule sets `DEPLOY_TARGET: production` for the matching branch. The `glint graph tree` command benefits from the same enrichment. diff --git a/Taskfile.yml b/Taskfile.yml index 66975e4..1a9f32f 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -75,21 +75,28 @@ tasks: ignore_error: false - cmd: ./{{.BINARY}} check testdata/variable_refs.yml ignore_error: false - - cmd: ./{{.BINARY}} check samba-testdata/.gitlab-ci.yml + - cmd: ./{{.BINARY}} check testdata/variable_refs_included.yml ignore_error: false - - cmd: ./{{.BINARY}} check samba-testdata/.gitlab-ci-coverage.yml + - cmd: ./{{.BINARY}} check testdata/samba/.gitlab-ci.yml ignore_error: false - - cmd: ./{{.BINARY}} check samba-testdata/.gitlab-ci-private.yml + - cmd: ./{{.BINARY}} check testdata/samba/.gitlab-ci-coverage.yml + ignore_error: false + - cmd: ./{{.BINARY}} check testdata/samba/.gitlab-ci-private.yml ignore_error: false lint-go: desc: Run go vet on all packages cmd: "{{.GO}} vet ./..." + lint-static: + desc: Run staticcheck on all packages + cmd: "{{.GO}} tool staticcheck ./..." + ci: - desc: Full CI check — vet, test, build, validate + desc: Full CI check — vet, staticcheck, test, build, validate cmds: - task: lint-go + - task: lint-static - task: test - task: build - task: validate diff --git a/go.mod b/go.mod index 1ed4a2d..ce69ff3 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,14 @@ module git.k3nny.fr/glint go 1.26.4 -require gopkg.in/yaml.v3 v3.0.1 // indirect +require ( + github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c // indirect + golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 // indirect + golang.org/x/mod v0.31.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/tools v0.40.1-0.20260108161641-ca281cf95054 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + honnef.co/go/tools v0.7.0 // indirect +) + +tool honnef.co/go/tools/cmd/staticcheck diff --git a/go.sum b/go.sum index 4bc0337..2c9dd9d 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,16 @@ +github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs= +github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ= +golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 h1:1P7xPZEwZMoBoz0Yze5Nx2/4pxj6nw9ZqHWXqP0iRgQ= +golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/tools v0.40.1-0.20260108161641-ca281cf95054 h1:CHVDrNHx9ZoOrNN9kKWYIbT5Rj+WF2rlwPkhbQQ5V4U= +golang.org/x/tools v0.40.1-0.20260108161641-ca281cf95054/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU= +honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc= diff --git a/internal/graph/includes.go b/internal/graph/includes.go index 5c3e861..41c8e36 100644 --- a/internal/graph/includes.go +++ b/internal/graph/includes.go @@ -55,9 +55,7 @@ func (b *treeBuilder) nextID() string { func (b *treeBuilder) buildChildren(parent *treeNode, rawIncludes []any) { for _, entry := range rawIncludes { - for _, child := range b.parseEntry(entry) { - parent.children = append(parent.children, child) - } + parent.children = append(parent.children, b.parseEntry(entry)...) } } diff --git a/internal/linter/keywords.go b/internal/linter/keywords.go index d628d09..4db14dd 100644 --- a/internal/linter/keywords.go +++ b/internal/linter/keywords.go @@ -352,7 +352,7 @@ func checkEnvironment(name string, job model.Job) []Finding { return nil } var findings []Finding - envName, _ := m["name"] + envName := m["name"] _, hasURL := m["url"] if (envName == nil || envName == "") && hasURL { findings = append(findings, Finding{ @@ -391,7 +391,7 @@ func checkArtifacts(name string, job model.Job) []Finding { }) } if _, hasExposeAs := m["expose_as"]; hasExposeAs { - paths, _ := m["paths"] + paths := m["paths"] if paths == nil { findings = append(findings, Finding{ Severity: Error, @@ -484,7 +484,7 @@ func checkImage(name string, job model.Job) []Finding { if !ok { return nil // String form is valid. } - imgName, _ := m["name"] + imgName := m["name"] if imgName == nil || imgName == "" { return []Finding{{ Severity: Error, diff --git a/internal/linter/samba_test.go b/internal/linter/samba_test.go index 364de74..ec974fe 100644 --- a/internal/linter/samba_test.go +++ b/internal/linter/samba_test.go @@ -14,7 +14,7 @@ import ( // pipeline that is valid on GitLab) produces no Error findings. // These files exercise local include resolution and multi-level extends chains. func TestSambaCI(t *testing.T) { - entryPoint := "../../samba-testdata/.gitlab-ci.yml" + entryPoint := "../../testdata/samba/.gitlab-ci.yml" p, err := model.Parse(entryPoint) if err != nil { @@ -51,9 +51,9 @@ func TestSambaCIEntryFiles(t *testing.T) { name string path string }{ - {"default", "../../samba-testdata/.gitlab-ci.yml"}, - {"coverage", "../../samba-testdata/.gitlab-ci-coverage.yml"}, - {"private", "../../samba-testdata/.gitlab-ci-private.yml"}, + {"default", "../../testdata/samba/.gitlab-ci.yml"}, + {"coverage", "../../testdata/samba/.gitlab-ci-coverage.yml"}, + {"private", "../../testdata/samba/.gitlab-ci-private.yml"}, } for _, tc := range entryPoints { diff --git a/internal/resolver/includes.go b/internal/resolver/includes.go index 87130c0..b3510b7 100644 --- a/internal/resolver/includes.go +++ b/internal/resolver/includes.go @@ -326,8 +326,9 @@ func includeFiles(entry map[string]any) []string { return nil } -// mergeIncluded copies jobs and stages from src into dst. -// dst (the main pipeline) always wins when a key already exists. +// mergeIncluded copies jobs, stages, and variables from src into dst. +// dst (the main pipeline) always wins when a key already exists — this matches +// GitLab's precedence rule where root-pipeline values override included templates. func mergeIncluded(dst, src *model.Pipeline) { stageSet := make(map[string]bool, len(dst.Stages)) for _, s := range dst.Stages { @@ -348,4 +349,16 @@ func mergeIncluded(dst, src *model.Pipeline) { } } } + + // Merge pipeline-level variables: dst wins on conflict (root overrides includes). + if len(src.Variables) > 0 { + if dst.Variables == nil { + dst.Variables = make(map[string]any, len(src.Variables)) + } + for k, v := range src.Variables { + if _, exists := dst.Variables[k]; !exists { + dst.Variables[k] = v + } + } + } } diff --git a/samba-testdata/.gitlab-ci-coverage-runners.yml b/testdata/samba/.gitlab-ci-coverage-runners.yml similarity index 100% rename from samba-testdata/.gitlab-ci-coverage-runners.yml rename to testdata/samba/.gitlab-ci-coverage-runners.yml diff --git a/samba-testdata/.gitlab-ci-coverage.yml b/testdata/samba/.gitlab-ci-coverage.yml similarity index 100% rename from samba-testdata/.gitlab-ci-coverage.yml rename to testdata/samba/.gitlab-ci-coverage.yml diff --git a/samba-testdata/.gitlab-ci-default-runners.yml b/testdata/samba/.gitlab-ci-default-runners.yml similarity index 100% rename from samba-testdata/.gitlab-ci-default-runners.yml rename to testdata/samba/.gitlab-ci-default-runners.yml diff --git a/samba-testdata/.gitlab-ci-default.yml b/testdata/samba/.gitlab-ci-default.yml similarity index 100% rename from samba-testdata/.gitlab-ci-default.yml rename to testdata/samba/.gitlab-ci-default.yml diff --git a/samba-testdata/.gitlab-ci-main.yml b/testdata/samba/.gitlab-ci-main.yml similarity index 100% rename from samba-testdata/.gitlab-ci-main.yml rename to testdata/samba/.gitlab-ci-main.yml diff --git a/samba-testdata/.gitlab-ci-private.yml b/testdata/samba/.gitlab-ci-private.yml similarity index 100% rename from samba-testdata/.gitlab-ci-private.yml rename to testdata/samba/.gitlab-ci-private.yml diff --git a/samba-testdata/.gitlab-ci.yml b/testdata/samba/.gitlab-ci.yml similarity index 100% rename from samba-testdata/.gitlab-ci.yml rename to testdata/samba/.gitlab-ci.yml diff --git a/samba-testdata/.gitleaks.toml b/testdata/samba/.gitleaks.toml similarity index 100% rename from samba-testdata/.gitleaks.toml rename to testdata/samba/.gitleaks.toml diff --git a/samba-testdata/bootstrap/.gitlab-ci.yml b/testdata/samba/bootstrap/.gitlab-ci.yml similarity index 100% rename from samba-testdata/bootstrap/.gitlab-ci.yml rename to testdata/samba/bootstrap/.gitlab-ci.yml diff --git a/testdata/variable_refs_included.yml b/testdata/variable_refs_included.yml new file mode 100644 index 0000000..736d59c --- /dev/null +++ b/testdata/variable_refs_included.yml @@ -0,0 +1,25 @@ +--- +# variable_refs_included.yml +# GL032 must NOT fire for variables declared in an included file. +# The included file (variable_refs_included_template.yml) declares TEMPLATE_VAR. +# Expected: exits 0 (no errors; no GL032 warnings). + +stages: + - build + +include: + - local: /variable_refs_included_template.yml + +build: + stage: build + script: make build + rules: + # TEMPLATE_VAR comes from the included file — GL032 must not fire. + - if: '$TEMPLATE_VAR == "enabled"' + when: on_success + # ROOT_VAR is declared in this file — GL032 must not fire. + - if: '$ROOT_VAR == "yes"' + when: manual + +variables: + ROOT_VAR: "yes" diff --git a/testdata/variable_refs_included_template.yml b/testdata/variable_refs_included_template.yml new file mode 100644 index 0000000..93be3ca --- /dev/null +++ b/testdata/variable_refs_included_template.yml @@ -0,0 +1,6 @@ +--- +# Included by variable_refs_included.yml — declares a pipeline-level variable +# that the parent pipeline's jobs reference in rules:if: expressions. + +variables: + TEMPLATE_VAR: "enabled"