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
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ pytest.xml
dist/
.python-version
.venv
plan.md
/site/
/.worktrees/
uv.lock
52 changes: 40 additions & 12 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,44 @@

## Workflow

This project uses **Superpowers** (brainstorm → plan → TDD → review).
This project uses **Superpowers** (brainstorm → plan → TDD → review) with the
portable two-axis planning convention. The living truth about *what the system
does now* lives in [`architecture/`](architecture/) at the repo root (one file
per capability: `strategies.md`, `providers.md`, `cli.md`); `planning/` records
*how it got there*. See [`planning/README.md`](planning/README.md) for the full
conventions and the change Index, and [`planning/_templates/`](planning/_templates/)
for copy-and-fill starters.

- Brainstorm specs live in `planning/specs/YYYY-MM-DD-<topic>-design.md`.
- Implementation plans live in `planning/plans/YYYY-MM-DD-<topic>.md`.
- Use TDD by default: red, green, refactor. Tests before implementation.
- Use git worktrees for feature isolation (`superpowers:using-git-worktrees`).
- Use the verification gate before claiming work complete
(`superpowers:verification-before-completion`).
- Request code review via a subagent before landing
(`superpowers:requesting-code-review`).
Per feature: brainstorming → spec in
`planning/changes/active/YYYY-MM-DD.NN-<slug>/design.md` → writing-plans → plan
in the same bundle's `plan.md` → executing-plans / subagent-driven-development →
requesting-code-review → finishing-a-development-branch. `<slug>` is a
kebab-case description, not a story ID; `.NN` is a zero-padded intra-day counter
that breaks same-date ties. On merge the bundle moves to
`planning/changes/archive/` with `status: shipped`, `pr:`, and `outcome:`
filled, **and the change promotes its conclusions into the affected
`architecture/<capability>.md`** — that hand-edit is what keeps `architecture/`
true.

**Three lanes.** Scale the artifact to the change. **Full** — a `design.md` +
`plan.md` bundle — for real design judgment, a new file/module, a public-API
change, cross-cutting/multi-file work, or non-trivial test design.
**Lightweight** — a single `change.md` — for small-but-real changes (≲30 LOC
net, ≤2 files, no new file, no public-API change, a single straightforward
test). **Tiny** — no bundle, just a conventional commit — for a typo, dep bump,
linter/formatter/CI tweak, a mechanical rename, or a single-line config change.
Heavier lane wins on ambiguity.

Use TDD by default: red, green, refactor. Tests before implementation. Use git
worktrees for feature isolation (`superpowers:using-git-worktrees`). Use the
verification gate before claiming work complete
(`superpowers:verification-before-completion`). Request code review via a
subagent before landing (`superpowers:requesting-code-review`).

Planning artifacts live under `planning/` (not under `docs/`, so they're
excluded from the mkdocs site automatically). When superpowers skills default to
`docs/superpowers/specs/` or `docs/superpowers/plans/`, use the change bundle
under `planning/changes/active/` here instead.

## Commit messages

Expand Down Expand Up @@ -47,7 +75,7 @@ See `Justfile` for the canonical commands. Quick reference:
## What the codebase ships

`semvertag` is a public-OSS auto-tagger for GitLab/GitHub/Bitbucket
repositories. Two strategies (`branch-prefix`, `conventional-commits`), one
provider implemented today (GitLab), distributed as a Python CLI plus a
GitHub Actions wrapper (`action.yml`) and a GitLab CI Catalog component
repositories. Two strategies (`branch-prefix`, `conventional-commits`), two
providers implemented today (GitLab, GitHub), distributed as a Python CLI plus
a GitHub Actions wrapper (`action.yml`) and a GitLab CI Catalog component
(`templates/semvertag.yml`).
4 changes: 4 additions & 0 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ publish:
uv build
uv publish --token $PYPI_TOKEN

# Strict local docs build (no deploy). Mirrors CI's link/strict checks.
docs-build:
uvx --with-requirements docs/requirements.txt mkdocs build --strict

# Force-pushes built site to gh-pages; CI runs this on push to main.
# Manual invocation from a stale checkout will roll the live site back.
docs-deploy:
Expand Down
122 changes: 122 additions & 0 deletions architecture/cli.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# CLI

The CLI is the one process everything funnels through: a human at a shell, the
GitHub Action, and the GitLab CI component all invoke the same `semvertag tag`
command. It parses flags + environment into validated `Settings`, wires a
provider and a strategy through a modern-di container, and runs the use-case.

## Entry point

`semvertag/__main__.py` builds `MAIN_APP`, a `typer.Typer` app
(`no_args_is_help=True`), with one real command, `tag`, plus a root callback
that gathers global options and an eager `--version`. modern-di is attached via
`modern_di_typer.setup_di(MAIN_APP, ioc.container)`, and `main()` enters the
container as a context manager before running the app.

`tag` (`_tag_command`) takes `--quiet`, `--json`, and `--dry-run`. It builds the
output (`build_json_output` or `build_rich_output`), resolves the use-case from
DI, and calls it. `--dry-run` is threaded straight into the use-case
(`use_case(output=output, dry_run=dry_run)`): the use-case still fetches the
commit, reads the tag history, and computes the new version, but when
`dry_run` is true it short-circuits *before* `provider.create_tag` — emitting a
`dry_run` status with the planned tag instead of pushing it. Errors are caught
at this boundary: any `SemvertagError` (or `ImportError`) is printed via the
output and re-raised as `typer.Exit(code=err.exit_code)`, mapping the domain
error hierarchy to process exit codes; `BrokenPipeError` exits 0.

## IoC wiring

`semvertag/ioc.py` defines a modern-di `Container` over four `Group`s:

- `SettingsGroup` — a `ContextProvider` for `Settings`; the callback sets the
validated instance into the container context (`set_context(Settings,
settings)`) so everything downstream resolves from one settings object.
- `ProvidersGroup` — `gitlab_client` / `github_client` factories (with
`_close_client` finalizers) and `current_provider`, which dispatches on
`settings.provider`.
- `StrategiesGroup` — the two strategy factories plus `current_strategy`,
dispatching on `settings.strategy`.
- `UseCasesGroup` — `semvertag_use_case`, built from `current_provider` +
`current_strategy`.

The CLI resolves the use-case through `_resolve_use_case`, a
`@modern_di_typer.inject`'d function with a `FromDI(SemvertagUseCase)` parameter.
Because modern-di's `Factory` eagerly resolves all kwargs, both HTTP clients are
constructed even though only one provider runs; this is safe (lazy httpx2 pools)
and `_build_current_provider` carries `assert` guards on `repo` / `project_id`
that both narrow types for `ty` and document the invariant the settings
validator guarantees — the eager-resolution None-field guard.

## Settings

`semvertag/_settings.py` defines `Settings` (and nested `GitLabConfig` /
`GitHubConfig`) as `pydantic-settings` models. Sources are the environment
(prefix `SEMVERTAG_`, nested delimiter `__`) and CLI overrides; `AliasChoices`
lets one field accept several env names — e.g. the GitLab token reads
`SEMVERTAG_GITLAB__TOKEN`, `SEMVERTAG_TOKEN`, `CI_JOB_TOKEN`, or `GITLAB_TOKEN`;
`provider` accepts `SEMVERTAG_PROVIDER` or `PROVIDER`; `project_id` accepts
`CI_PROJECT_ID`; `repo` accepts `GITHUB_REPOSITORY`. A `model_validator`
auto-detects the provider from CI env (`GITHUB_ACTIONS` / `GITLAB_CI`) when
unset and enforces that github needs `repo` and gitlab needs `project_id`. A
field validator clamps `request_timeout` to a 10-second ceiling.

CLI flags are applied *over* the env-built settings by `apply_cli_overlay`,
which is built on `model_copy(update=...)`: it splits dotted keys
(`gitlab.endpoint`) into nested sub-model copies, copies the top-level fields,
then re-validates the whole model so field/model validators fire again on the
merged result. Precedence is therefore **CLI over env over default** — env (and
defaults) build the base instance, then non-`None` CLI overrides overwrite it.
`--token` is applied in a second overlay pass routed to the *resolved* active
provider (`{provider}.token`), so one flag lands on whichever forge is active.

## Use-case

`semvertag/_use_case.py` defines `SemvertagUseCase`, a frozen dataclass holding
a `provider` and a `strategy`; calling it (`__call__(*, output, dry_run=False)
-> RunResult`) is the whole orchestration:

1. fetch the latest commit on the default branch;
2. list tags and pick the highest semver-parseable one (`_pick_latest_semver_tag`
sorts by `semver.Version`; unparseable names are skipped);
3. early no-bump exits — `no_tags` when there is no prior semver tag (it does
**not** seed an initial tag in v1.0), `already_tagged` when the head commit
already carries the latest tag;
4. ask the strategy for a `Bump`; `Bump.NONE` exits with the strategy's own
status/reason;
5. compute the new version (`_compute_new_version` via `semver`'s
`bump_major/minor/patch`);
6. if `dry_run`, return `dry_run`; else `provider.create_tag` and return
`created`.

Every exit funnels through `_emit`, which builds a frozen `RunResult`
(`schema_version`, `strategy`, `bump`, `status`, `tag`, `commit`, `reason`),
hands it to the output, and returns it.

## Output

`semvertag/_output.py` defines an `Output` protocol (`progress` / `emit` /
`error`) with two implementations. `RichOutput` is the human path: progress
lines and a one-sentence result to stdout via `rich`, errors to stderr.
`JsonOutput` is the machine path: `progress` is a no-op and `emit` writes a
single compact JSON envelope (`dataclasses.asdict(result)`) to stdout. `--quiet`
suppresses progress narrative on both while still emitting the final result.
`RichOutput` redacts all output paths; `JsonOutput` redacts only its `error` path — `emit` writes the result envelope as unredacted JSON (see providers.md).

## Distribution wrappers

Two thin wrappers shell out to the same published CLI:

- `action.yml` — a composite GitHub Action. It sets up `uv`, exports
`GITHUB_TOKEN` and `SEMVERTAG_STRATEGY` from inputs, and runs
`uvx 'semvertag>=0.5.0,<1' tag --json $dry_run_flag`, where `$dry_run_flag`
expands to `--dry-run` when the `dry-run` input is `"true"`. It parses the
JSON envelope with `jq` and normalizes the CLI's internal status to a stable
`created | no-bump` enum for `tag` / `bump` / `status` outputs.
- `templates/semvertag.yml` — a GitLab CI Catalog component. It pip-installs
`uv`, maps the `strategy` input to `SEMVERTAG_STRATEGY`, and runs
`uvx 'semvertag>=0.1,<1' tag`.

Both shell out to the same CLI, but they are **not** symmetric on dry-run:
`action.yml` passes `--dry-run` through (gated on the `dry-run` input), whereas
`templates/semvertag.yml` exposes only a `strategy` input and runs `tag` with no
`--dry-run` flag — the GitLab component has no dry-run path today.
117 changes: 117 additions & 0 deletions architecture/providers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# Providers

A provider is the API adapter for one forge. It hides REST-vs-REST differences
behind a small, forge-neutral contract so the use-case can read commits and tags
and create a tag without knowing whether it is talking to GitLab or GitHub.
Two providers ship today.

## The contract

`semvertag/providers/_base.py` defines `Provider`, a `typing.Protocol`
(structural — providers are matched by shape, registered in the IoC container,
not by subclassing). The abstract operations:

- `name: str` — the provider id.
- `get_default_branch(self) -> str` — the repo's default branch name.
- `get_latest_commit_on_default_branch(self) -> Commit` — head commit of that
branch as a frozen `Commit` (`sha`, `message`).
- `list_tags(self) -> list[Tag]` — every tag as `Tag` (`name`, `commit_sha`).
- `create_tag(self, name: str, commit_sha: str) -> None` — create a tag
pointing at a commit; side-effecting, returns nothing.

Both concrete providers are frozen, slotted, kw-only dataclasses holding their
forge config, a repo identifier, and an `httpware.Client`.

## GitLab

`semvertag/providers/gitlab.py` targets the GitLab v4 REST API under
`/api/v4/projects/{project_id}`:

- default branch — `GET /api/v4/projects/{id}`, reads `default_branch`; a null
value raises `ConfigError`.
- latest commit — `GET .../repository/commits?ref_name={branch}&per_page=1`,
takes element `[0]`; an empty list raises `ProviderAPIError`.
- tags — `GET .../repository/tags?per_page=100`, paginated (below).
- create — `POST .../repository/tags` with `{"tag_name": name, "ref":
commit_sha}`.

Auth is the `PRIVATE-TOKEN` header, set once when the client is built in
`semvertag/ioc.py` (`_build_gitlab_client`) from `settings.gitlab.token`.

## GitHub

`semvertag/providers/github.py` targets the GitHub REST API under
`/repos/{owner}/{repo}`:

- default branch — `GET /repos/{repo}`, reads `default_branch` (null →
`ConfigError`).
- latest commit — `GET /repos/{repo}/commits?sha={branch}&per_page=1`, element
`[0]` (empty → `ProviderAPIError`); the message lives at `commit.message` in
the GitHub payload shape.
- tags — `GET /repos/{repo}/tags?per_page=100`, paginated.
- create — `POST /repos/{repo}/git/refs` with `{"ref":
"refs/tags/{name}", "sha": commit_sha}` (the git-refs endpoint, not a tags
endpoint).

Auth and the GitHub-required headers are set when the client is built
(`_build_github_client`): `Authorization: Bearer <token>`, `Accept:
application/vnd.github+json`, `X-GitHub-Api-Version: 2022-11-28`.

## HTTP client

Every request goes through an `httpware.Client` constructed in
`semvertag/ioc.py`. The factories set `base_url` (from the provider's
`endpoint`), `timeout` (`settings.request_timeout`), the auth headers above, and
a single middleware: `httpware.Retry` over status codes
`{408, 429, 500, 502, 503, 504}`. Both clients are eagerly resolved by
modern-di, which is safe because httpx2 connection pools are lazy — the unused
client opens no sockets. Clients are closed by a modern-di cache finalizer
(`_close_client`). Responses are decoded by httpware against pydantic
`response_model`s (`_ProjectResponse`, `_CommitList`, `_TagList`, …) via the
`get` / `send_with_response` helpers; a decode failure surfaces as
`httpware.DecodeError` and is translated to `ProviderAPIError`.

## Link-header pagination

Tag listing walks RFC 8288 `Link` headers. `semvertag/_link_pagination.py`
exposes `next_page_url(response, *, current_url)`, which parses the `Link`
header, finds the `rel="next"` entry, and resolves it against the current URL
(returning `None` when there is no next page). Both providers call it inside
`list_tags`: after each page they request the next URL until `next_page_url`
returns `None`, capped at `_MAX_TAG_PAGES = 100` (exceeding it raises
`ProviderAPIError`). Before following a next URL, `same_origin(next_url,
endpoint)` checks that scheme + netloc match the configured endpoint; a
cross-host `Link` is refused with `ProviderAPIError` so a malicious or
misconfigured server cannot redirect a token-bearing request to another host.

## Secret redaction

`semvertag/_redact.py` exposes `redact(text)`, which substitutes `***` for any
substring matching known token shapes — GitLab `glpat-…`, GitHub
`github_pat_…` / `ghp_` / `gho_` / `ghu_` / `ghs_` / `ghr_…`, Bitbucket
`ATBB…`, and any bare 32+-char hex run. It is applied at the output boundary:
`RichOutput` (`semvertag/_output.py`) runs `redact` on all three paths
(`progress`, `emit`, `error`). `JsonOutput` is more selective: `progress` is a
no-op (nothing is printed), `emit` writes the result envelope as raw JSON
without redaction, and only `error` passes its message through `redact` before
printing to stderr. A token that leaks into an error message never reaches the
terminal, but a token embedded in a result field would appear unredacted in JSON
output.

## Errors

`semvertag/_errors.py` defines the domain hierarchy, each carrying an
`exit_code` the CLI uses verbatim: `SemvertagError` (1, base), `ConfigError`
(2), `AuthError` (3), `ProviderAPIError` (4). `semvertag/providers/_errors.py`
translates raw `httpware.ClientError`s into these via `translate_gitlab` /
`translate_github`, which split into:

- status errors — 401/403 → `AuthError` (with forge-specific scope hints),
404 → `ConfigError` (project/repo not found), 422 → `ConfigError`,
429/5xx → `ProviderAPIError` (retries exhausted), other → `ProviderAPIError`.
- transport errors (shared `_translate_transport`) — `DecodeError`,
`TimeoutError`, `RetryBudgetExhaustedError`, `NetworkError`, and a fallback
all → `ProviderAPIError`.
- tag-creation specials — a 400 ("already exists", GitLab) or 422
("already_exists", GitHub) on create becomes a `ConfigError` naming the tag,
distinguishing a concurrent/duplicate run from a malformed request.
Loading