Skip to content

feat: derive versions from git tags and cut releases from CI - #49

Merged
bandrel merged 22 commits into
nightlyfrom
feat/auto-versioning
Aug 26, 2026
Merged

feat: derive versions from git tags and cut releases from CI#49
bandrel merged 22 commits into
nightlyfrom
feat/auto-versioning

Conversation

@bandrel

@bandrel bandrel commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

Adds tag-driven release versioning, a --version flag, and opt-in update checking.

What this does

  • tools/next_version.py owns the entire version policy, in Python where it can be unit-tested rather than in YAML where it cannot. Any feat: commit (or a ! subject / BREAKING CHANGE: footer) since the last final tag takes the batch to X.(Y+1).0; a batch of only fixes, docs and chores takes it to X.Y.(Z+1). The major is never bumped automatically — a breaking marker counts as a feature, because an automatic major is an irreversible published mistake one mistyped subject line away. Push a major by hand and release.yml publishes it.
  • A tag job in ci.yml cuts vX.Y.ZrcN candidates on nightly and promotes the same target to vX.Y.Z plus a GitHub release on main. Aiming candidates one version forward is what makes them sort correctly: 0.0.0 < 0.1.0rc1 < 0.1.0 < 0.2.0rc1 < 0.2.0.
  • hatch-vcs replaces the static version = "0.1.0", so the package version is whatever tag the artifact was built from and there is no second copy to drift.
  • --version reads installed distribution metadata, printing unknown (running from source) from a checkout.
  • --check-update is an on-demand check. The launch-time check is off unless explicitly enabled via check_for_updates in config.json, and an absent key means off.

Two deliberate departures from hate_crack

Update checking defaults to off. hate_crack's check_for_updates defaults to True and calls api.github.com on every launch. SpooNMAP runs from jumpboxes inside client networks, where that is an unauthorised outbound beacon from an engagement host. A test patches the gate and asserts it is never called under a default config, so this cannot regress silently.

Tagging lives inside ci.yml, not in workflow_run-triggered workflows. That was the original design and it was rejected: zizmor — already a required job here — rates workflow_run an error-level dangerous trigger and exits 14, and this repo does not silence findings with ignore comments. A needs-gated job buys the same "only tag what passed CI" guarantee without the trigger, and drops two footguns along the way (the head_sha checkout dance, and the rule that the workflow had to live on the default branch).

What is guarded, and why

tests/test_release_versioning.py exists because this wiring fails silently — no error, tags just stop appearing or appear wrong. Mutation testing during development found seven guards that did not guard, including two that would have shipped:

  • Swapping the channel branches would have cut final releases on nightly and published them, with all 20 tests passing.
  • Renaming a $GITHUB_OUTPUT key would have produced a permanent green check with zero tags, forever.

Both now fail loudly, as do a reverted fetch-depth, a dropped needs entry, a missing nightly push trigger, a widened if:, and a push that moves anything other than the one computed tag. The behavioural guards extract the step script from the YAML and run it against a real git repo and a real bare remote, because substring assertions on YAML proved defeatable.

First run

The repo has no tags and this history contains feat: subjects, so the first push to nightly treats the whole history as one batch and cuts v0.1.0rc1; the first merge to main then cuts v0.1.0.

Verification

1160 passed, 8 skipped, 100% coverage. ruff, bandit (33 findings, unchanged — the one addition is a reviewed B310 on a hardcoded-https urlopen, baseline regenerated deliberately rather than suppressed inline), uv lock --check, actionlint and zizmor all exit 0. No inline suppressions were added anywhere.

🤖 Generated with Claude Code

bandrel and others added 22 commits August 26, 2026 13:01
Ports hate_crack's release versioning: a shared tools/next_version.py
policy module, CI-gated tagging workflows for the nightly and main
channels, and a hatch-vcs version derived from git tags.

Inverts one hate_crack default deliberately. Its check_for_updates
config key defaults to True and its startup path calls out to
api.github.com on every launch. SpooNMAP runs from jumpboxes inside
client networks, so the capability is ported but the launch-time check
is off unless the operator opts in, with a test that fails if a network
call reappears under a default config.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Seven tasks, each ending in a testable deliverable and a commit.

Three spec claims were checked against reality while writing this and
corrected in the plan: a shallow clone does not fail hatch-vcs, it silently
versions from no tag; uv build's sdist-to-wheel path works fine; and
_config_bool() already exists rather than needing to be added.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ported from hate_crack, where it replaced ~70 lines of `cut -d.` version
arithmetic duplicated across two workflow files. The policy lives in Python
so it can be unit-tested; nothing in YAML parses or increments a version.

Adapted for a 3.8 floor: the module-level Version alias is evaluated at
import, so PEP 585 builtin generics would break the test-legacy CI job.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
FINDING 1: Fixed spec violation - replaced all three remaining references to
nightly-dev branch naming with nightly (SpooNMAP's convention):
- test_main_cuts_the_final_of_the_same_target docstring (line 189)
- test_baseline_need_not_be_reachable_from_head docstring (line 303)
- checkout command creating nightly branch instead (line 322)

FINDING 2: Improved feature detection guard fixture to test realistic scenario
where git revert/squash-merge bodies contain feat: lines, while subject is fix:.
Fixture now guards against unanchored FEATURE_SUBJECT regex patterns.

FINDING 3: Added comprehensive CLI boundary tests covering:
- main() printing candidate tag for nightly channel (regex: ^v\d+\.\d+\.\d+rc\d+$)
- main() printing final tag for stable channel (regex: ^v\d+\.\d+\.\d+$)
- main() printing nothing when no commits since baseline (empty batch)
- git_tags() returning actual tags from a real repository

All 49 tests now pass (43 original + 2 SpooNMAP + 4 CLI boundary).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replaces the static version = "0.1.0", which had no relationship to
anything published and would drift the moment tags started being cut.

The build job's checkout gains fetch-depth: 0. A shallow clone does not
fail here -- verified -- it silently versions the artifacts from no tag at
all, which is why the job now asserts the version it produced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds tools/ to the sdist required entries list and implements a
set-equality assertion matching the existing nse/ check. This prevents
stray files in tools/ (e.g. operator scratch or backup files) from
silently shipping in sdist releases.

Proof: creating tools/scratch.py.backup causes the assertion to fail with
'extra: [scratch.py.backup]'; deleting it makes the assertion pass with
'tools/ matches git-tracked files exactly (1 files)'.

Also updates uv.lock to reflect the dynamic version change from Task 2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nightly cuts vX.Y.ZrcN candidates, main promotes the same target to its
final release and publishes it. Both call tools/next_version.py; neither
does version arithmetic in YAML.

ci.yml now runs on pushes to nightly. It did not before, so there would
have been no successful CI run for nightly-tag.yml's workflow_run trigger
to key on and it would have silently never fired.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Tagging now uses a needs-gated job in ci.yml rather than a separate
workflow_run trigger, eliminating a zizmor error[dangerous-triggers]
finding (workflow_run is the standard privilege-escalation vector in
GitHub Actions).

Deleted auto-tag.yml and nightly-tag.yml. Their logic is now a single
`tag` job in ci.yml, gated on all validation jobs passing. This removes
the head_sha checkout dance and the rule that nightly-tag.yml had to
live on the default branch.

release.yml now uses gh release create (already in the runner) instead of
softprops/action-gh-release, unifying both release paths on identical logic.

Both linters now pass: actionlint exit=0, zizmor exit=0 (0 findings).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
zizmor rates workflow_run an error-level dangerous trigger and exits 14,
and this repo does not silence findings with ignore comments, so tagging
became a needs-gated job inside ci.yml instead. Task 3's original YAML is
kept as the record of what was tried; Tasks 6 and 7 are rewritten for the
shape that shipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Reads the version from distribution metadata rather than a literal in
spoonmap.py, since the version is derived from git tags at build time and a
literal would be a second copy that drifts.

Running from a checkout has no metadata to read, which is the documented
invocation, so that reports a non-numeric 'unknown' sentinel rather than a
number the update check could compare against.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
hate_crack's equivalent defaults check_for_updates to True and calls out to
api.github.com on every launch. SpooNMAP runs from jumpboxes inside client
networks, where that is an unauthorised outbound beacon from an engagement
host, so the key defaults to false and absent means false.

The gate lives in _maybe_check_for_updates() rather than inline in main(),
which is under pragma: no cover -- 'does a default config reach the
network' is the one question here that must not go untested, and its test
patches urlopen to raise if it is called at all.

Baseline regenerated for one new bandit B310 on the urlopen call. The URL
is a hardcoded https literal; B310 is scheme-blind and cannot see that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fixes 8 issues identified in review:

BLOCKING 1: Opt-in guard test now uses mock-based verification instead of
exception tripwire that gets swallowed by the broad except clause. Proven
to fail when guard is removed.

BLOCKING 2: Non-string tag_name values (int or dict) now coerced to string
before parsing. Proxies and captive portals return valid JSON with
unexpected field types. New test covering numeric and dict tag_name.
Proven to fail without str() coercion.

3: Moved tag_name extraction outside try block and added isinstance check
for payload. Prevents masking of real failures inside parsing. Proven to
fail with bracket access instead of .get().

4: Added positive assertion that 'Update available' is emitted when a newer
release exists (was only checking absence elsewhere).

5: Added test pinning that check_for_updates defaults to False in sample.

6: Added assertion that 'is up to date' is actually printed when current
equals latest.

7: Added assertion that nothing is claimed on unparseable JSON path.

8: Removed unused urllib.error import and rewrote timeout docstring to
correctly describe that getaddrinfo can block past timeout.

All three mutation proofs (items 1, 2, 3) included in report.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The isinstance(payload, dict) guard at spoonmap.py:5910 handles the case
where JSON parsing succeeds but returns a list or bare string — common
in proxy/captive-portal error pages. Add test covering both list and
string payloads, asserting _check_for_updates() does not raise and
claims no update.

Restores coverage to 100%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The policy is unit-tested; the wiring around it is what fails silently. A
tag job that stops depending on a test job, a step that stops calling
next_version.py, a reverted fetch-depth, or a tag pushed without being the
one computed all produce no error -- tags just quietly stop appearing, or
appear wrong.

Behavioural guards extract the step script from the YAML and run it against
a real repo and a real bare remote. Substring assertions on YAML were
defeated in hate_crack by replacing the whole if/else with an unconditional
push while every test still passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Address review feedback on 11 issues:

CRITICAL (2):
- Add test_compute_tag_step_assigns_correct_channel: Parses GITHUB_OUTPUT
  to catch renamed output keys and inverted channel logic
- Validates both channel and new_tag keys with proper types

IMPORTANT (3):
- Assert full normalized if expressions, not substrings (tag job condition,
  release step condition)
- Positive assertions for release workflow (gh release create exists)

MINOR (4):
- Type coercion: Compare fetch-depth/persist-credentials as strings
- Add concurrency group branch-specificity test
- Add timeout-minutes requirement test for all jobs
- Scope exactly_one_call and no_shell_version_arithmetic to tag job only

HARNESS (2):
- Minimal environment for _run_create_tag and compute tag subprocess
  (PATH, HOME, explicit vars only—no inherited CI variables)
- Prevents silent fallback to real GITHUB_REF on CI runners

SELF-UPDATING (1):
- Exclude publish-like jobs (where needs contains 'tag') from expectations
- Future publish jobs won't create test failures or cycle detection issues

All six mutation proofs verified: channel inversion, output key renames (2),
operator precedence flip, conditional appending, step deletion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ITEM 1 (Important): Replace derived exemption in test_only_the_tag_job_can_write
  with explicit allowlist (MAY_DECLARE_PERMISSIONS = {'tag'}). Derived rule
  was self-exempting: any job with needs: [tag] could silently declare
  arbitrary permissions. Explicit allowlist prevents this escape hatch.

ITEM 2 (Minor): Normalize needs check with helper to handle string/list forms.
  String membership test ('tag' in needs_string) was substring matching,
  so 'needs: build-tag' would be wrongly exempted.

ITEM 3 (Minor): Require non-empty new_tag values in test_compute_tag_step
  _assigns_correct_channel. Repo state has commits since last tag, so
  tools/next_version.py must return non-empty. Catches mutations that write
  then immediately blank the value.

All three proofs verified: hypothetical publish job now fails permissions test,
blank-value mutation now fails new_tag test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Remove time bomb: previous fix required non-empty new_tag, which fails
when a tag lands on HEAD (legitimate scenario where tools/next_version.py
returns empty). This breaks healthy repos during normal tagging.

Correct approach: validate value shape without guarding.

Changes to test_compute_tag_step_assigns_correct_channel:
- Assert both new_tag and channel keys present (catches renames)
- Assert channel value matches expected for branch
- Assert new_tag is empty OR matches channel-appropriate regex:
  * stable: ^v\d+\.\d+\.\d+$ (e.g. v0.1.0)
  * nightly: ^v\d+\.\d+\.\d+rc\d+$ (e.g. v0.1.0rc1)
- Count key occurrences in raw file (catches append mutations)

Accepts legitimate empty values (HEAD on tag) while catching mutations
that create duplicate key lines (append after real write).

Proven: duplicate-key mutation fails on count check; legitimate empty
value passes all validations when tested against tagged repo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Records what fails silently rather than loudly -- the nightly CI trigger,
the tag job's needs list, fetch-depth on two jobs, and the
persist-credentials exception -- since each produces no error, just tags
that quietly stop appearing or appear wrong.

Also records why tagging is a needs-gated job rather than a workflow_run
workflow, so the rejected design is not reintroduced by someone reading
the upstream project it was ported from.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous sentence claiming the test patches urllib.request.urlopen to
raise was false and misleading — that approach was removed because
_check_for_updates() has a broad except Exception that swallows even the
test's own tripwire.

The real mechanism patches _check_for_updates() one layer up and asserts it
was never called when disabled. Documented the failure and why it was
replaced so future readers do not attempt to reproduce an inert approach.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…, CI wiring guards, doc drift)

--check-update now reports a failure instead of exiting silently (the
launch-time path stays silent, matching its courtesy-check contract); this
was previously indistinguishable from "up to date" and guaranteed to fire
since this repo has no releases yet. Adds an end-to-end guard for
main()'s --version/--check-update argv wiring, which was untested despite
_tool_version()/_check_for_updates() being covered in isolation. Documents
that main must contain nightly's commits as ancestors (never squash-merged).
Widens the credential-persistence and ruff-scope guards to cover release.yml
and tools/, tightens the tag-push test to assert no branch is touched, fixes
three comments referencing deleted workflow files, makes check_for_updates
round-trip through a regenerated config.json explicitly, rewords the
version-mismatch message to say "not comparable" instead of "unknown", adds
real assertions behind the update check's timeout/URL safety claims, and
corrects the tag job's cancel-in-progress comment to describe coalescing
rather than "no push is skipped".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…review

The build job's --version end-to-end check required a strict X.Y.Z match,
which an untagged tree's legitimate hatch-vcs dev version (0.0.post1.devN)
never satisfies -- and since the tag job needs build, that deadlocked
tagging until someone hand-pushed a tag. Relaxed to a PEP 440-tolerant
pattern; the step's real signal (non-empty output, not the from-source
sentinel) is unchanged.

Also adds the round-trip test item 7 was missing: check_for_updates: true
surviving _build_interactive_config() -> _write_interactive_config() ->
_load_config(), which a green suite previously permitted to regress
silently. Drops a misnamed/duplicate quiet-default test, adds a floor to
the widened credentials test so it can't pass vacuously, and strengthens
the update-check URL test to assert the call site actually used the
pinned constant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docs/ is not tracked on main, and these two files carry absolute local
paths from the machine they were written on. The durable reasoning they
hold -- why workflow_run was rejected, why update checking defaults off,
what fails silently -- already lives in CLAUDE.md, which is tracked.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@bandrel
bandrel merged commit 6fb74c3 into nightly Aug 26, 2026
13 checks passed
@bandrel
bandrel deleted the feat/auto-versioning branch August 26, 2026 22:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant