Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions docs/providers/gitlab.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 5 additions & 1 deletion templates/semvertag.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
26 changes: 25 additions & 1 deletion tests/_descriptor_gate.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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")
Expand All @@ -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")

Expand Down Expand Up @@ -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}",
)
45 changes: 45 additions & 0 deletions tests/test_ci_descriptor_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading