Releases: modern-python/semvertag
0.6.0 — dry-run on the composite action
semvertag 0.6.0 — dry-run on the composite action
Minor release exposing --dry-run through modern-python/semvertag@v0 and removing the action-smoke side-effect smell. Pure action + CI change — the CLI is unchanged from 0.5.0 (no Python source touched). Consumers who don't need the new input can keep using @v0 exactly as before; the input is opt-in.
If you don't use the composite action and only consume semvertag directly via uvx, you can skip this release — there's no CLI change.
What landed
dry-runinput onaction.yml(default'false'). Whentrue, the composite passes--dry-runtosemvertag tag; semvertag short-circuits beforeprovider.create_tagand the existing case block normalizesstatus="dry_run"to the publicstatus="no-bump". The action'sbump/tagoutputs reflect what would have happened. (PR #16)action-smoke(this repo's CI) becomes structurally side-effect-free. Two layers of protection:- Layer 1:
with: { dry-run: 'true' }onuses: ./— semvertag never attempts a push. - Layer 2:
permissions: contents: writeremoved — even a future regression that bypassed dry-run would 403 on the API.
- Layer 1:
- Assertion reduced from two lines (
status == no-bumpANDbump == none) to one (status == no-bump). Under dry-run,bumpis unstable (reflects the would-be value) — the old check would fail on any PR where main HEAD was an untaggedfeat//bugfix/merge. Status normalization is the real wiring contract; the single check catches a regression loudly (a unwired dry-run would surface ascreated). - CLI version floor in
action.ymlbumps from>=0.3.1,<1to>=0.5.0,<1(the release that ships--dry-run). docs/providers/github.mdgets a## Preview the next bumpsection with both the action-input example and the equivalent localuvxinvocation.
Usage
- id: semvertag
uses: modern-python/semvertag@v0
with:
dry-run: true
- if: steps.semvertag.outputs.bump != 'none'
run: echo "Next release would be ${{ steps.semvertag.outputs.tag }} (${{ steps.semvertag.outputs.bump }} bump)"Under dry-run: true, the action's status output is no-bump (no real tag was pushed). The bump and tag outputs reflect what would have happened, so a downstream conditional like the one above can branch on the planned bump.
Why
PR #14 (in 0.5.0) surfaced the smell: action-smoke ran semvertag against the real main with contents: write, and when main HEAD wasn't already tagged, the smoke test pushed a real release tag from a PR's CI run. 0.5.0 shipped the CLI half (--dry-run); 0.6.0 wires it through the composite and switches the smoke job to use it. After this release:
action-smokeis provably side-effect-free regardless of main HEAD's tag state.- The brittle "main HEAD is always already-tagged" assumption is gone — the test asserts the dry-run wiring, not an external invariant.
- Any consumer who wants to preview the next bump from a workflow can opt in via the same input.
Breaking changes
None. dry-run defaults to 'false'; existing uses: modern-python/semvertag@v0 invocations behave identically to 0.5.x.
The version floor bump (>=0.3.1,<1 → >=0.5.0,<1) is internal to the action — consumers who pin @v0 don't see it directly. If anyone was somehow consuming action.yml outside the @v0 pin and pinning an old semvertag themselves, they'd need to upgrade to 0.5.0+. No known consumer pattern matches this.
Release procedure
tag-major.yml automatically updates the floating v0 tag on release-published, so the steps from 0.4.0's manual bootstrap are no longer needed.
gh release create 0.6.0 \
--title '0.6.0 — dry-run on the composite action' \
--notes-file planning/releases/0.6.0.mdpublish.yml fires on release creation and pushes 0.6.0 to PyPI. tag-major.yml fires in parallel and force-updates v0 to point at the 0.6.0 commit.
See also
0.5.0
New
semvertag tag --dry-run— compute the bump and print the result without pushing a tag. Useful for previewing the next version locally and for CI smoke tests that need to exercise the composite action side-effect-free. JSON output uses a newstatus: "dry_run"value; human-readable rendering says "Dry run: would create tag X on commit Y…". (#15)- Docs site auto-deploys to https://semvertag.modern-python.org on every push to
mainthat touchesdocs/,mkdocs.yml, or the docs workflow. (#14)
CI
0.4.0 — composite GitHub Action
semvertag 0.4.0 — composite GitHub Action
Minor release shipping the action.yml composite wrapper that has been on the deferred list since 0.3.0. Users can now write uses: modern-python/semvertag@v0 instead of pasting an 11-line install-and-run block. No CLI changes; the action wraps the existing semvertag tag --json invocation and surfaces tag / bump / status as step outputs.
If you're already using the documented pure-CLI snippet and don't want to consume third-party actions, you can stay on it — docs/providers/github.md preserves it as the "Without the composite action" recipe.
What landed
action.ymlat repo root — composite action:astral-sh/setup-uv@v7, thenuvx 'semvertag>=0.3.1,<1' tag --json, then parses the envelope intotag/bump/statusstep outputs..github/workflows/tag-major.yml— fires on release published (non-prerelease) and force-updates the floatingv0major tag so consumers can pin@v0and ride minor bumps automatically.- Dogfood workflow migration —
.github/workflows/semvertag.ymlnow consumesuses: ./, exercising the action against the working tree on every push to main. action-smokeCI job — runsuses: ./on every PR and asserts thatstatusandbumpoutputs are non-empty. Real tag creation against the GitHub API is covered by the post-merge dogfood run, not the PR-time job (forks can't havecontents: write).- README +
docs/providers/github.mdrewrite — Quick Start leads with the action; Outputs section documents the three step outputs; "Without the composite action" preserves the pure-CLI fallback for constrained environments.
CLI version floor
action.yml pins 'semvertag>=0.3.1,<1'. 0.3.1 is the minimum CLI version that ships every feature the action depends on (--json envelope, GITHUB_ACTIONS=true auto-detection, branch-prefix GitHub merge subject recognition). The floor only needs to bump when a future release breaks CLI contract — not on every minor.
Release procedure (maintainer)
Step 1: Pre-flight check
Before tagging:
- Search https://github.com/marketplace?type=actions for "semvertag" — the listing name
semvertagmust not be taken by another action.- If it's taken: edit
action.yml'sname:field to'semvertag tag'(Marketplace permits spaces in display names) and re-PR before continuing. Theuses: modern-python/semvertag@v0syntax depends on the repo slug, not the display name, so consumer-facing docs do not change.
- If it's taken: edit
- Confirm
branding.iconis one of the Feather icon names GitHub accepts andbranding.coloris one ofwhite | yellow | blue | green | orange | red | purple | gray-dark. (We shipicon: tag,color: blue— both valid.) - Confirm CI is green on main, including the new
action-smokejob.
Step 2: Cut the 0.4.0 GitHub release
This project's release tag convention is bare semver (0.4.0, no v prefix) — the same as 0.3.x and earlier. The 0.4.0 tag is created automatically by the dogfood workflow on PR merge, so it already exists when you reach this step.
Create the GitHub release pointing at the existing tag:
gh release create 0.4.0 \
--title '0.4.0 — composite GitHub Action' \
--notes-file planning/releases/0.4.0.md(Or use the GitHub web UI: Releases → "Draft a new release" → choose existing tag 0.4.0.)
publish.yml fires on release creation and pushes 0.4.0 to PyPI via just publish (which uses uv version $GITHUB_REF_NAME to inject the bare semver tag name as the package version).
Step 3: Bootstrap the floating v0 tag (one-time)
Releases use bare semver tags (0.4.0) but the floating action tag is v-prefixed (v0) so consumers can write the conventional uses: modern-python/semvertag@v0. tag-major.yml handles the v-prefix bump automatically on every release from 0.4.1 forward. For 0.4.0 specifically — the first release after the workflow landed — the floating tag does not yet exist and must be bootstrapped manually:
git fetch --tags
git tag -fa v0 0.4.0 -m 'Update v0 to 0.4.0'
git push -f origin v0After this, uses: modern-python/semvertag@v0 resolves successfully for consumers.
Step 4: Publish to Marketplace (manual UI step)
- Navigate to https://github.com/modern-python/semvertag/releases/tag/0.4.0.
- Click Edit release.
- Check Publish this Action to the GitHub Marketplace.
- Accept the Marketplace Terms of Service if prompted (one-time for the repo).
- Select Primary Category:
Continuous integration. - Select Secondary Category (optional):
Utilities. - Save the release.
Step 5: Post-publish smoke test
- Confirm the listing appears at https://github.com/marketplace/actions/semvertag (the slug derives from the
name:field; if you renamed in Step 1 the slug will differ). - In a sandbox repo, paste the README snippet (
uses: modern-python/semvertag@v0after aactions/checkout@v4withfetch-depth: 0) and confirm the workflow runs end-to-end.
Breaking changes
None. The action is additive; the pure-CLI recipe still works exactly as before (and remains documented as the fallback).
See also
- Spec:
planning/specs/2026-06-08-action-yml-composite-wrapper-design.md - Implementation plan:
planning/plans/2026-06-08-action-yml-composite-wrapper.md
0.3.1 — branch-prefix recognizes GitHub merge subjects
Patch release with one fix surfaced by dogfooding. Anyone using --strategy branch-prefix (the default) inside GitHub Actions was silently no-op'ing on every merge with status=no_merge_commit, even on real merge commits. 0.3.1 fixes the default to recognize GitHub's PR-merge subject format alongside GitLab's git merge format. No changes to the GitHub or GitLab providers themselves.
If you're not on branch-prefix, or your CI is GitLab, you can skip this release — no behavior change for you.
The bug
BranchPrefixStrategy.decide gated the bump computation on a single substring check:
if self.config.merge_mark_text not in subject: # default: "Merge branch"
return Bump.NONE"Merge branch" is GitLab's default git merge feature/foo subject (Merge branch 'feature/foo' into main). GitHub's PR merges produce a different subject — Merge pull request #N from OWNER/BRANCH — which does not contain "Merge branch". So every branch-prefix consumer running inside GitHub Actions hit the gate, fell through to Bump.NONE, and reported no_merge_commit even when the commit was clearly a merge.
The docs in docs/strategies/branch-prefix.md explicitly called this out as a limitation under defaults — the fix turns "explicitly limited" into "works".
Surfaced concretely by dogfooding 0.3.0 on this repo's own CI (PR #5, PR #6 — both merged via GitHub PR, both no-op'd with no_merge_commit).
The fix
BranchPrefixConfig.merge_mark_text: str = "Merge branch" becomes a plural tuple with both formats in the default:
merge_mark_texts: tuple[str, ...] = pydantic.Field(
default=("Merge branch", "Merge pull request"),
min_length=1,
)BranchPrefixStrategy.decide now checks any(mark in subject for mark in self.config.merge_mark_texts). Out-of-the-box correct for both providers.
Breaking change
The field rename merge_mark_text → merge_mark_texts (singular → plural tuple) affects anyone who was overriding the default via env or config. Specifically:
SEMVERTAG_BRANCH_PREFIX__MERGE_MARK_TEXT="X"(old) →SEMVERTAG_BRANCH_PREFIX__MERGE_MARK_TEXTS='["X"]'(new — note the plural field name + JSON-tuple env shape).BranchPrefixConfig(merge_mark_text="X")(old) →BranchPrefixConfig(merge_mark_texts=("X",))(new).
Acceptable pre-1.0: this knob shipped in 0.1.0 with the documented use case "non-default merge conventions" and has no known consumers overriding it. Users who were overriding it get a clear pydantic ValidationError at startup — easy to fix.
Migration
For the common case (using defaults): no action needed. 0.3.1 just works.
For anyone overriding the old field: rename per above. The new shape accepts a tuple of substrings, so existing single-string overrides become a 1-tuple — and you can now list multiple marks (e.g. squash-merge prefixes alongside the standard ones) without writing custom code.
See also
0.3.0 — GitHub provider
Feature release. Adds first-class GitHub support. semvertag now auto-tags both GitLab projects and GitHub repos from the same CLI, with auto-detection from the CI runner's environment. The package description "Auto-tag GitLab repos" becomes "Auto-tag GitLab and GitHub repos" — honest at last.
If you're on GitLab today, nothing changes: the default provider remains gitlab when no CI env signals otherwise. Drop straight to "What's new" — there are no breaking changes to the GitLab side.
What's new
GitHubProvider. Parallel toGitLabProvider, conforming to the same four-methodProviderProtocol. Talks toapi.github.com(or your GitHub Enterprise endpoint viaSEMVERTAG_GITHUB__ENDPOINT), authenticates viaAuthorization: Bearer <token>+X-GitHub-Api-Version: 2022-11-28, creates tags viaPOST /repos/{owner}/{repo}/git/refs. Same retry posture, same decoder path, same error translator shape as the GitLab side — built on thehttpware-backed infrastructure that landed in 0.2.x.- Provider auto-detection from CI env. semvertag reads
GITHUB_ACTIONSandGITLAB_CIto pick the active provider. Inside GitHub Actions:GITHUB_ACTIONS=trueis auto-set →provider=github. Inside GitLab CI:GITLAB_CI=true→provider=gitlab. Outside CI: defaults togitlab(back-compat with 0.2.x users). Explicit--provider githuborSEMVERTAG_PROVIDER=githubalways wins. - New CLI flags.
--provider github|gitlab— explicit override--repo OWNER/REPO— GitHub repo identifier (or setGITHUB_REPOSITORY/SEMVERTAG_REPO)--github-endpoint— GitHub API endpoint (for GitHub Enterprise)--tokennow routes to the active provider's token field (a--provider github --token ghp_...invocation populatesSettings.github.token)
- New env aliases picked up by
pydantic-settings:GITHUB_REPOSITORY,SEMVERTAG_REPO→Settings.repoSEMVERTAG_PROVIDER,PROVIDER→Settings.providerGITHUB_TOKEN,SEMVERTAG_GITHUB__TOKEN,SEMVERTAG_TOKEN→Settings.github.tokenSEMVERTAG_GITHUB__ENDPOINT→Settings.github.endpoint(defaulthttps://api.github.com)
- Tag-already-exists detection on GitHub. GitHub returns
422 Unprocessable Entitywith{"errors": [{"resource": "Reference", "code": "already_exists"}]}when you POST a tag ref that already exists.translate_create_tag_github_unprocessableinspects both the structuredalready_existscode (durable contract) and the"already exists"message string (safety net) and surfaces as aConfigError("Tag already exists: 'vX.Y.Z'."). - Inline GitHub Actions workflow recipe in
docs/providers/github.mdandREADME.md. Usesactions/setup-python+uvx semvertag tag. Workflow needspermissions: contents: writeto use the auto-issuedGITHUB_TOKEN; or supply a PAT withcontents: write(fine-grained) /repo/public_repo(classic) asSEMVERTAG_TOKEN.
What's new under the hood
Internal cleanups that landed alongside the provider:
_link_paginationmodule — RFC 8288 Link-header walking extracted fromproviders/gitlab.py(GitHub paginates identically; one helper now serves both)._translate_transportshared helper inproviders/_errors.py— the transport-error translation branches (DecodeError,TimeoutError,NetworkError,RetryBudgetExhaustedError, fallback) were uniformly"<provider> <description>"between GitLab and GitHub, so they parameterize cleanly onprovider_label: str. Per-status translation stays per-provider because the actionable hints differ.Settings._resolve_providervalidator — runs at construction time; auto-detects from env ifSettings.provideris unset; enforces thatprovider=githubrequiresrepoandprovider=gitlabrequiresproject_id. Replaces the runtime guards that used to live inioc.py.
Behavior notes
Settings()now requires eitherproject_id(gitlab) orrepo(github). Pre-0.3.0 you could constructSettings()with neither, and the runtime guard inioc._build_gitlab_providerwould raiseConfigError("Project id missing. ...")at provider-construction time. The new validator catches the same situation earlier with a clearer message. CI consumers were already settingCI_PROJECT_ID(auto-exported by GitLab CI) so this is enforcing what was implicitly required. Local-dev users runningsemvertag tagagainstgitlab.comwith no--project-idwill see the new validator message instead of the old one. Same exit code (2), more actionable diagnostic.- No retry-policy changes. Retry middleware, backoff, status codes,
RetryBudgetall unchanged from 0.2.x. - No CLI flag removals. All 0.2.x flags still work. Three new ones added;
--tokensemantics extended to route by active provider.
Migration
For most users, no migration needed. GitLab callers see no behavior change.
To start using GitHub:
# .github/workflows/semvertag.yml
name: semvertag
on:
push:
branches: [main]
permissions:
contents: write
jobs:
tag:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: actions/setup-python@v5
with: { python-version: "3.13" }
- run: pip install --quiet 'uv>=0.4,<1'
- run: uvx 'semvertag>=0.3,<1' tag
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}GITHUB_ACTIONS=true auto-resolves --provider github; GITHUB_REPOSITORY auto-resolves --repo. The minimum workflow above is the complete setup for github.com-hosted public/private repos using the workflow-scoped GITHUB_TOKEN.
See docs/providers/github.md for token scopes, fine-grained vs classic PATs, GitHub Enterprise setup, and troubleshooting.
See also
- Spec:
planning/specs/2026-06-08-github-provider-design.md - Plan:
planning/plans/2026-06-08-github-provider.md - PR #4
Known follow-ups
action.ymlcomposite GitHub Action. A one-lineuses: modern-python/semvertag@vX.Y.Zis nicer DX than the inline workflow. Deferred; the inline recipe is the supported path until the composite ships.- Bitbucket provider. Same pattern; separate work.
- GitHub App authentication. PATs and the workflow-scoped
GITHUB_TOKENare supported; GitHub App JWT exchange is not.
0.2.0 — HTTP stack on httpware; Python 3.10 dropped
Breaking release. The provider HTTP stack is rebuilt on top of httpware 0.8.2 — a maintained sibling client framework — replacing the in-tree RetryingTransport + HttpClient plumbing that shipped in 0.1.x. Operator-visible behavior changes are listed below. Python 3.10 is no longer supported.
If you pin semvertag in CI and use Python 3.11+ already, you can skip straight to "Behavior changes" — the rest is internal.
What's new
- HTTP stack on httpware.
GitLabProviderholds ahttpware.Clientdirectly.httpware.Retrymiddleware replaces the hand-rolledRetryingTransport.httpware.PydanticDecoderdecodes response bodies viaresponse_model=— no more in-tree validator plumbing. - Smaller surface. Net ~600 LOC of HTTP plumbing deleted across the two migrations.
semvertag/_transport.pyandsemvertag/providers/_http.pyare gone; their tests deleted too. - Uniform error contract.
httpware.ClientErrorcovers transport failures, status errors, AND decode failures uniformly. semvertag's error tree (ConfigError/2,AuthError/3,ProviderAPIError/4) is unchanged at the CLI boundary — exit codes are stable. httpware[pydantic]>=0.8.2is a new transitive dependency. End users who installsemvertagfrom PyPI pick it up automatically.
Breaking changes
Python 3.10 dropped
requires-python = ">=3.11,<4". semvertag 0.1.x supported 3.10; 0.2.0 does not. The bump is forced by httpware's own floor.
Migration: upgrade your CI runners and local development environment to Python 3.11 or newer. If you cannot, stay on semvertag==0.1.1.
Behavior changes from the new retry middleware
The 0.1.x RetryingTransport is gone; httpware.Retry takes over. These are the user-observable differences in CI logs:
| Knob | 0.1.x | 0.2.0 |
|---|---|---|
| Retryable statuses | 408, 429, 500, 502, 503, 504 | same |
| Methods retried | all (transport-level) | idempotent only — POST not retried |
| Backoff base | 1.0 s, full-jitter | 0.1 s, full-jitter |
| Max sleep per attempt | implicit via 30 s wall cap | 5 s |
| Total-attempt wall cap | 30 s per request | none; httpware.RetryBudget (10 deposits + 20% retry ratio) caps storms across requests instead |
| Retry-After combine | max(server_hint, local_backoff) |
server hint honored verbatim, capped at max sleep |
Net effect for a typical CI run: faster transient-failure recovery (backoff starts 10× shorter), create_tag (POST) now fails immediately on a 429 instead of retrying — operator must rerun the job. All other surfaces unchanged.
Error message wording
Provider error messages produced by decoder failures (malformed JSON, schema mismatch) now read "GitLab _ProjectResponse response could not be decoded: <pydantic error>" instead of the 0.1.x "shape invalid: ..." / "malformed JSON in response body" wording. Only matters if you grep stderr for specific substrings. Exception types (ProviderAPIError) and exit codes (4) are unchanged.
Migration
For most users, the migration is one line in CI config:
- python-version: "3.10"
+ python-version: "3.11"If you grep stderr or test output for the old retry/decode error wording, update the strings. If you depend on create_tag POSTs being retried automatically on 429, add an outer retry loop in your CI script (e.g., gh workflow rerun on failure).
The semvertag CLI surface, exit codes, environment variables (SEMVERTAG_*), and the templates/semvertag.yml GitLab CI Catalog descriptor are all unchanged.
See also
Two design+implementation cycles fed this release:
- httpware migration — replace the in-tree HTTP stack with
httpware.Client+httpware.Retry. - Decoder adoption — switch the three GETs to use
response_model=/send_with_response, delete the in-tree validator helpers.
0.1.1
Full Changelog: 0.1.0...0.1.1
0.1.0
Full Changelog: https://github.com/modern-python/semvertag/commits/0.1.0