From 583eac906160e8cc0515740d8e6148c9c3e625b7 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 3 Oct 2026 23:29:19 +0300 Subject: [PATCH] feat: add a dry-run input to the GitLab CI component --- docs/providers/gitlab.md | 16 ++++++++++++ templates/semvertag.yml | 6 ++++- tests/_descriptor_gate.py | 26 +++++++++++++++++- tests/test_ci_descriptor_gate.py | 45 ++++++++++++++++++++++++++++++++ 4 files changed, 91 insertions(+), 2 deletions(-) diff --git a/docs/providers/gitlab.md b/docs/providers/gitlab.md index 25825bf..3504aa9 100644 --- a/docs/providers/gitlab.md +++ b/docs/providers/gitlab.md @@ -68,6 +68,22 @@ block on the `include:`. The values and default match [`templates/semvertag.yml`](https://github.com/modern-python/semvertag/blob/main/templates/semvertag.yml)'s `spec.inputs.strategy` so the migration is a snippet swap. +## Dry run + +Pass `--dry-run` to compute the bump and report the planned tag +without pushing it. It needs semvertag 0.5.0 or later, so raise the +floor in the job: + +```yaml +semvertag: + # ... + script: + - uvx 'semvertag>=0.5.0,<1' tag --dry-run +``` + +The Catalog component exposes this as a boolean `dry-run` input, +`false` by default. + ## Required permissions diff --git a/templates/semvertag.yml b/templates/semvertag.yml index e84f534..40bbc96 100644 --- a/templates/semvertag.yml +++ b/templates/semvertag.yml @@ -5,6 +5,10 @@ spec: type: string options: [branch-prefix, conventional-commits] default: branch-prefix + dry-run: + description: 'If true, compute the bump and report the planned tag but do not push it.' + type: boolean + default: false --- semvertag: image: python:3.13-slim@sha256:b04b5d7233d2ad9c379e22ea8927cd1378cd15c60d4ef876c065b25ea8fb3bf3 @@ -14,4 +18,4 @@ semvertag: before_script: - pip install --quiet --no-cache-dir 'uv>=0.4,<1' script: - - uvx 'semvertag>=0.1,<1' tag + - uvx 'semvertag>=0.5.0,<1' tag $(if $[[ inputs.dry-run ]]; then echo --dry-run; fi) diff --git a/tests/_descriptor_gate.py b/tests/_descriptor_gate.py index 3b325b7..981cb27 100644 --- a/tests/_descriptor_gate.py +++ b/tests/_descriptor_gate.py @@ -1,6 +1,7 @@ """Structural validator for templates/semvertag.yml, run by test_ci_descriptor_gate.py.""" import pathlib +import re import typing import yaml @@ -9,6 +10,10 @@ _DIGEST_MARKER: typing.Final = "@sha256:" _VERSION_MARKER: typing.Final = "@v" _SUBSTITUTION_LITERAL: typing.Final = "$[[ inputs.strategy ]]" +_DRY_RUN_LITERAL: typing.Final = "$[[ inputs.dry-run ]]" +_DRY_RUN_FLAG: typing.Final = "--dry-run" +_SEMVERTAG_FLOOR_PATTERN: typing.Final = re.compile(r"semvertag(?:>=|==|@v)(\d+(?:\.\d+)*)") +_MIN_SEMVERTAG: typing.Final = (0, 5, 0) _EXPECTED_OPTIONS: typing.Final = {"branch-prefix", "conventional-commits"} _EXPECTED_DOC_COUNT: typing.Final = 2 @@ -43,7 +48,10 @@ def validate(path: str) -> None: inputs = spec["spec"]["inputs"] _require(isinstance(inputs, dict), f"spec.inputs must be a mapping, got {type(inputs).__name__}") - _require(set(inputs) == {"strategy"}, f"expected inputs={{strategy}}, got {sorted(inputs)}") + _require( + set(inputs) == {"strategy", "dry-run"}, + f"expected inputs={{dry-run, strategy}}, got {sorted(inputs)}", + ) s = inputs["strategy"] _require(isinstance(s, dict), "spec.inputs.strategy must be a mapping") @@ -57,6 +65,11 @@ def validate(path: str) -> None: f"spec.inputs.strategy.options must equal {sorted(_EXPECTED_OPTIONS)}, got {sorted(s.get('options', []))}", ) + d = inputs["dry-run"] + _require(isinstance(d, dict), "spec.inputs.dry-run must be a mapping") + _require(d.get("type") == "boolean", f"spec.inputs.dry-run.type must be 'boolean', got {d.get('type')!r}") + _require(d.get("default") is False, f"spec.inputs.dry-run.default must be False, got {d.get('default')!r}") + _require(isinstance(body, dict), f"second doc must be a mapping, got {type(body).__name__}") _require("semvertag" in body, "job 'semvertag' missing from body") @@ -99,3 +112,14 @@ def validate(path: str) -> None: ">=" in first or "==" in first or _VERSION_MARKER in first, f"job.script[0] must pin the semvertag version (contain '>=', '==', or '{_VERSION_MARKER}'), got {first!r}", ) + _require( + _DRY_RUN_LITERAL in first and _DRY_RUN_FLAG in first, + f"job.script[0] must map inputs.dry-run onto {_DRY_RUN_FLAG} (contain {_DRY_RUN_LITERAL!r}), got {first!r}", + ) + floor = _SEMVERTAG_FLOOR_PATTERN.search(first) + parts = tuple(int(n) for n in floor.group(1).split(".")) if floor else () + _require( + parts + (0,) * (len(_MIN_SEMVERTAG) - len(parts)) >= _MIN_SEMVERTAG, + f"job.script[0] semvertag floor must be >= {'.'.join(map(str, _MIN_SEMVERTAG))} " + f"(first release with {_DRY_RUN_FLAG}), got {first!r}", + ) diff --git a/tests/test_ci_descriptor_gate.py b/tests/test_ci_descriptor_gate.py index 5adf551..1f8762d 100644 --- a/tests/test_ci_descriptor_gate.py +++ b/tests/test_ci_descriptor_gate.py @@ -114,6 +114,51 @@ def test_unpinned_uv_fails(tmp_path: pathlib.Path, shipped_descriptor_docs: list validate(str(bad)) +def test_missing_dry_run_input_fails(tmp_path: pathlib.Path, shipped_descriptor_docs: list[typing.Any]) -> None: + """Negative: drop spec.inputs.dry-run → gate raises (GitLab/GitHub wrapper parity).""" + spec, body = shipped_descriptor_docs + del spec["spec"]["inputs"]["dry-run"] + bad = _write_descriptor(tmp_path, [spec, body]) + with pytest.raises(DescriptorGateError, match=r"expected inputs=\{dry-run, strategy\}"): + validate(str(bad)) + + +def test_string_typed_dry_run_fails(tmp_path: pathlib.Path, shipped_descriptor_docs: list[typing.Any]) -> None: + """Negative: GitHub-style string dry-run input → gate raises (the script relies on a bare true/false).""" + spec, body = shipped_descriptor_docs + spec["spec"]["inputs"]["dry-run"]["type"] = "string" + bad = _write_descriptor(tmp_path, [spec, body]) + with pytest.raises(DescriptorGateError, match=r"dry-run\.type must be 'boolean'"): + validate(str(bad)) + + +def test_dry_run_on_by_default_fails(tmp_path: pathlib.Path, shipped_descriptor_docs: list[typing.Any]) -> None: + """Negative: dry-run defaulting to true would silently stop every consumer from tagging.""" + spec, body = shipped_descriptor_docs + spec["spec"]["inputs"]["dry-run"]["default"] = True + bad = _write_descriptor(tmp_path, [spec, body]) + with pytest.raises(DescriptorGateError, match=r"dry-run\.default must be False"): + validate(str(bad)) + + +def test_unwired_dry_run_fails(tmp_path: pathlib.Path, shipped_descriptor_docs: list[typing.Any]) -> None: + """Negative: declare dry-run but never thread it to the CLI → gate raises.""" + spec, body = shipped_descriptor_docs + body["semvertag"]["script"] = ["uvx 'semvertag>=0.5.0,<1' tag"] + bad = _write_descriptor(tmp_path, [spec, body]) + with pytest.raises(DescriptorGateError, match=r"must map inputs\.dry-run onto --dry-run"): + validate(str(bad)) + + +def test_pre_dry_run_semvertag_floor_fails(tmp_path: pathlib.Path, shipped_descriptor_docs: list[typing.Any]) -> None: + """Negative: a semvertag floor below 0.5.0 could resolve a CLI without --dry-run.""" + spec, body = shipped_descriptor_docs + body["semvertag"]["script"] = [body["semvertag"]["script"][0].replace(">=0.5.0", ">=0.1")] + bad = _write_descriptor(tmp_path, [spec, body]) + with pytest.raises(DescriptorGateError, match=r"semvertag floor must be >= 0\.5\.0"): + validate(str(bad)) + + def test_unpinned_semvertag_fails(tmp_path: pathlib.Path, shipped_descriptor_docs: list[typing.Any]) -> None: """Negative: drop the semvertag version specifier → gate raises (D1 pinning regression-proof).""" spec, body = shipped_descriptor_docs