feat(gitlab-sim): 🚀 branches support
This commit is contained in:
+15
-2
@@ -3,12 +3,25 @@
|
|||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||||
This project does not yet use semantic versioning; entries are listed under `[Unreleased]` until a first release is tagged.
|
This project uses [Semantic Versioning](https://semver.org).
|
||||||
|
|
||||||
## [Unreleased]
|
## [0.1.0] - 2026-06-07
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- **Context simulation (`--branch` / `--tag` / `--source` / `--var`)** — show which jobs would be active, manual, or skipped for a specific pipeline event without leaving the terminal:
|
||||||
|
- `--branch <name>` — simulates a branch push; populates `CI_COMMIT_BRANCH`, `CI_COMMIT_REF_NAME`, `CI_COMMIT_REF_SLUG`, and defaults `CI_PIPELINE_SOURCE` to `push`
|
||||||
|
- `--tag <name>` — simulates a tag push; populates `CI_COMMIT_TAG`, clears `CI_COMMIT_BRANCH`
|
||||||
|
- `--source <event>` — sets `CI_PIPELINE_SOURCE` explicitly (`merge_request_event`, `schedule`, `web`, `api`, `pipeline`, …)
|
||||||
|
- `--var KEY=VALUE` — sets any CI variable; repeatable; overrides shortcut values
|
||||||
|
- Evaluates `rules:if:` expressions (`==`, `!=`, `=~`, `!~`, `&&`, `||`, `!`, parentheses, `$VAR`, string literals, `null`)
|
||||||
|
- Evaluates `only:`/`except:` ref keywords (`branches`, `tags`, `merge_requests`, `schedules`, `pushes`, `web`, `api`, `pipelines`), branch name globs (`feat/*`), and `/regex/` patterns
|
||||||
|
- Evaluates `workflow:rules:` — warns when the pipeline itself would not start for the given context
|
||||||
|
- `rules:changes:` and `rules:exists:` are not evaluated (no git tree at lint time); rules without `if:` always match
|
||||||
|
- Linting runs in full regardless of context; context output is printed before findings
|
||||||
|
- New `internal/cicontext` package (`context.go`, `eval.go`, `reachability.go`); no new external dependencies
|
||||||
|
- 33 unit tests covering the expression evaluator; 5 fixture runs in `task validate`
|
||||||
|
|
||||||
- **Graph output (`--graph`)** — visualises the pipeline instead of running lint rules:
|
- **Graph output (`--graph`)** — visualises the pipeline instead of running lint rules:
|
||||||
- `--graph includes` — [Mermaid](https://mermaid.js.org) flowchart of include dependencies written to stdout; one node per `include:` entry (project, component, local, remote, template), colour-coded by type; pipe to a `.mmd` file or paste into [mermaid.live](https://mermaid.live)
|
- `--graph includes` — [Mermaid](https://mermaid.js.org) flowchart of include dependencies written to stdout; one node per `include:` entry (project, component, local, remote, template), colour-coded by type; pipe to a `.mmd` file or paste into [mermaid.live](https://mermaid.live)
|
||||||
- `--graph pipeline` — GitLab CI-style SVG/PNG pipeline graph written to a timestamped file in `--graph-out` (default: `gitlab-sim-out/`); jobs rendered as white chip cards with a coloured status indicator (blue: regular, orange: manual, purple: trigger, amber: delayed); DAG mode draws job-to-job Bézier arrows when any job has `needs:`, classic mode draws L-shaped connectors between stage columns; converted to PNG automatically when `rsvg-convert`, `inkscape`, or `magick` is available
|
- `--graph pipeline` — GitLab CI-style SVG/PNG pipeline graph written to a timestamped file in `--graph-out` (default: `gitlab-sim-out/`); jobs rendered as white chip cards with a coloured status indicator (blue: regular, orange: manual, purple: trigger, amber: delayed); DAG mode draws job-to-job Bézier arrows when any job has `needs:`, classic mode draws L-shaped connectors between stage columns; converted to PNG automatically when `rsvg-convert`, `inkscape`, or `magick` is available
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# gitlab-sim
|
# gitlab-sim
|
||||||
|
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
|
[](CHANGELOG.md)
|
||||||
|
|
||||||
> **Disclaimer:** This tool was built through iterative AI-assisted development with [Claude](https://claude.ai). It is experimental, incomplete, and not intended for production use. Coverage of GitLab CI keywords is best-effort and may lag behind GitLab's evolving spec. Use it at your own discretion — no correctness guarantees are made. Contributions and bug reports are welcome.
|
> **Disclaimer:** This tool was built through iterative AI-assisted development with [Claude](https://claude.ai). It is experimental, incomplete, and not intended for production use. Coverage of GitLab CI keywords is best-effort and may lag behind GitLab's evolving spec. Use it at your own discretion — no correctness guarantees are made. Contributions and bug reports are welcome.
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@ A local tool to validate and lint `.gitlab-ci.yml` pipelines without needing a G
|
|||||||
- **CI/CD catalog components** — resolves `include: component:` references from the GitLab CI/CD Catalog; public components work without a token
|
- **CI/CD catalog components** — resolves `include: component:` references from the GitLab CI/CD Catalog; public components work without a token
|
||||||
- **Deprecation warnings** — flags `only`/`except` usage in favour of `rules`
|
- **Deprecation warnings** — flags `only`/`except` usage in favour of `rules`
|
||||||
- **Graph output** — emits Mermaid diagrams for the include dependency tree and the pipeline jobs layout (DAG or classic stage ordering)
|
- **Graph output** — emits Mermaid diagrams for the include dependency tree and the pipeline jobs layout (DAG or classic stage ordering)
|
||||||
|
- **Context simulation** — pass `--branch`, `--tag`, or `--source` to see which jobs would be active, manual, or skipped for a specific branch push, tag, or pipeline event; evaluates `rules:if:` expressions and `only`/`except` filters
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
@@ -143,10 +145,61 @@ Jobs are colour-coded by type:
|
|||||||
DAG mode (job-to-job Bézier arrows) activates automatically when any job has a `needs:` list.
|
DAG mode (job-to-job Bézier arrows) activates automatically when any job has a `needs:` list.
|
||||||
Classic mode draws L-shaped or straight connectors between stage columns otherwise.
|
Classic mode draws L-shaped or straight connectors between stage columns otherwise.
|
||||||
|
|
||||||
|
### Context simulation
|
||||||
|
|
||||||
|
Pass `--branch`, `--tag`, or `--source` to see which jobs would run for a given
|
||||||
|
pipeline event. The pipeline is still fully linted; context output is printed first.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# What runs on a push to develop?
|
||||||
|
gitlab-sim --branch develop .gitlab-ci.yml
|
||||||
|
|
||||||
|
# What runs when a v1.2.0 tag is pushed?
|
||||||
|
gitlab-sim --tag v1.2.0 .gitlab-ci.yml
|
||||||
|
|
||||||
|
# Merge request pipeline
|
||||||
|
gitlab-sim --source merge_request_event .gitlab-ci.yml
|
||||||
|
|
||||||
|
# Arbitrary variable overrides (repeatable)
|
||||||
|
gitlab-sim --branch main --var DEPLOY_ENV=production .gitlab-ci.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
**Evaluated:**
|
||||||
|
- `rules:if:` — full expression language: `==`, `!=`, `=~`, `!~`, `&&`, `||`, `!`, `()`, `$VAR`, string literals, `null`
|
||||||
|
- `only:` / `except:` — ref keywords (`branches`, `tags`, `merge_requests`, `schedules`, …), branch name globs (`feat/*`), and `/regex/` patterns
|
||||||
|
|
||||||
|
**Not evaluated** (no git tree at lint time): `rules:changes:`, `rules:exists:`.
|
||||||
|
Rules without an `if:` clause always match.
|
||||||
|
|
||||||
|
**Predefined variables** set automatically by the shortcut flags:
|
||||||
|
|
||||||
|
| Flag | Variables populated |
|
||||||
|
|------|---------------------|
|
||||||
|
| `--branch <name>` | `CI_COMMIT_BRANCH`, `CI_COMMIT_REF_NAME`, `CI_COMMIT_REF_SLUG`, `CI_PIPELINE_SOURCE=push` |
|
||||||
|
| `--tag <name>` | `CI_COMMIT_TAG`, `CI_COMMIT_REF_NAME`, `CI_COMMIT_REF_SLUG`, `CI_PIPELINE_SOURCE=push` (clears `CI_COMMIT_BRANCH`) |
|
||||||
|
| `--source <event>` | `CI_PIPELINE_SOURCE` |
|
||||||
|
| `--var KEY=VALUE` | any variable; overrides shortcuts |
|
||||||
|
|
||||||
### Example output
|
### Example output
|
||||||
|
|
||||||
```
|
```
|
||||||
# Clean pipeline
|
# Clean pipeline, no context
|
||||||
|
OK: .gitlab-ci.yml — no issues found (5 jobs, 3 stages)
|
||||||
|
|
||||||
|
# With --branch develop context
|
||||||
|
Context: branch=develop, source=push
|
||||||
|
|
||||||
|
Active (3): build, deploy-staging, test
|
||||||
|
Skipped (2): deploy-prod, release-notes
|
||||||
|
|
||||||
|
OK: .gitlab-ci.yml — no issues found (5 jobs, 3 stages)
|
||||||
|
|
||||||
|
# With --tag v1.0.0 context
|
||||||
|
Context: tag=v1.0.0, source=push
|
||||||
|
|
||||||
|
Active (4): build, deploy-prod, release-notes, test
|
||||||
|
Skipped (1): deploy-staging
|
||||||
|
|
||||||
OK: .gitlab-ci.yml — no issues found (5 jobs, 3 stages)
|
OK: .gitlab-ci.yml — no issues found (5 jobs, 3 stages)
|
||||||
|
|
||||||
# Pipeline with issues
|
# Pipeline with issues
|
||||||
@@ -241,6 +294,7 @@ task clean # remove build artifacts
|
|||||||
.
|
.
|
||||||
├── cmd/gitlab-sim/ # CLI entrypoint
|
├── cmd/gitlab-sim/ # CLI entrypoint
|
||||||
├── internal/
|
├── internal/
|
||||||
|
│ ├── cicontext/ # CI variable context, rules:if: evaluator, job reachability
|
||||||
│ ├── fetcher/ # GitLab API client (project include fetching)
|
│ ├── fetcher/ # GitLab API client (project include fetching)
|
||||||
│ ├── graph/ # Mermaid and SVG/PNG graph generators
|
│ ├── graph/ # Mermaid and SVG/PNG graph generators
|
||||||
│ ├── linter/ # lint rules and findings
|
│ ├── linter/ # lint rules and findings
|
||||||
|
|||||||
+140
@@ -0,0 +1,140 @@
|
|||||||
|
# Roadmap
|
||||||
|
|
||||||
|
This document tracks planned improvements to `gitlab-sim`. Items are grouped by theme, roughly in priority order within each group. Nothing here is a commitment — the tool is experimental and the list will shift as real usage surfaces better priorities.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context-aware validation
|
||||||
|
|
||||||
|
Pipelines in Git Flow, Trunk-Based Development, or any branching strategy are rarely uniform: jobs activate or skip based on `$CI_COMMIT_BRANCH`, `$CI_COMMIT_TAG`, `$CI_PIPELINE_SOURCE`, and similar runtime variables. Today `gitlab-sim` validates structure but cannot tell which jobs are actually reachable for a given context.
|
||||||
|
|
||||||
|
The plan is to make the execution context injectable so the linter can evaluate `rules:if:` / `only` / `except` conditions and report per-context reachability.
|
||||||
|
|
||||||
|
**CLI surface**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Push to develop
|
||||||
|
gitlab-sim --branch develop .gitlab-ci.yml
|
||||||
|
|
||||||
|
# Tag push (v1.2.0) — sets CI_COMMIT_TAG and clears CI_COMMIT_BRANCH
|
||||||
|
gitlab-sim --tag v1.2.0 .gitlab-ci.yml
|
||||||
|
|
||||||
|
# Merge request pipeline
|
||||||
|
gitlab-sim --source merge_request_event \
|
||||||
|
--var CI_MERGE_REQUEST_TARGET_BRANCH_NAME=main \
|
||||||
|
.gitlab-ci.yml
|
||||||
|
|
||||||
|
# Explicit variable overrides for anything not covered by the shortcuts
|
||||||
|
gitlab-sim --var CI_COMMIT_BRANCH=feat/my-feature \
|
||||||
|
--var CI_ENVIRONMENT_NAME=staging \
|
||||||
|
.gitlab-ci.yml
|
||||||
|
|
||||||
|
# Simulate multiple contexts in one run (print per-context job tables)
|
||||||
|
gitlab-sim --context branch=main \
|
||||||
|
--context branch=develop \
|
||||||
|
--context tag=v1.0.0 \
|
||||||
|
.gitlab-ci.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
**What context injection enables**
|
||||||
|
|
||||||
|
- Each job is resolved to **active** / **manual** / **skipped** for the given context
|
||||||
|
- Warn when the entire pipeline would produce zero runnable jobs (common mistake when a `workflow:rules:` block is too restrictive)
|
||||||
|
- Lint only the active job subset — skip `needs:` / `dependencies:` cross-checks for jobs that never co-execute in that context
|
||||||
|
- `--context` multi-simulation: print a table showing which jobs activate per context, making it easy to audit Git Flow rules across branches and tags at once
|
||||||
|
|
||||||
|
**Expression evaluator scope**
|
||||||
|
|
||||||
|
GitLab's `rules:if:` expression language will be implemented incrementally:
|
||||||
|
|
||||||
|
| Priority | Operators / features |
|
||||||
|
|----------|----------------------|
|
||||||
|
| 1 (MVP) | `==`, `!=`, `null` check, `&&`, `\|\|`, `!`, parentheses |
|
||||||
|
| 2 | Regex match `=~` / `!~` with `/pattern/` literals |
|
||||||
|
| 3 | `$CI_COMMIT_BRANCH =~ /^feat\//`, anchored patterns |
|
||||||
|
| 4 | `only: branches / tags / merge_requests` shorthand mapping |
|
||||||
|
| 5 | `changes:` path glob evaluation against a real or mock file tree |
|
||||||
|
|
||||||
|
Predefined variables populated automatically from `--branch` / `--tag` / `--source` shortcuts: `CI_COMMIT_BRANCH`, `CI_COMMIT_TAG`, `CI_COMMIT_REF_NAME`, `CI_COMMIT_REF_SLUG`, `CI_PIPELINE_SOURCE`, `CI_DEFAULT_BRANCH` (defaulting to `main`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lint coverage
|
||||||
|
|
||||||
|
The current rule set covers the most common sources of broken pipelines. These are the gaps most likely to matter in practice.
|
||||||
|
|
||||||
|
- **Variable reference validation** — warn when a job references `$VAR` (or `${VAR}`) that is not declared anywhere in `variables:`, `default.variables`, or the job itself
|
||||||
|
- **`services:` validation** — map form requires `name`; `alias` must be a valid DNS label
|
||||||
|
- **`rules:changes` / `rules:exists`** — warn on glob patterns that can never match (e.g. absolute paths, double `**` on unsupported versions)
|
||||||
|
- **`timeout` format** — must be a duration string GitLab understands (`1h 30m`, `90 minutes`, etc.)
|
||||||
|
- **`id_tokens:` / `secrets:`** — presence and required-key checks
|
||||||
|
- **`pages:publish`** — validate that the path is consistent with `artifacts.paths`
|
||||||
|
- **`inherit:` completeness** — flag when a job overrides a default field that would require `inherit: default: false` to suppress
|
||||||
|
- **Unreachable jobs** — detect jobs that can never run because every `rules:` branch evaluates to `never` (static analysis only, no variable expansion)
|
||||||
|
- **Duplicate stage names** — GitLab silently merges them; warn to avoid confusion
|
||||||
|
- **`cache:key:files`** — must be a list of paths, not a glob
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Include resolution
|
||||||
|
|
||||||
|
- **`include: local:`** full resolution — parse and merge locally-referenced YAML files the same way remote project includes are handled; enables cross-file `extends:` and `needs:` validation for monorepo setups
|
||||||
|
- **`include: remote:`** (URL) — fetch and merge plain HTTP/HTTPS URLs (no auth required)
|
||||||
|
- **Recursive include depth limit** — guard against include cycles across files
|
||||||
|
- **Offline mode / cache** — persist fetched remote templates to a local cache directory; `--offline` flag to skip network calls and use only cached copies
|
||||||
|
- **`include: inputs:`** — substitute CI component input values into fetched templates before merging, so component-scoped jobs get their correct `stage:` and keyword values
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Output formats
|
||||||
|
|
||||||
|
Right now the only output is plain-text findings. Structured output enables integration with other tools.
|
||||||
|
|
||||||
|
- **JSON** (`--format json`) — machine-readable findings with file, job, severity, rule ID, and message; stable schema
|
||||||
|
- **SARIF** (`--format sarif`) — [Static Analysis Results Interchange Format](https://sarifweb.azurewebsites.net); consumed natively by GitHub Code Scanning and GitLab SAST
|
||||||
|
- **JUnit XML** (`--format junit`) — lets CI pipelines publish lint results as a test report artifact
|
||||||
|
- **GitHub / GitLab annotation format** — emit `::error file=…,line=…::message` lines so findings appear as inline comments in PR diffs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pipeline graph improvements
|
||||||
|
|
||||||
|
The SVG renderer covers the basic layout. These would bring it closer to GitLab's full interactive view.
|
||||||
|
|
||||||
|
- **Multi-job connector accuracy** — draw one connector per job pair rather than one per stage pair in classic mode, so pipelines with uneven columns look correct
|
||||||
|
- **Job tooltip / detail panel** — embed a hidden `<title>` and `<desc>` per chip so SVG viewers show `stage`, `when`, `image`, and `needs` on hover
|
||||||
|
- **`when: on_failure` visual distinction** — dashed border or distinct icon for failure-path jobs
|
||||||
|
- **Blocked / skipped state colouring** — grey out jobs that are statically unreachable given known `rules:` conditions
|
||||||
|
- **Interactive HTML output** — self-contained `.html` file with pan/zoom and a job-detail sidebar; no external dependencies
|
||||||
|
- **Mermaid pipeline output** — keep `pipeline.go` but wire it up through `--graph pipeline --format mermaid` for users who want to paste into mermaid.live
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CI / editor integration
|
||||||
|
|
||||||
|
- **GitLab CI template** — a `.gitlab-ci.yml` snippet that runs `gitlab-sim` as a pipeline-validation job before the real pipeline executes; publishable to the GitLab CI/CD Catalog
|
||||||
|
- **GitHub Actions action** — `uses: k3nny/gitlab-sim@v1` wrapper for repositories that mirror or manage GitLab pipelines from GitHub
|
||||||
|
- **Pre-commit hook** — entry for [pre-commit](https://pre-commit.com) so `gitlab-sim` runs automatically on `git commit` when `.gitlab-ci.yml` changes
|
||||||
|
- **LSP server** — `gitlab-sim lsp` mode exposing diagnostics over the Language Server Protocol; enables inline squiggles in VS Code, JetBrains, Neovim, etc. without a dedicated extension
|
||||||
|
- **VS Code extension** — thin wrapper around the LSP server with syntax highlighting for `.gitlab-ci.yml`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
- **`.gitlab-sim.yml` config file** — project-level configuration for:
|
||||||
|
- Rule suppression by rule ID (e.g. `ignore: [no-only, missing-stages]`)
|
||||||
|
- Severity overrides (demote specific errors to warnings)
|
||||||
|
- Custom `stages` allowlist for projects that use a non-standard default set
|
||||||
|
- Token and URL defaults so flags are not needed in every invocation
|
||||||
|
- **Inline suppression comments** — `# gitlab-sim: ignore next-line <rule-id>` in the pipeline YAML
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reliability and developer experience
|
||||||
|
|
||||||
|
- **Structured rule IDs** — assign a stable short ID to every rule (e.g. `GS001`) so suppression, documentation, and SARIF output are stable across versions
|
||||||
|
- **`--explain <rule-id>`** — print the rule description, rationale, and an example fix
|
||||||
|
- ~~**Semantic versioning and first release**~~ — shipped as `v0.1.0` (2026-06-07)
|
||||||
|
- **Changelog automation** — generate release notes from Conventional Commits via `git-cliff` or similar
|
||||||
|
- **Fuzz testing** — add a `go test -fuzz` target for the YAML parser to harden it against malformed input
|
||||||
@@ -45,6 +45,16 @@ tasks:
|
|||||||
ignore_error: false
|
ignore_error: false
|
||||||
- cmd: ./{{.BINARY}} testdata/includes_component.yml
|
- cmd: ./{{.BINARY}} testdata/includes_component.yml
|
||||||
ignore_error: false
|
ignore_error: false
|
||||||
|
- cmd: ./{{.BINARY}} testdata/context_rules.yml
|
||||||
|
ignore_error: false
|
||||||
|
- cmd: ./{{.BINARY}} --branch main testdata/context_rules.yml
|
||||||
|
ignore_error: false
|
||||||
|
- cmd: ./{{.BINARY}} --branch develop testdata/context_rules.yml
|
||||||
|
ignore_error: false
|
||||||
|
- cmd: ./{{.BINARY}} --branch feat/my-feature testdata/context_rules.yml
|
||||||
|
ignore_error: false
|
||||||
|
- cmd: ./{{.BINARY}} --tag v1.0.0 testdata/context_rules.yml
|
||||||
|
ignore_error: false
|
||||||
|
|
||||||
lint-go:
|
lint-go:
|
||||||
desc: Run go vet on all packages
|
desc: Run go vet on all packages
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package cicontext
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// Context holds the simulated CI execution environment used for context-aware
|
||||||
|
// pipeline evaluation (rules:if:, only:, except:, workflow:rules:).
|
||||||
|
// Variables are keyed by their name without the leading $.
|
||||||
|
type Context struct {
|
||||||
|
Vars map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
// New builds a Context from high-level shortcut values and optional KEY=VALUE
|
||||||
|
// overrides. Predefined CI variables are derived from the shortcuts so callers
|
||||||
|
// do not need to know their exact names.
|
||||||
|
//
|
||||||
|
// Returns an empty Context (IsEmpty() == true) when all inputs are zero values,
|
||||||
|
// preserving the existing linting behaviour when no context flags are given.
|
||||||
|
//
|
||||||
|
// Override priority (highest wins): extraVars > branch/tag/source shortcuts.
|
||||||
|
func New(branch, tag, source string, extraVars []string) *Context {
|
||||||
|
if branch == "" && tag == "" && source == "" && len(extraVars) == 0 {
|
||||||
|
return &Context{}
|
||||||
|
}
|
||||||
|
|
||||||
|
vars := make(map[string]string)
|
||||||
|
|
||||||
|
if branch != "" {
|
||||||
|
vars["CI_COMMIT_BRANCH"] = branch
|
||||||
|
vars["CI_COMMIT_REF_NAME"] = branch
|
||||||
|
vars["CI_COMMIT_REF_SLUG"] = slugify(branch)
|
||||||
|
if source == "" {
|
||||||
|
source = "push"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tag != "" {
|
||||||
|
vars["CI_COMMIT_TAG"] = tag
|
||||||
|
vars["CI_COMMIT_REF_NAME"] = tag
|
||||||
|
vars["CI_COMMIT_REF_SLUG"] = slugify(tag)
|
||||||
|
delete(vars, "CI_COMMIT_BRANCH") // tag pushes have no branch variable
|
||||||
|
if source == "" {
|
||||||
|
source = "push"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if source != "" {
|
||||||
|
vars["CI_PIPELINE_SOURCE"] = source
|
||||||
|
}
|
||||||
|
if _, ok := vars["CI_DEFAULT_BRANCH"]; !ok {
|
||||||
|
vars["CI_DEFAULT_BRANCH"] = "main"
|
||||||
|
}
|
||||||
|
|
||||||
|
// KEY=VALUE overrides win over shortcuts.
|
||||||
|
for _, kv := range extraVars {
|
||||||
|
k, v, ok := strings.Cut(kv, "=")
|
||||||
|
if ok {
|
||||||
|
vars[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Context{Vars: vars}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsEmpty reports whether no variables have been set (no context flags given).
|
||||||
|
func (c *Context) IsEmpty() bool {
|
||||||
|
return c == nil || len(c.Vars) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns the value of a CI variable (key without the leading $).
|
||||||
|
// Returns an empty string when the variable is not defined.
|
||||||
|
func (c *Context) Get(key string) string {
|
||||||
|
if c == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return c.Vars[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary returns a short human-readable description of the context for CLI output.
|
||||||
|
func (c *Context) Summary() string {
|
||||||
|
if c.IsEmpty() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var parts []string
|
||||||
|
if v := c.Get("CI_COMMIT_TAG"); v != "" {
|
||||||
|
parts = append(parts, "tag="+v)
|
||||||
|
} else if v := c.Get("CI_COMMIT_BRANCH"); v != "" {
|
||||||
|
parts = append(parts, "branch="+v)
|
||||||
|
}
|
||||||
|
if v := c.Get("CI_PIPELINE_SOURCE"); v != "" {
|
||||||
|
parts = append(parts, "source="+v)
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// slugify converts a ref name to its GitLab slug form:
|
||||||
|
// lowercased, non-alphanumeric characters replaced with '-', leading/trailing '-' removed.
|
||||||
|
func slugify(s string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range strings.ToLower(s) {
|
||||||
|
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
||||||
|
b.WriteRune(r)
|
||||||
|
} else {
|
||||||
|
b.WriteByte('-')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Trim(b.String(), "-")
|
||||||
|
}
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
package cicontext
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EvalIf evaluates a GitLab CI rules:if: expression string against the provided
|
||||||
|
// variable resolver.
|
||||||
|
//
|
||||||
|
// Supported:
|
||||||
|
// - Variable references: $VAR_NAME
|
||||||
|
// - String literals: "value" or 'value'
|
||||||
|
// - Null keyword: null
|
||||||
|
// - Comparison: == != =~ !~
|
||||||
|
// - Boolean: && || !
|
||||||
|
// - Grouping: ( )
|
||||||
|
//
|
||||||
|
// Regex patterns use Go's regexp syntax, which covers the common RE2 subset
|
||||||
|
// used by GitLab CI. Unsupported or unparseable expressions fall back to true
|
||||||
|
// (permissive) so the linter never silently drops jobs it cannot evaluate.
|
||||||
|
func EvalIf(expr string, vars func(string) string) bool {
|
||||||
|
p := &exprParser{s: strings.TrimSpace(expr), vars: vars}
|
||||||
|
result, ok := p.parseOr()
|
||||||
|
if !ok || p.pos < len(p.s) {
|
||||||
|
return true // unparseable → permissive
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Parser ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type exprParser struct {
|
||||||
|
s string
|
||||||
|
pos int
|
||||||
|
vars func(string) string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *exprParser) peek() byte {
|
||||||
|
if p.pos >= len(p.s) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return p.s[p.pos]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *exprParser) startsWith(tok string) bool {
|
||||||
|
return strings.HasPrefix(p.s[p.pos:], tok)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *exprParser) consume(tok string) bool {
|
||||||
|
if p.startsWith(tok) {
|
||||||
|
p.pos += len(tok)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *exprParser) skipWS() {
|
||||||
|
for p.pos < len(p.s) && (p.s[p.pos] == ' ' || p.s[p.pos] == '\t') {
|
||||||
|
p.pos++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Grammar (recursive descent) ───────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// or_expr → and_expr ( '||' and_expr )*
|
||||||
|
// and_expr → not_expr ( '&&' not_expr )*
|
||||||
|
// not_expr → '!' not_expr | primary
|
||||||
|
// primary → '(' or_expr ')' | comparison
|
||||||
|
// comparison → value ( op value | regex_op regex )? | value
|
||||||
|
// value → '$' ident | '"' … '"' | "'" … "'" | 'null'
|
||||||
|
// op → '==' | '!='
|
||||||
|
// regex_op → '=~' | '!~'
|
||||||
|
// regex → '/' … '/'
|
||||||
|
|
||||||
|
func (p *exprParser) parseOr() (bool, bool) {
|
||||||
|
left, ok := p.parseAnd()
|
||||||
|
if !ok {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
p.skipWS()
|
||||||
|
if !p.consume("||") {
|
||||||
|
return left, true
|
||||||
|
}
|
||||||
|
right, ok := p.parseAnd()
|
||||||
|
if !ok {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
left = left || right
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *exprParser) parseAnd() (bool, bool) {
|
||||||
|
left, ok := p.parseNot()
|
||||||
|
if !ok {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
p.skipWS()
|
||||||
|
if !p.consume("&&") {
|
||||||
|
return left, true
|
||||||
|
}
|
||||||
|
right, ok := p.parseNot()
|
||||||
|
if !ok {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
left = left && right
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *exprParser) parseNot() (bool, bool) {
|
||||||
|
p.skipWS()
|
||||||
|
// '!' is logical NOT only when not followed by '=' (which would be '!=').
|
||||||
|
if p.peek() == '!' && !p.startsWith("!=") {
|
||||||
|
p.pos++
|
||||||
|
val, ok := p.parseNot() // right-associative: !!x == x
|
||||||
|
return !val, ok
|
||||||
|
}
|
||||||
|
return p.parsePrimary()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *exprParser) parsePrimary() (bool, bool) {
|
||||||
|
p.skipWS()
|
||||||
|
if p.peek() == '(' {
|
||||||
|
p.pos++ // consume '('
|
||||||
|
val, ok := p.parseOr()
|
||||||
|
if !ok {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
p.skipWS()
|
||||||
|
if p.peek() != ')' {
|
||||||
|
return false, false // unmatched parenthesis
|
||||||
|
}
|
||||||
|
p.pos++ // consume ')'
|
||||||
|
return val, true
|
||||||
|
}
|
||||||
|
return p.parseComparison()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *exprParser) parseComparison() (bool, bool) {
|
||||||
|
p.skipWS()
|
||||||
|
leftStr, ok := p.parseValue()
|
||||||
|
if !ok {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
p.skipWS()
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case p.consume("=="):
|
||||||
|
p.skipWS()
|
||||||
|
rightStr, ok := p.parseValue()
|
||||||
|
if !ok {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
return leftStr == rightStr, true
|
||||||
|
|
||||||
|
case p.consume("!="):
|
||||||
|
p.skipWS()
|
||||||
|
rightStr, ok := p.parseValue()
|
||||||
|
if !ok {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
return leftStr != rightStr, true
|
||||||
|
|
||||||
|
case p.consume("=~"):
|
||||||
|
p.skipWS()
|
||||||
|
pat, ok := p.parseRegexLiteral()
|
||||||
|
if !ok {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
re, err := regexp.Compile(pat)
|
||||||
|
if err != nil {
|
||||||
|
return true, true // bad pattern → permissive
|
||||||
|
}
|
||||||
|
return re.MatchString(leftStr), true
|
||||||
|
|
||||||
|
case p.consume("!~"):
|
||||||
|
p.skipWS()
|
||||||
|
pat, ok := p.parseRegexLiteral()
|
||||||
|
if !ok {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
re, err := regexp.Compile(pat)
|
||||||
|
if err != nil {
|
||||||
|
return true, true // bad pattern → permissive
|
||||||
|
}
|
||||||
|
return !re.MatchString(leftStr), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// No operator: variable is truthy when non-empty (defined and non-null).
|
||||||
|
return leftStr != "", true
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseValue reads $VAR, "string", 'string', or null.
|
||||||
|
// null and undefined variables both produce an empty string.
|
||||||
|
func (p *exprParser) parseValue() (string, bool) {
|
||||||
|
p.skipWS()
|
||||||
|
|
||||||
|
if p.peek() == '$' {
|
||||||
|
p.pos++ // consume '$'
|
||||||
|
name := p.parseIdent()
|
||||||
|
if name == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return p.vars(name), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// null keyword — must not be a prefix of a longer identifier.
|
||||||
|
if p.startsWith("null") {
|
||||||
|
end := p.pos + 4
|
||||||
|
if end >= len(p.s) || !isIdentByte(p.s[end]) {
|
||||||
|
p.pos += 4
|
||||||
|
return "", true // null → empty string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.peek() == '"' || p.peek() == '\'' {
|
||||||
|
return p.parseStringLiteral()
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *exprParser) parseIdent() string {
|
||||||
|
start := p.pos
|
||||||
|
for p.pos < len(p.s) && isIdentByte(p.s[p.pos]) {
|
||||||
|
p.pos++
|
||||||
|
}
|
||||||
|
return p.s[start:p.pos]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *exprParser) parseStringLiteral() (string, bool) {
|
||||||
|
quote := p.s[p.pos]
|
||||||
|
p.pos++ // consume opening quote
|
||||||
|
var sb strings.Builder
|
||||||
|
for p.pos < len(p.s) {
|
||||||
|
b := p.s[p.pos]
|
||||||
|
if b == quote {
|
||||||
|
p.pos++ // consume closing quote
|
||||||
|
return sb.String(), true
|
||||||
|
}
|
||||||
|
if b == '\\' && p.pos+1 < len(p.s) {
|
||||||
|
p.pos++
|
||||||
|
sb.WriteByte(p.s[p.pos])
|
||||||
|
} else {
|
||||||
|
sb.WriteByte(b)
|
||||||
|
}
|
||||||
|
p.pos++
|
||||||
|
}
|
||||||
|
return "", false // unterminated string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *exprParser) parseRegexLiteral() (string, bool) {
|
||||||
|
if p.peek() != '/' {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
p.pos++ // consume opening '/'
|
||||||
|
var sb strings.Builder
|
||||||
|
for p.pos < len(p.s) {
|
||||||
|
b := p.s[p.pos]
|
||||||
|
if b == '/' {
|
||||||
|
p.pos++ // consume closing '/'
|
||||||
|
return sb.String(), true
|
||||||
|
}
|
||||||
|
if b == '\\' && p.pos+1 < len(p.s) {
|
||||||
|
p.pos++
|
||||||
|
sb.WriteByte('\\')
|
||||||
|
sb.WriteByte(p.s[p.pos])
|
||||||
|
} else {
|
||||||
|
sb.WriteByte(b)
|
||||||
|
}
|
||||||
|
p.pos++
|
||||||
|
}
|
||||||
|
return "", false // unterminated regex
|
||||||
|
}
|
||||||
|
|
||||||
|
func isIdentByte(b byte) bool {
|
||||||
|
return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') || b == '_'
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package cicontext
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestEvalIf(t *testing.T) {
|
||||||
|
vars := func(key string) string {
|
||||||
|
m := map[string]string{
|
||||||
|
"CI_COMMIT_BRANCH": "develop",
|
||||||
|
"CI_COMMIT_TAG": "",
|
||||||
|
"CI_PIPELINE_SOURCE": "push",
|
||||||
|
"DEPLOY_ENV": "staging",
|
||||||
|
}
|
||||||
|
return m[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
expr string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
// ── Equality ──────────────────────────────────────────────────────────
|
||||||
|
{"eq match", `$CI_COMMIT_BRANCH == "develop"`, true},
|
||||||
|
{"eq no match", `$CI_COMMIT_BRANCH == "main"`, false},
|
||||||
|
{"eq single-quote", `$CI_COMMIT_BRANCH == 'develop'`, true},
|
||||||
|
{"neq match", `$CI_COMMIT_BRANCH != "main"`, true},
|
||||||
|
{"neq no match", `$CI_COMMIT_BRANCH != "develop"`, false},
|
||||||
|
|
||||||
|
// ── Null checks ───────────────────────────────────────────────────────
|
||||||
|
{"tag undefined eq null", `$CI_COMMIT_TAG == null`, true},
|
||||||
|
{"tag undefined neq null", `$CI_COMMIT_TAG != null`, false},
|
||||||
|
{"branch defined neq null", `$CI_COMMIT_BRANCH != null`, true},
|
||||||
|
{"branch defined eq null", `$CI_COMMIT_BRANCH == null`, false},
|
||||||
|
{"null eq null", `null == null`, true},
|
||||||
|
|
||||||
|
// ── Variable truthiness (no operator) ────────────────────────────────
|
||||||
|
{"truthy branch", `$CI_COMMIT_BRANCH`, true},
|
||||||
|
{"falsy tag", `$CI_COMMIT_TAG`, false},
|
||||||
|
|
||||||
|
// ── Logical NOT ───────────────────────────────────────────────────────
|
||||||
|
{"not false", `!$CI_COMMIT_TAG`, true},
|
||||||
|
{"not true", `!$CI_COMMIT_BRANCH`, false},
|
||||||
|
{"double not", `!!$CI_COMMIT_BRANCH`, true},
|
||||||
|
|
||||||
|
// ── Boolean AND ───────────────────────────────────────────────────────
|
||||||
|
{"and both true", `$CI_COMMIT_BRANCH == "develop" && $CI_PIPELINE_SOURCE == "push"`, true},
|
||||||
|
{"and left false", `$CI_COMMIT_BRANCH == "main" && $CI_PIPELINE_SOURCE == "push"`, false},
|
||||||
|
{"and right false", `$CI_COMMIT_BRANCH == "develop" && $CI_PIPELINE_SOURCE == "schedule"`, false},
|
||||||
|
{"and both false", `$CI_COMMIT_BRANCH == "main" && $CI_PIPELINE_SOURCE == "schedule"`, false},
|
||||||
|
|
||||||
|
// ── Boolean OR ────────────────────────────────────────────────────────
|
||||||
|
{"or both true", `$CI_COMMIT_BRANCH == "develop" || $CI_COMMIT_BRANCH == "main"`, true},
|
||||||
|
{"or left true", `$CI_COMMIT_BRANCH == "develop" || $CI_COMMIT_BRANCH == "nope"`, true},
|
||||||
|
{"or right true", `$CI_COMMIT_BRANCH == "nope" || $CI_COMMIT_BRANCH == "develop"`, true},
|
||||||
|
{"or both false", `$CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "nope"`, false},
|
||||||
|
|
||||||
|
// ── Parentheses ───────────────────────────────────────────────────────
|
||||||
|
{"paren or+and", `($CI_COMMIT_BRANCH == "develop" || $CI_COMMIT_BRANCH == "main") && $CI_COMMIT_TAG == null`, true},
|
||||||
|
{"paren or+and false", `($CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "nope") && $CI_COMMIT_TAG == null`, false},
|
||||||
|
|
||||||
|
// ── Regex match ───────────────────────────────────────────────────────
|
||||||
|
{"regex match", `$CI_COMMIT_BRANCH =~ /^dev/`, true},
|
||||||
|
{"regex no match", `$CI_COMMIT_BRANCH =~ /^main/`, false},
|
||||||
|
{"regex not match", `$CI_COMMIT_BRANCH !~ /^main/`, true},
|
||||||
|
{"regex not no match", `$CI_COMMIT_BRANCH !~ /^dev/`, false},
|
||||||
|
{"regex case sensitive", `$CI_COMMIT_BRANCH =~ /^DEV/`, false},
|
||||||
|
{"regex feat branch", `$CI_COMMIT_BRANCH =~ /^feat\//`, false},
|
||||||
|
|
||||||
|
// ── Extra whitespace ──────────────────────────────────────────────────
|
||||||
|
{"extra spaces", ` $CI_COMMIT_BRANCH == "develop" `, true},
|
||||||
|
{"tabs", "$CI_COMMIT_BRANCH\t==\t\"develop\"", true},
|
||||||
|
|
||||||
|
// ── Permissive fallback ───────────────────────────────────────────────
|
||||||
|
{"unparseable returns true", `this is not valid syntax %%%`, true},
|
||||||
|
{"empty expr returns true", ``, true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := EvalIf(tc.expr, vars)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("EvalIf(%q) = %v, want %v", tc.expr, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
package cicontext
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.k3nny.fr/gitlab-sim/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// JobState describes whether a job would be included in a pipeline run for the
|
||||||
|
// given context.
|
||||||
|
type JobState int
|
||||||
|
|
||||||
|
const (
|
||||||
|
JobActive JobState = iota // job will run (on_success, always, delayed, …)
|
||||||
|
JobManual // job is gated behind a manual trigger
|
||||||
|
JobSkipped // job is excluded from the pipeline (when:never or no rule matched)
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s JobState) String() string {
|
||||||
|
switch s {
|
||||||
|
case JobActive:
|
||||||
|
return "active"
|
||||||
|
case JobManual:
|
||||||
|
return "manual"
|
||||||
|
default:
|
||||||
|
return "skipped"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// EvalWorkflow returns false when the pipeline's workflow:rules block would
|
||||||
|
// prevent any pipeline from starting in the given context.
|
||||||
|
// Returns true when ctx is empty, when there is no workflow block, or when no
|
||||||
|
// rule is configured.
|
||||||
|
func EvalWorkflow(p *model.Pipeline, ctx *Context) bool {
|
||||||
|
if ctx.IsEmpty() || p.Workflow == nil || len(p.Workflow.Rules) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
vars := ctx.Get
|
||||||
|
for _, rule := range p.Workflow.Rules {
|
||||||
|
if !ruleIfMatches(rule.If, vars) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
when := rule.When
|
||||||
|
if when == "" {
|
||||||
|
when = "always"
|
||||||
|
}
|
||||||
|
return when != "never"
|
||||||
|
}
|
||||||
|
return false // no rule matched → pipeline does not run
|
||||||
|
}
|
||||||
|
|
||||||
|
// EvalJob returns the effective JobState for job in the given context.
|
||||||
|
// When ctx is empty every job is treated as Active (preserves existing
|
||||||
|
// behaviour when no context flags are given).
|
||||||
|
func EvalJob(job model.Job, ctx *Context) JobState {
|
||||||
|
if ctx.IsEmpty() {
|
||||||
|
return JobActive
|
||||||
|
}
|
||||||
|
|
||||||
|
vars := ctx.Get
|
||||||
|
|
||||||
|
// rules: takes priority over only/except.
|
||||||
|
if len(job.Rules) > 0 {
|
||||||
|
for _, rule := range job.Rules {
|
||||||
|
if !ruleIfMatches(rule.If, vars) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return whenToState(rule.When)
|
||||||
|
}
|
||||||
|
return JobSkipped // no rule matched → job is excluded
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy only:/except: filtering.
|
||||||
|
if job.Only != nil || job.Except != nil {
|
||||||
|
if !onlyMatches(job.Only, ctx) || exceptMatches(job.Except, ctx) {
|
||||||
|
return JobSkipped
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return whenToState(job.When)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func ruleIfMatches(ifExpr string, vars func(string) string) bool {
|
||||||
|
if ifExpr == "" {
|
||||||
|
return true // no if: condition → rule always matches
|
||||||
|
}
|
||||||
|
return EvalIf(ifExpr, vars)
|
||||||
|
}
|
||||||
|
|
||||||
|
func whenToState(when string) JobState {
|
||||||
|
switch when {
|
||||||
|
case "never":
|
||||||
|
return JobSkipped
|
||||||
|
case "manual":
|
||||||
|
return JobManual
|
||||||
|
default:
|
||||||
|
// on_success, always, delayed, "" (default) → active
|
||||||
|
return JobActive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// onlyMatches returns true when the job's only: filter is satisfied by ctx.
|
||||||
|
func onlyMatches(only any, ctx *Context) bool {
|
||||||
|
if only == nil {
|
||||||
|
return true // no only → runs everywhere
|
||||||
|
}
|
||||||
|
refs := extractRefList(only)
|
||||||
|
if refs == nil {
|
||||||
|
return true // unrecognised form → permissive
|
||||||
|
}
|
||||||
|
return refListMatches(refs, ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// exceptMatches returns true when the job's except: filter excludes the job.
|
||||||
|
func exceptMatches(except any, ctx *Context) bool {
|
||||||
|
if except == nil {
|
||||||
|
return false // no except → nothing excluded
|
||||||
|
}
|
||||||
|
refs := extractRefList(except)
|
||||||
|
if refs == nil {
|
||||||
|
return false // unrecognised form → don't exclude
|
||||||
|
}
|
||||||
|
return refListMatches(refs, ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractRefList normalises the only:/except: value into a flat []string.
|
||||||
|
// Returns nil for forms that cannot be statically evaluated.
|
||||||
|
func extractRefList(v any) []string {
|
||||||
|
switch x := v.(type) {
|
||||||
|
case string:
|
||||||
|
return []string{x}
|
||||||
|
case []any:
|
||||||
|
var refs []string
|
||||||
|
for _, item := range x {
|
||||||
|
if s, ok := item.(string); ok {
|
||||||
|
refs = append(refs, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return refs
|
||||||
|
case map[string]any:
|
||||||
|
// Map form may carry { refs: [...], variables: [...], kubernetes: ... }.
|
||||||
|
// We only evaluate the refs list; skip anything more complex.
|
||||||
|
if raw, ok := x["refs"]; ok {
|
||||||
|
return extractRefList(raw)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// refListMatches returns true when at least one entry in refs matches ctx.
|
||||||
|
func refListMatches(refs []string, ctx *Context) bool {
|
||||||
|
branch := ctx.Get("CI_COMMIT_BRANCH")
|
||||||
|
tag := ctx.Get("CI_COMMIT_TAG")
|
||||||
|
source := ctx.Get("CI_PIPELINE_SOURCE")
|
||||||
|
|
||||||
|
for _, ref := range refs {
|
||||||
|
switch ref {
|
||||||
|
case "branches":
|
||||||
|
if branch != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
case "tags":
|
||||||
|
if tag != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
case "merge_requests":
|
||||||
|
if source == "merge_request_event" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
case "schedules":
|
||||||
|
if source == "schedule" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
case "pipelines":
|
||||||
|
if source == "pipeline" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
case "pushes":
|
||||||
|
if source == "push" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
case "web":
|
||||||
|
if source == "web" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
case "api":
|
||||||
|
if source == "api" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// Branch name glob or /regex/ pattern.
|
||||||
|
if refPatternMatches(ref, branch) || refPatternMatches(ref, tag) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func refPatternMatches(pattern, ref string) bool {
|
||||||
|
if ref == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(pattern, "/") && strings.HasSuffix(pattern, "/") {
|
||||||
|
re, err := regexp.Compile(pattern[1 : len(pattern)-1])
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return re.MatchString(ref)
|
||||||
|
}
|
||||||
|
return globMatches(pattern, ref)
|
||||||
|
}
|
||||||
|
|
||||||
|
// globMatches supports the simple '*' wildcard used in GitLab only/except refs.
|
||||||
|
func globMatches(pattern, s string) bool {
|
||||||
|
if !strings.Contains(pattern, "*") {
|
||||||
|
return pattern == s
|
||||||
|
}
|
||||||
|
// Recursive match: consume pattern character by character.
|
||||||
|
return matchGlob(pattern, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchGlob(pattern, s string) bool {
|
||||||
|
for len(pattern) > 0 {
|
||||||
|
if pattern[0] == '*' {
|
||||||
|
pattern = pattern[1:]
|
||||||
|
if len(pattern) == 0 {
|
||||||
|
return true // trailing '*' matches everything
|
||||||
|
}
|
||||||
|
// Try matching the rest of the pattern at every position in s.
|
||||||
|
for i := range s {
|
||||||
|
if matchGlob(pattern, s[i:]) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(s) == 0 || pattern[0] != s[0] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
pattern = pattern[1:]
|
||||||
|
s = s[1:]
|
||||||
|
}
|
||||||
|
return len(s) == 0
|
||||||
|
}
|
||||||
Vendored
+58
@@ -0,0 +1,58 @@
|
|||||||
|
# context_rules.yml
|
||||||
|
# Pipeline exercising context-aware rules evaluation.
|
||||||
|
# Expected behaviour per context:
|
||||||
|
# (no context) → lints clean, all jobs visible
|
||||||
|
# --branch main → build, test, deploy-prod active; deploy-staging/release-notes skipped
|
||||||
|
# --branch develop → build, test, deploy-staging active; deploy-prod/release-notes skipped
|
||||||
|
# --branch feat/my-feat → build, test active; deploy-staging manual; deploy-prod/release-notes skipped
|
||||||
|
# --tag v1.0.0 → build, test, deploy-prod, release-notes active; deploy-staging skipped
|
||||||
|
|
||||||
|
stages:
|
||||||
|
- build
|
||||||
|
- test
|
||||||
|
- deploy
|
||||||
|
|
||||||
|
build:
|
||||||
|
stage: build
|
||||||
|
script:
|
||||||
|
- make build
|
||||||
|
rules:
|
||||||
|
- when: always
|
||||||
|
|
||||||
|
test:
|
||||||
|
stage: test
|
||||||
|
script:
|
||||||
|
- make test
|
||||||
|
rules:
|
||||||
|
- when: always
|
||||||
|
|
||||||
|
deploy-staging:
|
||||||
|
stage: deploy
|
||||||
|
script:
|
||||||
|
- make deploy ENV=staging
|
||||||
|
rules:
|
||||||
|
- if: $CI_COMMIT_BRANCH == "develop"
|
||||||
|
when: on_success
|
||||||
|
- if: $CI_COMMIT_BRANCH =~ /^feat\//
|
||||||
|
when: manual
|
||||||
|
- when: never
|
||||||
|
|
||||||
|
deploy-prod:
|
||||||
|
stage: deploy
|
||||||
|
script:
|
||||||
|
- make deploy ENV=production
|
||||||
|
rules:
|
||||||
|
- if: $CI_COMMIT_BRANCH == "main"
|
||||||
|
when: on_success
|
||||||
|
- if: $CI_COMMIT_TAG != null
|
||||||
|
when: on_success
|
||||||
|
- when: never
|
||||||
|
|
||||||
|
release-notes:
|
||||||
|
stage: deploy
|
||||||
|
script:
|
||||||
|
- make release
|
||||||
|
rules:
|
||||||
|
- if: $CI_COMMIT_TAG != null
|
||||||
|
when: on_success
|
||||||
|
- when: never
|
||||||
Reference in New Issue
Block a user