feat(gitlab-sim): ✨ first commit
This commit is contained in:
commit
e2334ec12d
35
.gitignore
vendored
Normal file
35
.gitignore
vendored
Normal file
@ -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
|
||||||
48
CHANGELOG.md
Normal file
48
CHANGELOG.md
Normal file
@ -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 <file>` 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`
|
||||||
47
CLAUDE.md
Normal file
47
CLAUDE.md
Normal file
@ -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:
|
||||||
|
```
|
||||||
|
<type>(<scope>): <description>
|
||||||
|
|
||||||
|
[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.
|
||||||
144
README.md
Normal file
144
README.md
Normal file
@ -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 <pipeline.yml>
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
```
|
||||||
59
Taskfile.yml
Normal file
59
Taskfile.yml
Normal file
@ -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}}
|
||||||
5
go.mod
Normal file
5
go.mod
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
module git.k3nny.fr/gitlab-sim
|
||||||
|
|
||||||
|
go 1.26.4
|
||||||
|
|
||||||
|
require gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
3
go.sum
Normal file
3
go.sum
Normal file
@ -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=
|
||||||
44
internal/linter/dependencies.go
Normal file
44
internal/linter/dependencies.go
Normal file
@ -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
|
||||||
|
}
|
||||||
498
internal/linter/keywords.go
Normal file
498
internal/linter/keywords.go
Normal file
@ -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
|
||||||
|
}
|
||||||
139
internal/linter/linter.go
Normal file
139
internal/linter/linter.go
Normal file
@ -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
|
||||||
|
}
|
||||||
123
internal/linter/needs.go
Normal file
123
internal/linter/needs.go
Normal file
@ -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
|
||||||
|
}
|
||||||
50
internal/model/parser.go
Normal file
50
internal/model/parser.go
Normal file
@ -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
|
||||||
|
}
|
||||||
83
internal/model/pipeline.go
Normal file
83
internal/model/pipeline.go
Normal file
@ -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
|
||||||
|
}
|
||||||
160
internal/resolver/extends.go
Normal file
160
internal/resolver/extends.go
Normal file
@ -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
|
||||||
|
}
|
||||||
28
testdata/extends.yml
vendored
Normal file
28
testdata/extends.yml
vendored
Normal file
@ -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 ./...
|
||||||
23
testdata/invalid.yml
vendored
Normal file
23
testdata/invalid.yml
vendored
Normal file
@ -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
|
||||||
128
testdata/keywords_invalid.yml
vendored
Normal file
128
testdata/keywords_invalid.yml
vendored
Normal file
@ -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
|
||||||
79
testdata/keywords_valid.yml
vendored
Normal file
79
testdata/keywords_valid.yml
vendored
Normal file
@ -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
|
||||||
40
testdata/needs.yml
vendored
Normal file
40
testdata/needs.yml
vendored
Normal file
@ -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
|
||||||
16
testdata/needs_cycle.yml
vendored
Normal file
16
testdata/needs_cycle.yml
vendored
Normal file
@ -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
|
||||||
26
testdata/valid.yml
vendored
Normal file
26
testdata/valid.yml
vendored
Normal file
@ -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
|
||||||
Loading…
Reference in New Issue
Block a user