Skip to content

Commit ffbf4d7

Browse files
authored
Merge pull request #15 from modern-python/feat/dry-run-flag
feat: add semvertag tag --dry-run flag
2 parents a2cc114 + 9a2f5da commit ffbf4d7

10 files changed

Lines changed: 866 additions & 5 deletions

planning/plans/2026-06-09-dry-run-flag.md

Lines changed: 510 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
# semvertag `--dry-run` flag — design
2+
3+
**Status:** approved
4+
**Date:** 2026-06-09
5+
**Motivating issue:** PR #14's `action-smoke` job failed because semvertag pushed a real release tag (`0.4.1`) from a PR's CI run. The `action-smoke` job is meant to verify the composite action returns well-formed outputs; instead it mutates the real remote whenever main's HEAD isn't already tagged.
6+
7+
## Goal
8+
9+
Add a `--dry-run` flag to the `semvertag tag` CLI that computes the bump but skips the push, so a smoke test (or any other dry-run consumer) can exercise the composite action without side effects.
10+
11+
## Why
12+
13+
- `action-smoke` running with `permissions: contents: write` against the real `main` is structurally unsafe: any PR with a CI run can land a release tag if the current main HEAD is eligible and untagged. This is how PR #14 created `0.4.1`.
14+
- The current "smoke is no-bump" assertion is brittle: it depends on main HEAD being already tagged, an invariant that breaks under tag churn (the dogfood's previous tag attempt failing or being deleted).
15+
- `--dry-run` is a CLI feature with value beyond this test — users running semvertag locally to preview what the next bump would be will use the same flag.
16+
17+
## Scope
18+
19+
This spec covers **PR A only**: the CLI change. PR B (action.yml `dry-run` input, action-smoke update, version-floor bump) is listed under "Follow-ups" and gets its own spec once 0.5.0 is on PyPI.
20+
21+
In scope (PR A):
22+
1. New `--dry-run` boolean option on `semvertag tag` (default: false).
23+
2. `SemvertagUseCase.__call__` gains a `dry_run: bool = False` kwarg.
24+
3. When a bump is computed and `dry_run` is true, the use case emits `status="dry_run"` with the computed `bump` and `tag` populated, and **does not call** `provider.create_tag`.
25+
4. The three other early-return statuses (`no_tags`, `already_tagged`, the strategy's `no_bump_status`) are unaffected — they never reach `create_tag`.
26+
5. Tests covering the dry-run path (unit test on the use case + CLI smoke test).
27+
6. Release `0.5.0` (next minor) once the CLI change lands.
28+
29+
Out of scope (PR A):
30+
- `action.yml` changes — deferred to PR B because the action.yml needs a published semvertag release to consume.
31+
- `ci.yml` action-smoke job changes — deferred to PR B.
32+
- Strategy-level changes — the dry-run skip is strategy-agnostic by construction (it sits in the use case, not in either strategy).
33+
- Documenting `--dry-run` in the GitLab provider docs — only the GitHub Actions doc gets the update in PR B.
34+
- Refactoring `action-smoke` to use a fixture repo — `--dry-run` removes the need.
35+
36+
## Design
37+
38+
### 1. CLI surface
39+
40+
In `semvertag/__main__.py`, the `_tag_command` gains one new option:
41+
42+
```python
43+
@MAIN_APP.command("tag")
44+
def _tag_command(
45+
ctx: typer.Context,
46+
quiet: typing.Annotated[
47+
bool,
48+
typer.Option("--quiet", help="Suppress progress narrative; final result still emits."),
49+
] = False,
50+
json_flag: typing.Annotated[
51+
bool,
52+
typer.Option("--json", help="Emit a JSON envelope on stdout instead of human-readable output."),
53+
] = False,
54+
dry_run: typing.Annotated[
55+
bool,
56+
typer.Option("--dry-run", help="Compute the bump and print the result, but do not push a tag."),
57+
] = False,
58+
) -> None:
59+
output: Output = build_json_output(quiet=quiet) if json_flag else build_rich_output(quiet=quiet)
60+
try:
61+
use_case = _resolve_use_case(ctx=ctx)
62+
use_case(output=output, dry_run=dry_run)
63+
except ImportError as exc:
64+
...
65+
```
66+
67+
Naming: `--dry-run` (kebab-case CLI), `dry_run` (snake_case Python kwarg). Typer maps the two automatically.
68+
69+
### 2. Use case short-circuit
70+
71+
In `semvertag/_use_case.py`, `SemvertagUseCase.__call__` gains a kwarg and one new branch:
72+
73+
```python
74+
def __call__(self, *, output: Output, dry_run: bool = False) -> RunResult:
75+
output.progress(f"Detected strategy: {self.strategy.name}")
76+
output.progress("Fetching latest commit on default branch...")
77+
commit: typing.Final = self.provider.get_latest_commit_on_default_branch()
78+
79+
output.progress("Fetching tag history...")
80+
tags: typing.Final = self.provider.list_tags()
81+
latest_semver_tag: typing.Final = _pick_latest_semver_tag(tags)
82+
83+
if latest_semver_tag is None:
84+
return self._emit(
85+
output=output, bump=Bump.NONE, status="no_tags",
86+
tag=None, commit=commit.sha, reason=_NO_TAGS_REASON,
87+
)
88+
89+
if latest_semver_tag.commit_sha == commit.sha:
90+
return self._emit(
91+
output=output, bump=Bump.NONE, status="already_tagged",
92+
tag=latest_semver_tag.name, commit=commit.sha, reason=_ALREADY_TAGGED_REASON,
93+
)
94+
95+
output.progress("Computing bump...")
96+
bump: typing.Final = self.strategy.decide(commit)
97+
if bump is Bump.NONE:
98+
return self._emit(
99+
output=output, bump=Bump.NONE, status=self.strategy.no_bump_status,
100+
tag=None, commit=commit.sha, reason=self.strategy.no_bump_reason,
101+
)
102+
103+
new_version: typing.Final = _compute_new_version(latest_semver_tag, bump)
104+
if dry_run:
105+
return self._emit(
106+
output=output, bump=bump, status="dry_run",
107+
tag=new_version, commit=commit.sha, reason=None,
108+
)
109+
110+
output.progress(f"Creating tag {new_version}...")
111+
self.provider.create_tag(name=new_version, commit_sha=commit.sha)
112+
return self._emit(
113+
output=output, bump=bump, status="created",
114+
tag=new_version, commit=commit.sha, reason=None,
115+
)
116+
```
117+
118+
Key points:
119+
120+
- The dry-run branch sits between bump computation and `create_tag`. Both strategies use the same use case, so both benefit.
121+
- `bump` and `tag` are populated with what WOULD happen — the dry-run output is informative, not just a stub.
122+
- `reason` is `None` (consistent with the `created` branch).
123+
- The three early-return statuses (`no_tags`, `already_tagged`, strategy's `no_bump_status`) don't change under `dry_run`; they don't push anything, so dry-run has no effect on those paths.
124+
- `output.progress("Creating tag...")` is NOT emitted on the dry-run path. The status itself signals the intent; a "Creating" log would be misleading.
125+
126+
The kwarg default (`dry_run: bool = False`) keeps all existing call sites compiling unchanged.
127+
128+
### 3. Status field
129+
130+
`RunResult.status` is a plain `str` (`_types.py:24``status: str`). No type widening is required; `dry_run` joins the existing well-known values (`created`, `no_tags`, `already_tagged`, plus per-strategy `no_*_commit` variants).
131+
132+
The CLI's internal statuses are documented in one place outside the source: `action.yml`'s normalization-step comment (currently `no_tags`, `already_tagged`, `no_merge_commit`, `no_conforming_commit`, `...`). PR B updates that comment to include `dry_run`. This PR (PR A) doesn't touch `action.yml`.
133+
134+
### 4. Tests
135+
136+
Two new test cases. Both fixture-driven; no network.
137+
138+
**Unit test for the use case** (`tests/test_use_case.py` or wherever the use case is tested):
139+
140+
- Set up a fake provider that returns: one `latest_semver_tag` at version `0.1.0` on commit `aaa`, and a `latest_commit` at `bbb`.
141+
- Configure a fake strategy that returns `Bump.PATCH` for `bbb`.
142+
- Call `use_case(output=spy_output, dry_run=True)`.
143+
- Assert:
144+
- `provider.create_tag` is never invoked (use a mock spy, or a FakeProvider that flags the call).
145+
- The emitted result has `status == "dry_run"`, `bump == "patch"`, `tag == "0.1.1"`, `commit == "bbb"`, `reason is None`.
146+
- For comparison: same setup with `dry_run=False` emits `status == "created"` and calls `create_tag`.
147+
148+
**Existing tests** for the use case continue to pass without modification (default `dry_run=False`).
149+
150+
**CLI test** (`tests/test_cli.py` or wherever the CLI is exercised):
151+
152+
- Invoke `semvertag tag --dry-run --json` with a fake provider/strategy stack (whatever the existing CLI test harness uses).
153+
- Parse the JSON output; assert `status == "dry_run"` and the provider's `create_tag` was not called.
154+
- Coverage gate: this test should land in whatever module already enforces 100% branch coverage on `_use_case` (per `Justfile`'s `test-branch-strategies`).
155+
156+
### 5. JSON output shape
157+
158+
The JSON envelope already includes `status`, `bump`, `tag`, `commit`, `reason`, `strategy`, `schema_version` (from `_output.py`). The dry-run path uses the existing schema; only the `status` value is new:
159+
160+
```json
161+
{
162+
"schema_version": "1.0",
163+
"strategy": "branch-prefix",
164+
"bump": "patch",
165+
"status": "dry_run",
166+
"tag": "0.1.1",
167+
"commit": "bbb...",
168+
"reason": null
169+
}
170+
```
171+
172+
No new fields. `schema_version` stays at `1.0` because `status` was already a string with multiple values; consumers that already handle unknown statuses gracefully (e.g. PR #14's action.yml's `case` block) are forward-compatible.
173+
174+
### 6. Human-readable output
175+
176+
`_output.py:_format_result` currently has two branches: `created` (line 57-59, "Created tag X on commit Y...") and the catch-all (line 60-63, "No tag created (status: X, ...)"). Without a dedicated `dry_run` branch, dry-run output would fall through to "No tag created (status: dry_run, ...)" — technically true, but misleading: a dry-run produced an informative result, not a no-op.
177+
178+
Add one branch in `_format_result`, placed before the catch-all. Hoist `short` above the if-chain so `typing.Final` is declared once (avoids a duplicate-Final flag from `ty` and de-duplicates the slice):
179+
180+
```python
181+
def _format_result(result: RunResult) -> str:
182+
short: typing.Final = (result.commit or "")[:_COMMIT_SHORT_LEN]
183+
if result.status == "created":
184+
return f"Created tag {result.tag} on commit {short} (strategy: {result.strategy}, bump: {result.bump})"
185+
if result.status == "dry_run":
186+
return f"Dry run: would create tag {result.tag} on commit {short} (strategy: {result.strategy}, bump: {result.bump})"
187+
return (
188+
f"No tag created (status: {result.status}, strategy: {result.strategy}, "
189+
f"bump: {result.bump}, reason: {result.reason})"
190+
)
191+
```
192+
193+
Mirrors the `created` branch's format, swapping "Created tag" → "Dry run: would create tag". The no-tag fallback doesn't use `short` — the wasted 2-byte slice is fine.
194+
195+
### 7. Release
196+
197+
After the CLI change lands:
198+
199+
1. The dogfood `semvertag.yml` workflow auto-creates the next patch tag on push-to-main (current floor is `0.4.x`).
200+
2. Manually create a GitHub release pointing at the tagged commit, bumping to `0.5.0` (this is a minor — new feature). `tag-major.yml` will float `v0` accordingly.
201+
3. `publish.yml` runs on release-published and pushes `0.5.0` to PyPI.
202+
203+
The semvertag CLI version floor in `action.yml` (currently `>=0.3.1,<1`) is NOT touched in this PR. PR B bumps it.
204+
205+
## Risks
206+
207+
- **Tests must spy on `create_tag` to verify it isn't called.** A test that just asserts the JSON status without verifying the side-effect-not-taken is a weaker test. Make sure the unit test fails if `dry_run=True` accidentally calls `create_tag`.
208+
- **Status enum widening could break downstream callers.** If anyone is `match`-ing on `status` exhaustively (no default branch), a new value crashes them. Mitigation: action.yml's existing `case "$(jq -r '.status' <<<"$result")"` block uses a `*` default that maps unknown → `no-bump`, so the public `action.yml` consumer is forward-compatible. The CLI itself only emits well-known statuses; this is just a new well-known one.
209+
- **`build_rich_output` for `dry_run` may not match what users expect.** A 1-line rendering choice; cheap to change post-merge if feedback comes in.
210+
211+
## Testing
212+
213+
Automated:
214+
- Unit: `_use_case.py` dry-run path returns the right status without calling `create_tag`.
215+
- CLI: `semvertag tag --dry-run --json` returns the right JSON envelope.
216+
- 100% branch coverage on the `semvertag` package (enforced by `pyproject.toml`'s `[tool.coverage.report] fail_under = 100` plus `--cov=semvertag --cov-branch` in pytest's `addopts`).
217+
- All existing tests still pass (default `dry_run=False`).
218+
219+
Manual:
220+
- `uvx --from . semvertag tag --dry-run` against the real repo — verify no tag is pushed and the output is informative.
221+
- `semvertag tag --dry-run --json` — verify JSON shape matches §5.
222+
223+
## Follow-ups (PR B, not in this spec)
224+
225+
- Add `dry-run` input to `action.yml`. When `true`, pass `--dry-run` to the CLI invocation.
226+
- Bump `action.yml`'s semvertag version floor from `>=0.3.1,<1` to `>=0.5.0,<1`.
227+
- Update `ci.yml`'s `action-smoke` job to set `with: { dry-run: true }`, drop `permissions: contents: write`, and switch the assertion from "outputs.status == no-bump" to "outputs are well-formed AND status != created".
228+
- Document `dry-run` in `docs/providers/github.md` (and add a "preview the next bump" usage example).

semvertag/__main__.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,11 +176,15 @@ def _tag_command(
176176
bool,
177177
typer.Option("--json", help="Emit a JSON envelope on stdout instead of human-readable output."),
178178
] = False,
179+
dry_run: typing.Annotated[
180+
bool,
181+
typer.Option("--dry-run", help="Compute the bump and print the result, but do not push a tag."),
182+
] = False,
179183
) -> None:
180184
output: Output = build_json_output(quiet=quiet) if json_flag else build_rich_output(quiet=quiet)
181185
try:
182186
use_case = _resolve_use_case(ctx=ctx)
183-
use_case(output=output)
187+
use_case(output=output, dry_run=dry_run)
184188
except ImportError as exc:
185189
err = ConfigError(f"Required module unavailable: {exc}.")
186190
output.error(str(err))

semvertag/_output.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,14 @@ def error(self, message: str) -> None:
5454

5555

5656
def _format_result(result: RunResult) -> str:
57+
short: typing.Final = (result.commit or "")[:_COMMIT_SHORT_LEN]
5758
if result.status == "created":
58-
short: typing.Final = (result.commit or "")[:_COMMIT_SHORT_LEN]
5959
return f"Created tag {result.tag} on commit {short} (strategy: {result.strategy}, bump: {result.bump})"
60+
if result.status == "dry_run":
61+
return (
62+
f"Dry run: would create tag {result.tag} on commit {short}"
63+
f" (strategy: {result.strategy}, bump: {result.bump})"
64+
)
6065
return (
6166
f"No tag created (status: {result.status}, strategy: {result.strategy}, "
6267
f"bump: {result.bump}, reason: {result.reason})"

semvertag/_use_case.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ class SemvertagUseCase:
1818
provider: Provider
1919
strategy: BumpStrategy
2020

21-
def __call__(self, *, output: Output) -> RunResult:
21+
def __call__(self, *, output: Output, dry_run: bool = False) -> RunResult:
2222
output.progress(f"Detected strategy: {self.strategy.name}")
2323
output.progress("Fetching latest commit on default branch...")
2424
commit: typing.Final = self.provider.get_latest_commit_on_default_branch()
@@ -60,6 +60,16 @@ def __call__(self, *, output: Output) -> RunResult:
6060
)
6161

6262
new_version: typing.Final = _compute_new_version(latest_semver_tag, bump)
63+
if dry_run:
64+
return self._emit(
65+
output=output,
66+
bump=bump,
67+
status="dry_run",
68+
tag=new_version,
69+
commit=commit.sha,
70+
reason=None,
71+
)
72+
6373
output.progress(f"Creating tag {new_version}...")
6474
self.provider.create_tag(name=new_version, commit_sha=commit.sha)
6575
return self._emit(

tests/integration/test_cli_errors.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ class _RaisingUseCase:
2424
def __init__(self, exc: BaseException) -> None:
2525
self._exc = exc
2626

27-
def __call__(self, *, output: Output) -> typing.NoReturn: # noqa: ARG002
27+
def __call__(self, *, output: Output, dry_run: bool = False) -> typing.NoReturn: # noqa: ARG002
2828
raise self._exc
2929

3030

tests/integration/test_cli_main_verb.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,3 +140,24 @@ def test_main_callback_auto_detects_github_from_env(monkeypatch: pytest.MonkeyPa
140140
with ioc.container:
141141
result = runner.invoke(MAIN_APP, ["tag", "--quiet"])
142142
assert result.exit_code in (0, 3, 4)
143+
144+
145+
def test_dry_run_skips_post_to_tags_endpoint_and_emits_dry_run_status(
146+
cli_env: None, # noqa: ARG001
147+
install_mock_transport: collections.abc.Callable[[HandlerCallable], None],
148+
cli_runner: CliRunner,
149+
) -> None:
150+
recorded: list[httpx2.Request] = []
151+
install_mock_transport(_make_recording_handler(merge_commit_handler(), recorded))
152+
153+
result: typing.Final = cli_runner.invoke(MAIN_APP, ["tag", "--dry-run", "--json"])
154+
155+
assert result.exit_code == 0, result.output + result.stderr
156+
lines: typing.Final = [line for line in result.stdout.splitlines() if line.strip()]
157+
assert len(lines) == 1, f"expected one JSON line, got: {lines!r}"
158+
payload: typing.Final = json_module.loads(lines[0])
159+
assert payload["status"] == "dry_run"
160+
assert payload["tag"] == _EXPECTED_NEW_TAG
161+
assert payload["bump"] == "minor"
162+
posted: typing.Final = [r for r in recorded if r.method == "POST" and r.url.path == _TAGS_POST_PATH]
163+
assert posted == [], f"dry-run must not POST to tags endpoint; got: {posted}"

tests/integration/test_cli_quiet_json_matrix.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ def test_exits_with_one_on_generic_semvertag_error(
113113
) -> None:
114114
install_mock_transport(merge_commit_handler())
115115

116-
def raising_call(self: SemvertagUseCase, *, output: Output) -> typing.Any: # noqa: ANN401, ARG001
116+
def raising_call(self: SemvertagUseCase, *, output: Output, dry_run: bool = False) -> typing.Any: # noqa: ANN401, ARG001
117117
msg = "synthetic generic failure for AC9."
118118
raise SemvertagError(msg)
119119

tests/unit/test_output_rich.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,3 +143,22 @@ def test_build_json_output_returns_json_output_with_quiet_passthrough() -> None:
143143
assert isinstance(built, JsonOutput)
144144
assert built.quiet is True
145145
assert built.error_console.stderr is True
146+
147+
148+
def test_emit_renders_dry_run_with_would_create_phrasing() -> None:
149+
output, stdout_buf, _stderr = _make_pair()
150+
dry_run_result: typing.Final = RunResult(
151+
strategy="branch-prefix",
152+
bump="minor",
153+
status="dry_run",
154+
tag="1.2.0",
155+
commit="a2b4d12abc1234567890",
156+
reason=None,
157+
)
158+
output.emit(dry_run_result)
159+
stdout_text: typing.Final = stdout_buf.getvalue()
160+
assert "Dry run" in stdout_text
161+
assert "would create tag 1.2.0" in stdout_text
162+
assert "a2b4d12" in stdout_text
163+
assert "branch-prefix" in stdout_text
164+
assert "minor" in stdout_text

0 commit comments

Comments
 (0)