feat: derive versions from git tags and cut releases from CI - #49
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds tag-driven release versioning, a
--versionflag, and opt-in update checking.What this does
tools/next_version.pyowns the entire version policy, in Python where it can be unit-tested rather than in YAML where it cannot. Anyfeat:commit (or a!subject /BREAKING CHANGE:footer) since the last final tag takes the batch toX.(Y+1).0; a batch of only fixes, docs and chores takes it toX.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 andrelease.ymlpublishes it.tagjob inci.ymlcutsvX.Y.ZrcNcandidates onnightlyand promotes the same target tovX.Y.Zplus a GitHub release onmain. 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.version = "0.1.0", so the package version is whatever tag the artifact was built from and there is no second copy to drift.--versionreads installed distribution metadata, printingunknown (running from source)from a checkout.--check-updateis an on-demand check. The launch-time check is off unless explicitly enabled viacheck_for_updatesinconfig.json, and an absent key means off.Two deliberate departures from hate_crack
Update checking defaults to off. hate_crack's
check_for_updatesdefaults toTrueand callsapi.github.comon 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 inworkflow_run-triggered workflows. That was the original design and it was rejected: zizmor — already a required job here — ratesworkflow_runan error-level dangerous trigger and exits 14, and this repo does not silence findings with ignore comments. Aneeds-gated job buys the same "only tag what passed CI" guarantee without the trigger, and drops two footguns along the way (thehead_shacheckout dance, and the rule that the workflow had to live on the default branch).What is guarded, and why
tests/test_release_versioning.pyexists 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:nightlyand published them, with all 20 tests passing.$GITHUB_OUTPUTkey would have produced a permanent green check with zero tags, forever.Both now fail loudly, as do a reverted
fetch-depth, a droppedneedsentry, a missingnightlypush trigger, a widenedif:, 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 tonightlytreats the whole history as one batch and cutsv0.1.0rc1; the first merge tomainthen cutsv0.1.0.Verification
1160 passed, 8 skipped, 100% coverage.
ruff,bandit(33 findings, unchanged — the one addition is a reviewedB310on a hardcoded-httpsurlopen, baseline regenerated deliberately rather than suppressed inline),uv lock --check,actionlintandzizmorall exit 0. No inline suppressions were added anywhere.🤖 Generated with Claude Code