|
| 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). |
0 commit comments