From e2334ec12daf2bd615b041baeb777e657a79ed36 Mon Sep 17 00:00:00 2001 From: k3nny Date: Fri, 5 Jun 2026 01:29:07 +0200 Subject: [PATCH] feat(gitlab-sim): :sparkles: first commit --- .gitignore | 35 +++ CHANGELOG.md | 48 +++ CLAUDE.md | 47 +++ README.md | 144 +++++++++ Taskfile.yml | 59 ++++ go.mod | 5 + go.sum | 3 + internal/linter/dependencies.go | 44 +++ internal/linter/keywords.go | 498 ++++++++++++++++++++++++++++++++ internal/linter/linter.go | 139 +++++++++ internal/linter/needs.go | 123 ++++++++ internal/model/parser.go | 50 ++++ internal/model/pipeline.go | 83 ++++++ internal/resolver/extends.go | 160 ++++++++++ testdata/extends.yml | 28 ++ testdata/invalid.yml | 23 ++ testdata/keywords_invalid.yml | 128 ++++++++ testdata/keywords_valid.yml | 79 +++++ testdata/needs.yml | 40 +++ testdata/needs_cycle.yml | 16 + testdata/valid.yml | 26 ++ 21 files changed, 1778 insertions(+) create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 Taskfile.yml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/linter/dependencies.go create mode 100644 internal/linter/keywords.go create mode 100644 internal/linter/linter.go create mode 100644 internal/linter/needs.go create mode 100644 internal/model/parser.go create mode 100644 internal/model/pipeline.go create mode 100644 internal/resolver/extends.go create mode 100644 testdata/extends.yml create mode 100644 testdata/invalid.yml create mode 100644 testdata/keywords_invalid.yml create mode 100644 testdata/keywords_valid.yml create mode 100644 testdata/needs.yml create mode 100644 testdata/needs_cycle.yml create mode 100644 testdata/valid.yml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0b950ca --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# Compiled binaries +gitlab-sim +gitlab-sim.exe +dist/ +bin/ + +# Go test & coverage artifacts +*.test +*.out +coverage.html +coverage.txt + +# Task runner cache +.task/ + +# Claude Code project memory +.claude/ + +# Editor — JetBrains +.idea/ + +# Editor — VS Code (keep settings/extensions if you want to share them, +# but ignore personal/machine-specific state) +.vscode/settings.json +.vscode/launch.json +.vscode/*.code-workspace + +# Editor — Vim / Neovim +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0ed6547 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,48 @@ +# 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 does not yet use semantic versioning; entries are listed under `[Unreleased]` until a first release is tagged. + +## [Unreleased] + +### Added + +- **Comprehensive keyword validation** — checks for all major GitLab CI YAML keywords based on the official docs: + - `when` valid values: `on_success`, `on_failure`, `always`, `manual`, `delayed`, `never` + - `start_in` only allowed when `when: delayed`; error if set without it + - `parallel` must be integer 2–200 or map with `matrix` key + - `retry` max value 0–2; `retry.when` failure types validated against the full enum + - `allow_failure` must be boolean or `{exit_codes: ...}` + - `interruptible` must be boolean + - `trigger` jobs cannot have `script`; map form requires `project` or `include` + - `coverage` must be a regex pattern wrapped in `/…/` + - `release` requires `tag_name` + - `environment.url` requires `environment.name`; `environment.action` validated + - `artifacts.when` valid values; `expose_as` requires `paths` + - `cache.when` and `cache.policy` valid values + - `rules[*].when` validated per-rule + - `image` map form requires `name` + - `inherit.default` / `inherit.variables` must be boolean or list + - `workflow.rules[*].when` restricted to `always` / `never` + - Warning when `pages` job `artifacts.paths` does not include `public` +- **`dependencies:` validation** — referenced jobs must exist and must be in an earlier stage +- **`run:` keyword support** — recognised as alternative to `script:` (CI steps); no longer triggers "missing script" error +- **`spec:` reserved key** — top-level `spec:` is now recognised as a CI component header, not a job +- **New job model fields** — `interruptible`, `resource_group`, `start_in`, `run` +- **Testdata fixtures** — `keywords_valid.yml` (clean pipeline exercising every new check), `keywords_invalid.yml` (18 deliberate violations) + +- **`extends:` resolution** — resolves single and chained template inheritance before linting; deep-merges base job fields into derived jobs (child scalars/lists win, maps are merged recursively); cycle detection via topological sort +- **`needs:` DAG validation** — checks referenced jobs exist, respect stage ordering, and contain no circular dependencies; handles both the `- job-name` shorthand and the `- job: name` map form; cross-pipeline needs (`pipeline:` key) are skipped +- **Hidden job support** — jobs named with a leading `.` are treated as reusable templates and exempted from the `script` requirement and other per-job checks +- **Core linter** — initial set of lint rules: + - Missing `script` on non-trigger, non-template jobs (error) + - Job `stage` not declared in `stages` (error) + - `only`/`rules` or `except`/`rules` used together (error) + - No `stages` block defined (warning) + - Deprecated `only`/`except` usage (warning) +- **CLI** — `gitlab-sim ` exits 0 on clean pipelines, 1 on errors; prints findings with severity, job name, and message +- **YAML parser** — two-pass parse: reserved top-level keys (`stages`, `variables`, `default`, `include`, `workflow`) are decoded into typed structs; remaining keys are treated as job definitions +- **Taskfile** — `build`, `test`, `lint-go`, `validate`, `ci`, `clean` tasks via [Task](https://taskfile.dev) +- **Testdata fixtures** — `valid.yml`, `invalid.yml`, `extends.yml`, `needs.yml`, `needs_cycle.yml` diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b747c15 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,47 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +gitlab-sim is a tool aiming to simulate and lint gitlab-ci pipelines locally and produce a graph. + +## Commit Message Guidelines + +**IMPORTANT: This project uses [Conventional Commits](https://www.conventionalcommits.org/) format.** + +All commit messages must follow this format: +``` +(): + +[optional body] + +[optional footer(s)] +``` + +**Types:** +- `feat`: A new feature +- `fix`: A bug fix +- `docs`: Documentation only changes +- `refactor`: Code change that neither fixes a bug nor adds a feature +- `test`: Adding missing tests or correcting existing tests +- `chore`: Changes to build process or auxiliary tools +- `perf`: Performance improvements +- `style`: Code style changes (formatting, missing semicolons, etc.) + +**Scopes (commonly used):** +- `auth`: Authentication/authorization changes +- `security`: Security-related changes +- `gui`: Web GUI changes +- `api`: API changes +- `readme`: README.md changes +- `claude`: CLAUDE.md changes +- `core`: Core library changes + +**Breaking Changes:** +Add `!` after type/scope for breaking changes: +- `feat(api)!: remove deprecated endpoint` + +**Note:** Always include a scope in parentheses, even for documentation changes. + +When Claude Code creates commits, it will automatically follow this format. diff --git a/README.md b/README.md new file mode 100644 index 0000000..65dd449 --- /dev/null +++ b/README.md @@ -0,0 +1,144 @@ +# gitlab-sim + +A local tool to validate and lint `.gitlab-ci.yml` pipelines without needing a GitLab server. + +## Features + +- **YAML validation** — detects malformed pipeline files early +- **Stage validation** — every job's `stage` must be declared in `stages` +- **`extends:` resolution** — resolves single and multi-level template inheritance before linting, so derived jobs are evaluated against their fully merged definition +- **`needs:` DAG validation** — checks that `needs:` references exist, respect stage ordering, and contain no circular dependencies +- **Deprecation warnings** — flags `only`/`except` usage in favour of `rules` + +## Requirements + +- Go 1.21 or later +- [Task](https://taskfile.dev) (optional, for development tasks) + +## Installation + +```bash +git clone https://git.k3nny.fr/gitlab-sim +cd gitlab-sim +go build -o gitlab-sim ./cmd/gitlab-sim/... +``` + +Or with Task: + +```bash +task build +``` + +## Usage + +```bash +gitlab-sim +``` + +Exits `0` when no errors are found, `1` when at least one error is reported. + +### Example output + +``` +# Clean pipeline +OK: .gitlab-ci.yml — no issues found (5 jobs, 3 stages) + +# Pipeline with issues +[ERROR] job "deploy": stage "production" is not defined in 'stages' +[ERROR] job "test": needs unknown job "build-app" +[WARNING] job "old-job": 'only'/'except' are deprecated; prefer 'rules' + +3 finding(s): 2 error(s) +``` + +## Lint rules + +### Pipeline-level + +| Severity | Rule | +|----------|------| +| ERROR | `workflow.rules[*].when` is not `always` or `never` | +| WARNING | No `stages` defined (GitLab falls back to default stages) | + +### Job-level — structure + +| Severity | Rule | +|----------|------| +| ERROR | Job is missing required `script` (or `run`) — non-trigger, non-template jobs | +| ERROR | Job references a `stage` not declared in `stages` | +| ERROR | `only` and `rules` used together on the same job | +| ERROR | `except` and `rules` used together on the same job | +| WARNING | `only`/`except` used (deprecated, prefer `rules`) | + +### Job-level — keyword constraints + +| Severity | Rule | +|----------|------| +| ERROR | `when` is not one of `on_success`, `on_failure`, `always`, `manual`, `delayed`, `never` | +| ERROR | `when: delayed` without `start_in` | +| ERROR | `start_in` set but `when` is not `delayed` | +| ERROR | `parallel` integer not in range 2–200 | +| ERROR | `parallel` map form missing `matrix` key | +| ERROR | `retry` integer not in range 0–2 | +| ERROR | `retry.max` not in range 0–2 | +| ERROR | `retry.when` contains an invalid failure type | +| ERROR | `allow_failure` is not a boolean or a map with `exit_codes` | +| ERROR | `interruptible` is not a boolean | +| ERROR | `trigger` job also has `script` | +| ERROR | `trigger` map missing `project` or `include` | +| ERROR | `coverage` is not a regex pattern wrapped in `/` | +| ERROR | `release` missing required `tag_name` | +| ERROR | `environment.url` set without `environment.name` | +| ERROR | `environment.action` is not one of `start`, `stop`, `prepare`, `verify`, `access` | +| ERROR | `artifacts.when` is not `on_success`, `on_failure`, or `always` | +| ERROR | `artifacts.expose_as` set without `artifacts.paths` | +| ERROR | `cache.when` is not `on_success`, `on_failure`, or `always` | +| ERROR | `cache.policy` is not `pull`, `push`, or `pull-push` | +| ERROR | `rules[*].when` is not one of the valid `when` values | +| ERROR | `image` map form missing `name` key | +| ERROR | `inherit.default` / `inherit.variables` is not a boolean or list | +| WARNING | `pages` job `artifacts.paths` does not include `public` | + +### Cross-job graph + +| Severity | Rule | +|----------|------| +| ERROR | `needs:` references a job that does not exist | +| ERROR | `needs:` references a job in a later stage | +| ERROR | Circular dependency detected in `needs:` graph | +| ERROR | `dependencies:` references a job that does not exist | +| ERROR | `dependencies:` references a job in the same or a later stage | +| ERROR | `extends:` references an unknown job | +| ERROR | Cycle detected in `extends:` graph | + +### Hidden jobs (templates) + +Jobs whose name starts with `.` are treated as reusable templates and skipped for most rules. This matches GitLab's own behaviour. + +## Development + +This project uses [Task](https://taskfile.dev) as a task runner. + +```bash +task # list available tasks +task build # compile the binary +task test # run Go unit tests +task lint-go # run go vet +task validate # run the binary against all testdata fixtures +task ci # full check: vet → test → build → validate +task clean # remove build artifacts +``` + +## Project structure + +``` +. +├── cmd/gitlab-sim/ # CLI entrypoint +├── internal/ +│ ├── linter/ # lint rules and findings +│ ├── model/ # pipeline data structures and YAML parser +│ └── resolver/ # extends: resolution +├── testdata/ # sample pipelines used for manual validation +├── Taskfile.yml +└── go.mod +``` diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 0000000..3b06b59 --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,59 @@ +version: "3" + +vars: + BINARY: gitlab-sim + GO: /usr/local/go/bin/go + +tasks: + default: + desc: List available tasks + cmd: task --list + + build: + desc: Build the gitlab-sim binary + cmds: + - "{{.GO}} build -o {{.BINARY}} ./cmd/gitlab-sim/..." + sources: + - "**/*.go" + - go.mod + generates: + - "{{.BINARY}}" + + test: + desc: Run Go unit tests + cmd: "{{.GO}} test ./..." + + validate: + desc: Run gitlab-sim against all testdata fixtures + deps: [build] + cmds: + - cmd: ./{{.BINARY}} testdata/valid.yml + ignore_error: false + - cmd: ./{{.BINARY}} testdata/extends.yml + ignore_error: false + - cmd: ./{{.BINARY}} testdata/keywords_valid.yml + ignore_error: false + - cmd: ./{{.BINARY}} testdata/invalid.yml + ignore_error: true + - cmd: ./{{.BINARY}} testdata/needs.yml + ignore_error: true + - cmd: ./{{.BINARY}} testdata/needs_cycle.yml + ignore_error: true + - cmd: ./{{.BINARY}} testdata/keywords_invalid.yml + ignore_error: true + + lint-go: + desc: Run go vet on all packages + cmd: "{{.GO}} vet ./..." + + ci: + desc: Full CI check — vet, test, build, validate + cmds: + - task: lint-go + - task: test + - task: build + - task: validate + + clean: + desc: Remove build artifacts + cmd: rm -f {{.BINARY}} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1d935a8 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module git.k3nny.fr/gitlab-sim + +go 1.26.4 + +require gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4bc0337 --- /dev/null +++ b/go.sum @@ -0,0 +1,3 @@ +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= diff --git a/internal/linter/dependencies.go b/internal/linter/dependencies.go new file mode 100644 index 0000000..9bfa645 --- /dev/null +++ b/internal/linter/dependencies.go @@ -0,0 +1,44 @@ +package linter + +import ( + "fmt" + + "git.k3nny.fr/gitlab-sim/internal/model" +) + +func checkDependencies(p *model.Pipeline) []Finding { + stageIndex := make(map[string]int, len(p.Stages)) + for i, s := range p.Stages { + stageIndex[s] = i + } + + var findings []Finding + for name, job := range p.Jobs { + if len(job.Dependencies) == 0 { + continue + } + jobStageIdx, jobHasStage := stageIndex[job.Stage] + for _, dep := range job.Dependencies { + depJob, exists := p.Jobs[dep] + if !exists { + findings = append(findings, Finding{ + Severity: Error, + Job: name, + Message: fmt.Sprintf("'dependencies' references unknown job %q", dep), + }) + continue + } + if len(p.Stages) > 0 && jobHasStage && depJob.Stage != "" { + depIdx, depHasStage := stageIndex[depJob.Stage] + if depHasStage && depIdx >= jobStageIdx { + findings = append(findings, Finding{ + Severity: Error, + Job: name, + Message: fmt.Sprintf("'dependencies' job %q must be in an earlier stage (in %q, current job is in %q)", dep, depJob.Stage, job.Stage), + }) + } + } + } + } + return findings +} diff --git a/internal/linter/keywords.go b/internal/linter/keywords.go new file mode 100644 index 0000000..0142539 --- /dev/null +++ b/internal/linter/keywords.go @@ -0,0 +1,498 @@ +package linter + +import ( + "fmt" + "regexp" + "strings" + + "git.k3nny.fr/gitlab-sim/internal/model" +) + +var validJobWhen = map[string]bool{ + "on_success": true, + "on_failure": true, + "always": true, + "manual": true, + "delayed": true, + "never": true, +} + +var validRuleWhen = map[string]bool{ + "on_success": true, + "on_failure": true, + "always": true, + "manual": true, + "delayed": true, + "never": true, +} + +var validWorkflowRuleWhen = map[string]bool{ + "always": true, + "never": true, +} + +var validArtifactsWhen = map[string]bool{ + "on_success": true, + "on_failure": true, + "always": true, +} + +var validCacheWhen = map[string]bool{ + "on_success": true, + "on_failure": true, + "always": true, +} + +var validCachePolicy = map[string]bool{ + "pull": true, + "push": true, + "pull-push": true, +} + +var validRetryWhen = map[string]bool{ + "always": true, + "unknown_failure": true, + "script_failure": true, + "api_failure": true, + "stuck_or_timeout_failure": true, + "runner_system_failure": true, + "runner_unsupported": true, + "stale_schedule": true, + "job_execution_timeout": true, + "archived_failure": true, + "unmet_prerequisites": true, + "scheduler_failure": true, + "data_integrity_failure": true, + "forward_deployment_failure": true, + "insufficient_bridge_permissions": true, + "downstream_bridge_project_not_found": true, + "invalid_bridge_trigger": true, + "upstream_bridge_project_not_found": true, + "protected_environment_failure": true, + "pipeline_loop_detected": true, + "excluded_by_rules": true, +} + +var validEnvironmentAction = map[string]bool{ + "start": true, + "stop": true, + "prepare": true, + "verify": true, + "access": true, +} + +func checkJobKeywords(name string, job model.Job) []Finding { + var findings []Finding + findings = append(findings, checkWhen(name, job)...) + findings = append(findings, checkParallel(name, job)...) + findings = append(findings, checkRetry(name, job)...) + findings = append(findings, checkAllowFailure(name, job)...) + findings = append(findings, checkInterruptible(name, job)...) + findings = append(findings, checkTrigger(name, job)...) + findings = append(findings, checkCoverage(name, job)...) + findings = append(findings, checkRelease(name, job)...) + findings = append(findings, checkEnvironment(name, job)...) + findings = append(findings, checkArtifacts(name, job)...) + findings = append(findings, checkCache(name, job)...) + findings = append(findings, checkRules(name, job)...) + findings = append(findings, checkImage(name, job)...) + findings = append(findings, checkInherit(name, job)...) + return findings +} + +func checkWhen(name string, job model.Job) []Finding { + var findings []Finding + if job.When != "" && !validJobWhen[job.When] { + findings = append(findings, Finding{ + Severity: Error, + Job: name, + Message: fmt.Sprintf("'when' has invalid value %q; valid: on_success, on_failure, always, manual, delayed, never", job.When), + }) + } + if job.When == "delayed" && job.StartIn == "" { + findings = append(findings, Finding{ + Severity: Error, + Job: name, + Message: "'when: delayed' requires 'start_in' (e.g. 'start_in: 30 minutes')", + }) + } + if job.When != "delayed" && job.StartIn != "" { + findings = append(findings, Finding{ + Severity: Error, + Job: name, + Message: "'start_in' is only valid when 'when: delayed'", + }) + } + return findings +} + +func checkParallel(name string, job model.Job) []Finding { + if job.Parallel == nil { + return nil + } + switch v := job.Parallel.(type) { + case int: + if v < 2 || v > 200 { + return []Finding{{ + Severity: Error, + Job: name, + Message: fmt.Sprintf("'parallel' must be between 2 and 200, got %d", v), + }} + } + case map[string]any: + if _, ok := v["matrix"]; !ok { + return []Finding{{ + Severity: Error, + Job: name, + Message: "'parallel' map form must have a 'matrix' key", + }} + } + default: + return []Finding{{ + Severity: Error, + Job: name, + Message: "'parallel' must be an integer (2–200) or a map with 'matrix'", + }} + } + return nil +} + +func checkRetry(name string, job model.Job) []Finding { + if job.Retry == nil { + return nil + } + switch v := job.Retry.(type) { + case int: + if v < 0 || v > 2 { + return []Finding{{ + Severity: Error, + Job: name, + Message: fmt.Sprintf("'retry' must be 0, 1, or 2; got %d", v), + }} + } + case map[string]any: + var findings []Finding + if maxVal, ok := v["max"]; ok { + if n, ok := maxVal.(int); ok && (n < 0 || n > 2) { + findings = append(findings, Finding{ + Severity: Error, + Job: name, + Message: fmt.Sprintf("'retry.max' must be 0, 1, or 2; got %d", n), + }) + } + } + if whenVal, ok := v["when"]; ok { + findings = append(findings, validateRetryWhen(name, whenVal)...) + } + return findings + default: + return []Finding{{ + Severity: Error, + Job: name, + Message: "'retry' must be an integer (0–2) or a map with 'max'/'when'", + }} + } + return nil +} + +func validateRetryWhen(name string, val any) []Finding { + var findings []Finding + check := func(s string) { + if !validRetryWhen[s] { + findings = append(findings, Finding{ + Severity: Error, + Job: name, + Message: fmt.Sprintf("'retry.when' has invalid value %q", s), + }) + } + } + switch v := val.(type) { + case string: + check(v) + case []any: + for _, item := range v { + if s, ok := item.(string); ok { + check(s) + } + } + } + return findings +} + +func checkAllowFailure(name string, job model.Job) []Finding { + if job.Allow == nil { + return nil + } + switch v := job.Allow.(type) { + case bool: + // valid + case map[string]any: + if _, ok := v["exit_codes"]; !ok { + return []Finding{{ + Severity: Error, + Job: name, + Message: "'allow_failure' map form must contain 'exit_codes'", + }} + } + default: + _ = v + return []Finding{{ + Severity: Error, + Job: name, + Message: "'allow_failure' must be a boolean or a map with 'exit_codes'", + }} + } + return nil +} + +func checkInterruptible(name string, job model.Job) []Finding { + if job.Interruptible == nil { + return nil + } + if _, ok := job.Interruptible.(bool); !ok { + return []Finding{{ + Severity: Error, + Job: name, + Message: "'interruptible' must be a boolean", + }} + } + return nil +} + +func checkTrigger(name string, job model.Job) []Finding { + if job.Trigger == nil { + return nil + } + var findings []Finding + if len(job.Script) > 0 { + findings = append(findings, Finding{ + Severity: Error, + Job: name, + Message: "jobs with 'trigger' cannot use 'script'", + }) + } + if m, ok := job.Trigger.(map[string]any); ok { + _, hasProject := m["project"] + _, hasInclude := m["include"] + if !hasProject && !hasInclude { + findings = append(findings, Finding{ + Severity: Error, + Job: name, + Message: "'trigger' map must specify 'project' or 'include'", + }) + } + } + return findings +} + +var coveragePattern = regexp.MustCompile(`^/.*/$`) + +func checkCoverage(name string, job model.Job) []Finding { + if job.Coverage == "" { + return nil + } + if !coveragePattern.MatchString(job.Coverage) { + return []Finding{{ + Severity: Error, + Job: name, + Message: fmt.Sprintf("'coverage' must be a regex pattern wrapped in '/' (e.g. '/\\d+\\.?\\d*%%/'), got %q", job.Coverage), + }} + } + return nil +} + +func checkRelease(name string, job model.Job) []Finding { + if job.Release == nil { + return nil + } + m, ok := job.Release.(map[string]any) + if !ok { + return []Finding{{ + Severity: Error, + Job: name, + Message: "'release' must be a map", + }} + } + tagName, exists := m["tag_name"] + if !exists || tagName == "" || tagName == nil { + return []Finding{{ + Severity: Error, + Job: name, + Message: "'release' requires 'tag_name'", + }} + } + return nil +} + +func checkEnvironment(name string, job model.Job) []Finding { + if job.Environment == nil { + return nil + } + m, ok := job.Environment.(map[string]any) + if !ok { + // String form: just the environment name — valid. + return nil + } + var findings []Finding + envName, _ := m["name"] + _, hasURL := m["url"] + if (envName == nil || envName == "") && hasURL { + findings = append(findings, Finding{ + Severity: Error, + Job: name, + Message: "'environment.url' requires 'environment.name' to be set", + }) + } + if action, ok := m["action"].(string); ok && !validEnvironmentAction[action] { + findings = append(findings, Finding{ + Severity: Error, + Job: name, + Message: fmt.Sprintf("'environment.action' has invalid value %q; valid: start, stop, prepare, verify, access", action), + }) + } + return findings +} + +func checkArtifacts(name string, job model.Job) []Finding { + if job.Artifacts == nil { + return nil + } + m, ok := job.Artifacts.(map[string]any) + if !ok { + return nil + } + var findings []Finding + if w, ok := m["when"].(string); ok && !validArtifactsWhen[w] { + findings = append(findings, Finding{ + Severity: Error, + Job: name, + Message: fmt.Sprintf("'artifacts.when' has invalid value %q; valid: on_success, on_failure, always", w), + }) + } + if _, hasExposeAs := m["expose_as"]; hasExposeAs { + paths, _ := m["paths"] + if paths == nil { + findings = append(findings, Finding{ + Severity: Error, + Job: name, + Message: "'artifacts.expose_as' requires 'artifacts.paths'", + }) + } + } + // Pages job should publish to public/ + if name == "pages" { + if paths, ok := m["paths"].([]any); ok { + found := false + for _, p := range paths { + if s, ok := p.(string); ok && (s == "public" || strings.HasPrefix(s, "public/")) { + found = true + break + } + } + if !found { + findings = append(findings, Finding{ + Severity: Warning, + Job: name, + Message: "the 'pages' job should include 'public' in 'artifacts.paths' for GitLab Pages to deploy", + }) + } + } + } + return findings +} + +func checkCache(name string, job model.Job) []Finding { + if job.Cache == nil { + return nil + } + var maps []map[string]any + switch v := job.Cache.(type) { + case map[string]any: + maps = []map[string]any{v} + case []any: + for _, item := range v { + if m, ok := item.(map[string]any); ok { + maps = append(maps, m) + } + } + } + var findings []Finding + for _, m := range maps { + if w, ok := m["when"].(string); ok && !validCacheWhen[w] { + findings = append(findings, Finding{ + Severity: Error, + Job: name, + Message: fmt.Sprintf("'cache.when' has invalid value %q; valid: on_success, on_failure, always", w), + }) + } + if p, ok := m["policy"].(string); ok && !validCachePolicy[p] { + findings = append(findings, Finding{ + Severity: Error, + Job: name, + Message: fmt.Sprintf("'cache.policy' has invalid value %q; valid: pull, push, pull-push", p), + }) + } + } + return findings +} + +func checkRules(name string, job model.Job) []Finding { + var findings []Finding + for i, rule := range job.Rules { + if rule.When != "" && !validRuleWhen[rule.When] { + findings = append(findings, Finding{ + Severity: Error, + Job: name, + Message: fmt.Sprintf("rules[%d].when has invalid value %q; valid: on_success, on_failure, always, manual, delayed, never", i, rule.When), + }) + } + } + return findings +} + +func checkImage(name string, job model.Job) []Finding { + if job.Image == nil { + return nil + } + m, ok := job.Image.(map[string]any) + if !ok { + return nil // String form is valid. + } + imgName, _ := m["name"] + if imgName == nil || imgName == "" { + return []Finding{{ + Severity: Error, + Job: name, + Message: "'image' map form requires a 'name' key", + }} + } + return nil +} + +func checkInherit(name string, job model.Job) []Finding { + if job.Inherit == nil { + return nil + } + m, ok := job.Inherit.(map[string]any) + if !ok { + return nil + } + var findings []Finding + for _, key := range []string{"default", "variables"} { + val, exists := m[key] + if !exists { + continue + } + switch val.(type) { + case bool, []any: + // valid: false/true or list of names + default: + findings = append(findings, Finding{ + Severity: Error, + Job: name, + Message: fmt.Sprintf("'inherit.%s' must be a boolean or a list of names", key), + }) + } + } + return findings +} diff --git a/internal/linter/linter.go b/internal/linter/linter.go new file mode 100644 index 0000000..60d1cd8 --- /dev/null +++ b/internal/linter/linter.go @@ -0,0 +1,139 @@ +package linter + +import ( + "fmt" + "strings" + + "git.k3nny.fr/gitlab-sim/internal/model" +) + +type Severity string + +const ( + Error Severity = "ERROR" + Warning Severity = "WARNING" +) + +type Finding struct { + Severity Severity + Job string // empty for pipeline-level findings + Message string +} + +func (f Finding) String() string { + if f.Job != "" { + return fmt.Sprintf("[%s] job %q: %s", f.Severity, f.Job, f.Message) + } + return fmt.Sprintf("[%s] %s", f.Severity, f.Message) +} + +// Lint runs all rules against p and returns findings sorted by job name. +func Lint(p *model.Pipeline) []Finding { + var findings []Finding + findings = append(findings, checkStages(p)...) + findings = append(findings, checkWorkflow(p)...) + findings = append(findings, checkJobs(p)...) + findings = append(findings, checkNeeds(p)...) + findings = append(findings, checkDependencies(p)...) + return findings +} + +func checkStages(p *model.Pipeline) []Finding { + var findings []Finding + if len(p.Stages) == 0 { + findings = append(findings, Finding{ + Severity: Warning, + Message: "no stages defined; GitLab will use default stages (build, test, deploy)", + }) + } + return findings +} + +func checkWorkflow(p *model.Pipeline) []Finding { + if p.Workflow == nil { + return nil + } + var findings []Finding + for i, rule := range p.Workflow.Rules { + if rule.When != "" && !validWorkflowRuleWhen[rule.When] { + findings = append(findings, Finding{ + Severity: Error, + Message: fmt.Sprintf("workflow.rules[%d].when has invalid value %q; valid: always, never", i, rule.When), + }) + } + } + return findings +} + +func checkJobs(p *model.Pipeline) []Finding { + var findings []Finding + + stageSet := make(map[string]bool, len(p.Stages)) + for _, s := range p.Stages { + stageSet[s] = true + } + + for name, job := range p.Jobs { + findings = append(findings, checkJob(name, job, stageSet)...) + } + return findings +} + +func checkJob(name string, job model.Job, stageSet map[string]bool) []Finding { + var findings []Finding + + // Hidden jobs (names starting with ".") are reusable templates; skip most checks. + isTemplate := strings.HasPrefix(name, ".") + isTrigger := job.Trigger != nil + + // After extends resolution, a job with no script/run is an error. + // Exceptions: trigger jobs, pages jobs (use pages: keyword), and template jobs. + hasScript := len(job.Script) > 0 || job.Run != nil + if !isTemplate && !isTrigger && job.Pages == nil && !hasScript { + findings = append(findings, Finding{ + Severity: Error, + Job: name, + Message: "missing required field 'script' (or 'run')", + }) + } + + // stage must exist in declared stages (if stages were declared) + if job.Stage != "" && len(stageSet) > 0 && !stageSet[job.Stage] { + findings = append(findings, Finding{ + Severity: Error, + Job: name, + Message: fmt.Sprintf("stage %q is not defined in 'stages'", job.Stage), + }) + } + + // only and rules are mutually exclusive + if job.Only != nil && len(job.Rules) > 0 { + findings = append(findings, Finding{ + Severity: Error, + Job: name, + Message: "'only' and 'rules' cannot be used together", + }) + } + + // except and rules are mutually exclusive + if job.Except != nil && len(job.Rules) > 0 { + findings = append(findings, Finding{ + Severity: Error, + Job: name, + Message: "'except' and 'rules' cannot be used together", + }) + } + + // warn about deprecated only/except + if job.Only != nil || job.Except != nil { + findings = append(findings, Finding{ + Severity: Warning, + Job: name, + Message: "'only'/'except' are deprecated; prefer 'rules'", + }) + } + + findings = append(findings, checkJobKeywords(name, job)...) + + return findings +} diff --git a/internal/linter/needs.go b/internal/linter/needs.go new file mode 100644 index 0000000..f9349a9 --- /dev/null +++ b/internal/linter/needs.go @@ -0,0 +1,123 @@ +package linter + +import ( + "fmt" + + "git.k3nny.fr/gitlab-sim/internal/model" +) + +func checkNeeds(p *model.Pipeline) []Finding { + var findings []Finding + + // Build a stage-index map for ordering checks. + stageIndex := make(map[string]int, len(p.Stages)) + for i, s := range p.Stages { + stageIndex[s] = i + } + + // needsGraph maps each job to the list of jobs it depends on. + // Used for cycle detection after individual checks. + needsGraph := make(map[string][]string) + + for name, job := range p.Jobs { + if len(job.Needs) == 0 { + continue + } + + neededNames := parseNeedJobNames(job.Needs) + needsGraph[name] = neededNames + + jobStageIdx, jobHasStage := stageIndex[job.Stage] + + for _, needed := range neededNames { + neededJob, exists := p.Jobs[needed] + if !exists { + findings = append(findings, Finding{ + Severity: Error, + Job: name, + Message: fmt.Sprintf("needs unknown job %q", needed), + }) + continue + } + + // A job cannot need a job in a later stage. + if len(p.Stages) > 0 && jobHasStage && neededJob.Stage != "" { + neededStageIdx, neededHasStage := stageIndex[neededJob.Stage] + if neededHasStage && neededStageIdx > jobStageIdx { + findings = append(findings, Finding{ + Severity: Error, + Job: name, + Message: fmt.Sprintf( + "needs %q which is in a later stage (%q after %q)", + needed, neededJob.Stage, job.Stage, + ), + }) + } + } + } + } + + findings = append(findings, detectNeedsCycles(needsGraph)...) + return findings +} + +// parseNeedJobNames extracts job names from a needs: list. +// Each element is either a plain string or a map with a "job" key. +// Cross-pipeline needs (maps with a "pipeline" key) are skipped. +func parseNeedJobNames(needs []any) []string { + var names []string + for _, n := range needs { + switch v := n.(type) { + case string: + names = append(names, v) + case map[string]any: + if _, crossPipeline := v["pipeline"]; crossPipeline { + continue + } + if job, ok := v["job"].(string); ok { + names = append(names, job) + } + } + } + return names +} + +func detectNeedsCycles(graph map[string][]string) []Finding { + const ( + unvisited = 0 + visiting = 1 + visited = 2 + ) + + state := make(map[string]int, len(graph)) + var findings []Finding + reported := make(map[string]bool) + + var visit func(name string, path []string) + visit = func(name string, path []string) { + switch state[name] { + case visited: + return + case visiting: + if !reported[name] { + reported[name] = true + findings = append(findings, Finding{ + Severity: Error, + Job: name, + Message: fmt.Sprintf("circular dependency in needs: %v → %s", path, name), + }) + } + return + } + state[name] = visiting + for _, dep := range graph[name] { + visit(dep, append(path, name)) + } + state[name] = visited + } + + for name := range graph { + visit(name, nil) + } + return findings +} diff --git a/internal/model/parser.go b/internal/model/parser.go new file mode 100644 index 0000000..90faa25 --- /dev/null +++ b/internal/model/parser.go @@ -0,0 +1,50 @@ +package model + +import ( + "fmt" + "os" + + "gopkg.in/yaml.v3" +) + +// Parse reads a .gitlab-ci.yml file and returns a Pipeline. +func Parse(path string) (*Pipeline, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading file: %w", err) + } + + // First pass: decode into a raw map to extract job keys. + var raw map[string]yaml.Node + if err := yaml.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("parsing YAML: %w", err) + } + + // Second pass: decode known top-level fields into Pipeline. + p := &Pipeline{} + if err := yaml.Unmarshal(data, p); err != nil { + return nil, fmt.Errorf("parsing pipeline structure: %w", err) + } + + p.Jobs = make(map[string]Job) + p.RawJobs = make(map[string]map[string]any) + for key, node := range raw { + if ReservedKeys[key] { + continue + } + var rawMap map[string]any + if err := node.Decode(&rawMap); err != nil { + return nil, fmt.Errorf("parsing raw job %q: %w", key, err) + } + p.RawJobs[key] = rawMap + + var j Job + if err := node.Decode(&j); err != nil { + return nil, fmt.Errorf("parsing job %q: %w", key, err) + } + j.Name = key + p.Jobs[key] = j + } + + return p, nil +} diff --git a/internal/model/pipeline.go b/internal/model/pipeline.go new file mode 100644 index 0000000..fbf2c76 --- /dev/null +++ b/internal/model/pipeline.go @@ -0,0 +1,83 @@ +package model + +// Pipeline represents the top-level structure of a .gitlab-ci.yml file. +// Unknown top-level keys are collected into Jobs. +type Pipeline struct { + Stages []string `yaml:"stages"` + Variables map[string]string `yaml:"variables"` + Default *DefaultConfig `yaml:"default"` + Include []any `yaml:"include"` + Workflow *Workflow `yaml:"workflow"` + // Jobs holds every non-reserved top-level key (i.e. job definitions). + Jobs map[string]Job `yaml:"-"` + RawJobs map[string]map[string]any `yaml:"-"` // pre-resolution raw maps, used by the resolver +} + +type DefaultConfig struct { + Image string `yaml:"image"` + BeforeScript []string `yaml:"before_script"` + AfterScript []string `yaml:"after_script"` + Cache any `yaml:"cache"` + Artifacts any `yaml:"artifacts"` + Retry any `yaml:"retry"` + Timeout string `yaml:"timeout"` + Tags []string `yaml:"tags"` +} + +type Workflow struct { + Rules []Rule `yaml:"rules"` +} + +type Job struct { + Name string // set by parser, not from YAML + Stage string `yaml:"stage"` + Script []string `yaml:"script"` + Run any `yaml:"run"` // alternative to script (CI steps) + BeforeScript []string `yaml:"before_script"` + AfterScript []string `yaml:"after_script"` + Image any `yaml:"image"` + Services []any `yaml:"services"` + Variables map[string]string `yaml:"variables"` + Rules []Rule `yaml:"rules"` + Only any `yaml:"only"` + Except any `yaml:"except"` + Needs []any `yaml:"needs"` + Dependencies []string `yaml:"dependencies"` + Artifacts any `yaml:"artifacts"` + Cache any `yaml:"cache"` + Tags []string `yaml:"tags"` + Allow any `yaml:"allow_failure"` + When string `yaml:"when"` + StartIn string `yaml:"start_in"` + Timeout string `yaml:"timeout"` + Retry any `yaml:"retry"` + Parallel any `yaml:"parallel"` + Extends any `yaml:"extends"` + Trigger any `yaml:"trigger"` + Inherit any `yaml:"inherit"` + Environment any `yaml:"environment"` + Release any `yaml:"release"` + Coverage string `yaml:"coverage"` + Secrets any `yaml:"secrets"` + IDTokens any `yaml:"id_tokens"` + Pages any `yaml:"pages"` + Interruptible any `yaml:"interruptible"` + ResourceGroup string `yaml:"resource_group"` +} + +type Rule struct { + If string `yaml:"if"` + When string `yaml:"when"` + Changes []string `yaml:"changes"` + Exists []string `yaml:"exists"` +} + +// ReservedKeys are top-level GitLab CI keys that are NOT job definitions. +var ReservedKeys = map[string]bool{ + "stages": true, + "variables": true, + "default": true, + "include": true, + "workflow": true, + "spec": true, // CI component spec header +} diff --git a/internal/resolver/extends.go b/internal/resolver/extends.go new file mode 100644 index 0000000..7ae2789 --- /dev/null +++ b/internal/resolver/extends.go @@ -0,0 +1,160 @@ +package resolver + +import ( + "fmt" + + "git.k3nny.fr/gitlab-sim/internal/model" + "gopkg.in/yaml.v3" +) + +// Resolve resolves all extends: references in p.Jobs in place. +// Jobs are merged depth-first so that base definitions are resolved before +// derived ones. Mutates p.Jobs with the fully merged Job structs. +func Resolve(p *model.Pipeline) error { + // Build extends graph: jobName -> ordered list of base job names. + extendsGraph := make(map[string][]string, len(p.Jobs)) + for name, job := range p.Jobs { + bases, err := parseExtends(job.Extends) + if err != nil { + return fmt.Errorf("job %q: invalid extends: %w", name, err) + } + if len(bases) == 0 { + continue + } + for _, base := range bases { + if _, ok := p.RawJobs[base]; !ok { + return fmt.Errorf("job %q extends unknown job %q", name, base) + } + } + extendsGraph[name] = bases + } + + if len(extendsGraph) == 0 { + return nil + } + + // Topological sort — bases must be resolved before derived jobs. + order, err := topoSort(extendsGraph) + if err != nil { + return err + } + + // resolved holds the final merged raw map for each processed job. + resolved := make(map[string]map[string]any, len(order)) + + for _, name := range order { + bases := extendsGraph[name] + + // Merge bases left-to-right (later bases override earlier ones), + // then overlay the job's own definition on top. + merged := make(map[string]any) + for _, baseName := range bases { + baseMap := resolved[baseName] + if baseMap == nil { + baseMap = p.RawJobs[baseName] + } + merged = deepMerge(merged, baseMap) + } + merged = deepMerge(merged, p.RawJobs[name]) + resolved[name] = merged + + // Re-decode the merged map into a Job struct. + data, err := yaml.Marshal(merged) + if err != nil { + return fmt.Errorf("job %q: re-encoding merged definition: %w", name, err) + } + var j model.Job + if err := yaml.Unmarshal(data, &j); err != nil { + return fmt.Errorf("job %q: re-decoding merged definition: %w", name, err) + } + j.Name = name + p.Jobs[name] = j + } + + return nil +} + +// parseExtends normalises the extends field (string or []any) into []string. +func parseExtends(extends any) ([]string, error) { + if extends == nil { + return nil, nil + } + switch v := extends.(type) { + case string: + return []string{v}, nil + case []any: + names := make([]string, 0, len(v)) + for _, item := range v { + s, ok := item.(string) + if !ok { + return nil, fmt.Errorf("expected string item, got %T", item) + } + names = append(names, s) + } + return names, nil + default: + return nil, fmt.Errorf("unexpected type %T", extends) + } +} + +// topoSort returns the keys of graph in dependency order (bases first). +// Returns an error if a cycle is detected. +func topoSort(graph map[string][]string) ([]string, error) { + const ( + unvisited = 0 + visiting = 1 + visited = 2 + ) + + state := make(map[string]int, len(graph)) + order := make([]string, 0, len(graph)) + + var visit func(name string) error + visit = func(name string) error { + switch state[name] { + case visited: + return nil + case visiting: + return fmt.Errorf("cycle in extends involving job %q", name) + } + state[name] = visiting + for _, dep := range graph[name] { + if _, inGraph := graph[dep]; inGraph { + if err := visit(dep); err != nil { + return err + } + } + } + state[name] = visited + order = append(order, name) + return nil + } + + for name := range graph { + if err := visit(name); err != nil { + return nil, err + } + } + return order, nil +} + +// deepMerge returns a new map that is override merged onto base. +// Maps are merged recursively; all other values are replaced by override. +func deepMerge(base, override map[string]any) map[string]any { + result := make(map[string]any, len(base)+len(override)) + for k, v := range base { + result[k] = v + } + for k, v := range override { + if baseVal, exists := result[k]; exists { + if baseMap, ok := baseVal.(map[string]any); ok { + if overMap, ok := v.(map[string]any); ok { + result[k] = deepMerge(baseMap, overMap) + continue + } + } + } + result[k] = v + } + return result +} diff --git a/testdata/extends.yml b/testdata/extends.yml new file mode 100644 index 0000000..30c903c --- /dev/null +++ b/testdata/extends.yml @@ -0,0 +1,28 @@ +stages: + - build + - test + +# Template — no script of its own; should NOT trigger "missing script" +.base: + image: golang:1.26 + before_script: + - go env + +# Inherits image + before_script from .base, adds its own script +build-job: + extends: .base + stage: build + script: + - go build ./... + +# Multi-level: .test-base extends .base, test-job extends .test-base +.test-base: + extends: .base + stage: test + variables: + CGO_ENABLED: "0" + +test-job: + extends: .test-base + script: + - go test ./... diff --git a/testdata/invalid.yml b/testdata/invalid.yml new file mode 100644 index 0000000..70a9749 --- /dev/null +++ b/testdata/invalid.yml @@ -0,0 +1,23 @@ +stages: + - build + - test + +build-job: + stage: build + script: + - echo "ok" + +bad-stage-job: + stage: nonexistent + script: + - echo "this stage does not exist" + +no-script-job: + stage: test + +deprecated-job: + stage: test + script: + - echo "old style" + only: + - main diff --git a/testdata/keywords_invalid.yml b/testdata/keywords_invalid.yml new file mode 100644 index 0000000..d198a57 --- /dev/null +++ b/testdata/keywords_invalid.yml @@ -0,0 +1,128 @@ +stages: + - build + - test + +workflow: + rules: + - if: '$CI_PIPELINE_SOURCE == "push"' + when: on_success # ERROR: workflow rules only allow always/never + +bad-when-job: + stage: build + script: + - echo hello + when: whenever # ERROR: invalid when value + +bad-delayed-job: + stage: build + script: + - echo hello + when: delayed # ERROR: missing start_in + +bad-start-in-job: + stage: build + script: + - echo hello + start_in: 30 minutes # ERROR: start_in without when: delayed + +bad-parallel-job: + stage: build + script: + - echo hello + parallel: 1 # ERROR: must be 2–200 + +bad-retry-job: + stage: build + script: + - echo hello + retry: 5 # ERROR: must be 0–2 + +bad-retry-when-job: + stage: build + script: + - echo hello + retry: + max: 1 + when: cosmic_ray # ERROR: invalid retry.when value + +bad-allow-failure-job: + stage: build + script: + - echo hello + allow_failure: + bad_key: true # ERROR: map form must have exit_codes + +bad-coverage-job: + stage: build + script: + - echo hello + coverage: "not-a-regex" # ERROR: must be wrapped in / + +bad-release-job: + stage: build + script: + - echo hello + release: + description: "no tag" # ERROR: missing tag_name + +bad-environment-job: + stage: test + script: + - echo hello + environment: + url: https://example.com # ERROR: url without name + +bad-environment-action-job: + stage: test + script: + - echo hello + environment: + name: staging + action: dance # ERROR: invalid action value + +bad-artifacts-when-job: + stage: build + script: + - echo hello + artifacts: + paths: + - dist/ + when: sometimes # ERROR: invalid artifacts.when + +bad-cache-policy-job: + stage: build + script: + - echo hello + cache: + paths: + - .cache/ + policy: read-write # ERROR: invalid cache.policy + +bad-dependencies-job: + stage: test + script: + - echo hello + dependencies: + - nonexistent-job # ERROR: unknown job + +bad-dependencies-stage-job: + stage: build + script: + - echo hello + needs: + - test-job + dependencies: + - test-job # ERROR: test-job is in a later stage + +test-job: + stage: test + script: + - echo test + +bad-rule-when-job: + stage: build + script: + - echo hello + rules: + - if: '$CI_MERGE_REQUEST_ID' + when: sometimes # ERROR: invalid rules[0].when diff --git a/testdata/keywords_valid.yml b/testdata/keywords_valid.yml new file mode 100644 index 0000000..9de6f72 --- /dev/null +++ b/testdata/keywords_valid.yml @@ -0,0 +1,79 @@ +stages: + - build + - test + - release + +workflow: + rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + when: always + - when: never + +variables: + GO_VERSION: "1.26" + +build-job: + stage: build + image: + name: golang:1.26 + entrypoint: [""] + script: + - go build ./... + artifacts: + paths: + - bin/ + when: on_success + expire_in: 1 week + cache: + key: go-modules + paths: + - .go/pkg/mod + policy: pull-push + when: on_success + retry: 2 + interruptible: true + resource_group: build-group + +test-job: + stage: test + script: + - go test ./... + coverage: '/coverage: \d+\.\d+%/' + allow_failure: + exit_codes: [1, 2] + parallel: 4 + needs: + - build-job + dependencies: + - build-job + retry: + max: 2 + when: + - script_failure + - runner_system_failure + +release-job: + stage: release + script: + - echo "creating release" + release: + tag_name: $CI_COMMIT_TAG + description: "Release $CI_COMMIT_TAG" + rules: + - if: '$CI_COMMIT_TAG' + when: on_success + environment: + name: production + url: https://example.com + action: start + when: on_success + +.template: + before_script: + - echo "before" + tags: + - docker + inherit: + default: false + variables: + - GO_VERSION diff --git a/testdata/needs.yml b/testdata/needs.yml new file mode 100644 index 0000000..d48c16f --- /dev/null +++ b/testdata/needs.yml @@ -0,0 +1,40 @@ +stages: + - build + - test + - deploy + +build-job: + stage: build + script: + - go build ./... + +test-job: + stage: test + script: + - go test ./... + needs: + - build-job # OK: build comes before test + +deploy-job: + stage: deploy + script: + - echo "deploy" + needs: + - job: test-job + artifacts: true # OK: test comes before deploy + +# Bad: needs a job in a later stage +bad-needs-job: + stage: build + script: + - echo "bad" + needs: + - test-job # ERROR: test is after build + +# Bad: needs a job that doesn't exist +missing-needs-job: + stage: test + script: + - echo "bad" + needs: + - nonexistent-job # ERROR: job doesn't exist diff --git a/testdata/needs_cycle.yml b/testdata/needs_cycle.yml new file mode 100644 index 0000000..4e86747 --- /dev/null +++ b/testdata/needs_cycle.yml @@ -0,0 +1,16 @@ +stages: + - build + +job-a: + stage: build + script: + - echo a + needs: + - job-b + +job-b: + stage: build + script: + - echo b + needs: + - job-a diff --git a/testdata/valid.yml b/testdata/valid.yml new file mode 100644 index 0000000..5066771 --- /dev/null +++ b/testdata/valid.yml @@ -0,0 +1,26 @@ +stages: + - build + - test + - deploy + +variables: + APP_ENV: production + +build-job: + stage: build + script: + - echo "Building..." + - go build ./... + +test-job: + stage: test + script: + - go test ./... + +deploy-job: + stage: deploy + script: + - echo "Deploying to $APP_ENV" + rules: + - if: '$CI_COMMIT_BRANCH == "main"' + when: on_success