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
48 changes: 13 additions & 35 deletions docs/adr/0001-forge-providers-not-unified.md
Original file line number Diff line number Diff line change
@@ -1,35 +1,13 @@
# Forge providers stay separate; share only what is shared by standard

**Decision:** `GitHubProvider` and `GitLabProvider` remain independent classes. They are **not**
collapsed into a single descriptor-driven `RestForgeProvider` engine parameterised by a per-forge
spec. The only shared mechanism extracted is the RFC 8288 Link-header pagination loop behind
`list_tags`, which lives once in `semvertag/providers/_rest.py`.

An architecture review flagged roughly 70% line-level similarity between `providers/github.py` and
`providers/gitlab.py` — four methods, the tag pagination loop, the try/except-then-translate pattern
— and proposed a deep engine whose differing response shapes (`commit.message` nested vs flat
`message`, `sha` vs `id`) would be normalised through pydantic validation aliases into one uniform
attribute surface. Three options were on the table: keep the duplication and spend the effort on real
bugs; extract only the pagination driver; or go to the full descriptor engine.

GitHub's v4 REST API and GitLab's v4 REST API are **independently versioned third-party contracts**.
Most of the line-level similarity is *coincidental* — both are REST CRUD — not *essential*. Unifying
them behind one engine couples two contracts that will drift, and turns the shared engine into a
magnet for `if forge == "github"` conditionals: the wrong abstraction, which is strictly worse than
two honest copies. The pydantic-alias normalisation is the tell, because it pretends two different
API shapes are one.

The discriminating test adopted here, and reused by every later decision of this kind: **extract only
what is shared by *standard*, not by *coincidence*.** Link-header pagination is implemented the same
way by both forges because it is RFC 8288, not a coincidence — genuinely deep, stable, reused, so it
earns a shared home. Response shapes, URL paths, create-tag payloads, and conflict semantics (GitHub
422 vs GitLab 400) are where the two APIs are honestly independent and *will* diverge; those stay
separate by design. Keeping the duplication wholesale was defensible but left the one
genuinely-shared, standard mechanism copy-pasted.

**Revisit trigger:** a third forge that also paginates via RFC 8288 Link headers is added *and* its
commit/tag/default-branch operations turn out to be expressible as pure per-forge **data** with
**zero** forge-conditionals in the shared code. Two such forges plus a clean data-only third would
mean the descriptor engine is a real seam rather than a forced unification, and it is worth
re-pricing. Conversely, the moment the shared pagination helper needs its first forge-conditional,
narrow it back toward two independent copies.
# Forge providers stay separate; only what is shared by standard is extracted

`GitHubProvider` and `GitLabProvider` stay independent classes despite roughly 70% line-level
similarity, and the per-forge status ladders in `providers/_errors.py` stay as mirrored functions.
An architecture review proposed a descriptor-driven engine that would flatten `sha` against `id` and
nested against flat commit messages through pydantic validation aliases, and a generic translator
driven by a per-forge table of message strings; both were rejected. The two REST APIs are
independently versioned third-party contracts, so most of the similarity is coincidental rather than
essential, and a shared engine becomes a magnet for `if forge == ...` conditionals, which is worse
than two honest copies. What is shared by standard is extracted instead: RFC 8288 Link-header
pagination lives once in `_rest.collect_link_pages`, as does the transport translator, whose
messages differ only by a provider label. A third forge, or the first real drift between the two
ladders, would reprice this.
28 changes: 0 additions & 28 deletions docs/adr/0002-blank-settings-values-normalize-to-unset.md

This file was deleted.

11 changes: 11 additions & 0 deletions docs/adr/0002-outcome-renderings-stay-split.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Outcome renderings stay split between wire and human

`to_run_result` in `_outcome.py` and `_format_outcome` in `_output.py` are two exhaustive `match`
statements over the same closed `Outcome` sum, rather than two renderings hung on the variants
themselves. Only the match skeleton is shared, and no string is, by design: the wire arm builds a
frozen machine contract of fixed status tokens and reasons that `action.yml` parses with `jq`, while
the human arm builds a sentence that is free to be reworded. Co-locating them would trade
locality-of-concern for locality-of-variant and drag presentation phrasing into a module that today
depends only on `_types`. The drift a shared home would guard against is already type-enforced:
both matches end in `assert_never`, so a sixth variant is a type error in both arms until handled.
Unification earns its keep only once a variant's two renderings must be the identical string.
38 changes: 0 additions & 38 deletions docs/adr/0003-error-translators-not-tabled.md

This file was deleted.

13 changes: 13 additions & 0 deletions docs/adr/0003-semver-form-tags-only.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# The bump baseline is SemVer-form only, and a prerelease baseline finalizes

`_select_latest_semver_tag` keeps only tags `semver.Version.parse` accepts, strips build metadata,
and takes the maximum by SemVer precedence; `_compute_new_version` then calls `Version.next_version`
rather than `bump_*`, so a prerelease baseline finalizes (`1.0.0-rc.1` plus a patch bump gives
`1.0.0`, not `1.0.1`). That is the correct release-ramp semantics and is identical to `bump_*` on
every stable baseline, so it changed no existing behaviour. PEP 440 prereleases such as `0.9.0rc1`
are deliberately skipped: python-semver cannot parse them, its `coerce` recipe discards the `rc1` and
makes a prerelease masquerade as final, and honest support means running `packaging` alongside
`semver` to consume a form a SemVer tagger should not have to. A leading `v` is skipped too, which is
a deferral rather than a rejection, since it is a one-line strip but would make semvertag consume a
convention it does not emit; the cost until then is that a repo whose history is entirely
`v`-prefixed reports `NoTags` and never bumps.
12 changes: 12 additions & 0 deletions docs/adr/0004-no-doctor-preflight-command.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# No `doctor` preflight command

semvertag ships one verb, `tag`; configuration and permission problems surface from the real run as
a typed `AuthError`, `ConfigError` or `ProviderAPIError` carrying its own exit code. A `doctor`
subsystem shipped and was removed pre-1.0. It checked token validity, token scopes, project access
and protected-tag permission, and chose its exit code by matching string fragments out of a check's
cause, which had to be kept in lockstep with the provider's wording. Every failure it could name
already surfaces from the ordinary run with the same exit code and a message that names the fix, so
the preflight bought only a few seconds of earliness from a second code path. It also taxed the
forge-neutral `Provider` protocol with four `check_*` operations every new forge would owe, and a
small protocol is what made the GitHub provider cheap. Wanting a faster failure is not a reason to
bring it back; a failure the real run genuinely cannot report would be.
33 changes: 0 additions & 33 deletions docs/adr/0004-outcome-renderings-stay-split.md

This file was deleted.

13 changes: 13 additions & 0 deletions docs/adr/0005-composite-action-does-not-check-out.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# The composite action does not check out the repository

`action.yml` sets up `uv` and runs the CLI; it does not run `actions/checkout`, so
`uses: modern-python/semvertag@v0` is not self-contained and the caller owns the checkout step.
Folding one in was rejected because the caller has almost always already checked out, with options
the composite cannot guess: a specific `ref`, a submodule set, LFS objects, a sparse or monorepo
subpath, or a token other than `github.token`. A second checkout would either discard that setup or
fight it, and the failure would be confusing precisely because the step is invisible from the
calling workflow. The established actions in this niche, `mathieudutour/github-tag-action`,
`googleapis/release-please-action` and `cycjimmy/semantic-release-action`, uniformly skip it, so
callers already expect to own it. semvertag reads the head commit and the tag history over the
GitHub API and never touches the working tree, so a folded-in checkout would buy nothing and impose
a `fetch-depth` requirement the tool does not actually have.
45 changes: 0 additions & 45 deletions docs/adr/0005-semver-form-tags-only.md

This file was deleted.

Loading
Loading