commit e2334ec12daf2bd615b041baeb777e657a79ed36 Author: k3nny Date: Fri Jun 5 01:29:07 2026 +0200 feat(gitlab-sim): :sparkles: first commit 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