From a7a50feadbbe2ddd9175ee58d6c5744a9cfd1c2b Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Wed, 26 Aug 2026 13:01:43 -0400 Subject: [PATCH 01/22] docs: design for tag-driven auto-versioning 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 --- .../2026-08-26-auto-versioning-design.md | 255 ++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-26-auto-versioning-design.md diff --git a/docs/superpowers/specs/2026-08-26-auto-versioning-design.md b/docs/superpowers/specs/2026-08-26-auto-versioning-design.md new file mode 100644 index 0000000..b470347 --- /dev/null +++ b/docs/superpowers/specs/2026-08-26-auto-versioning-design.md @@ -0,0 +1,255 @@ +# Auto-versioning for SpooNMAP + +Date: 2026-08-26 +Status: approved, not yet implemented + +## Goal + +Give SpooNMAP the tag-driven release versioning that hate_crack uses: CI-gated +tags cut automatically from conventional-commit content, a version that comes +from git rather than a hand-maintained string, and a way for an operator to see +which version they are running. Adapted for SpooNMAP's hatchling build and its +`main` + `nightly` branch pair. + +One deliberate divergence from hate_crack, decided up front: **SpooNMAP never +contacts the network at launch unless the operator explicitly turned that on.** +hate_crack's `check_for_updates` config key defaults to `True` and its startup +path calls `check_for_updates()` on every run. SpooNMAP runs from jumpboxes +inside client networks, where an unprompted call to `api.github.com` is an +outbound beacon from an engagement host that nobody authorised. The capability +is ported; the default is inverted and a test holds it there. + +## Versioning policy + +Ordinary semver, with the bump derived from what is in the batch since the last +final tag: + +- Any `feat:` commit (including `feat(scope):`, `feat!:`, or a + `BREAKING CHANGE:` footer on any type) means the batch targets `X.(Y+1).0`. +- A batch of only fixes, docs and chores targets `X.Y.(Z+1)`. +- The major component is never bumped automatically. A breaking marker counts + as a feature, not a major. An automatic major is an irreversible published + mistake waiting for one mistyped subject line, so a major stays an explicit + human act: tag and push it by hand, which `release.yml` then picks up. + +`nightly` cuts release candidates for whichever version the batch is heading +toward — `v0.0.1rc1`, `v0.0.1rc2`, … — and merging down to `main` promotes that +same target to its final release. These are real PEP 440 pre-releases, so they +sort correctly at both ends: + + 0.0.0 < 0.0.1rc1 < 0.0.1rc2 < 0.0.1 < 0.1.0rc1 < 0.1.0 + +The target can change mid-cycle: the first `feat` to land moves it from +`X.Y.(Z+1)` to `X.(Y+1).0`, and candidate numbering restarts for the new target. +That is intended — the number always names what the batch would ship as today. + +The baseline is the highest *final* tag in the repository, deliberately not +restricted to tags reachable from HEAD. `main`'s release tag can sit on a commit +the `nightly` tip does not contain, and a reachability-restricted lookup would +compute the next nightly from a stale baseline and hand out a version below the +release that already shipped. + +### Starting point + +The repository has no tags. The baseline is therefore `(0, 0, 0)`, and no seed +tag is pushed: the first fix-only batch cuts `v0.0.1` and the first batch +containing a feature cuts `v0.1.0`. `pyproject.toml`'s current static +`version = "0.1.0"` is discarded rather than seeded, since the version becomes +derived state (see below) and there is nothing to carry forward. + +Two consequences accepted at design time: + +1. Early releases read as `v0.0.x` even though the tool is in real engagement + use. Acceptable; a human can push a `v1.0.0` by hand whenever that stops + being true, and the policy will build on it from there. +2. The first push to `nightly` after this lands treats the entire history as one + batch, since there is no baseline tag to bound it. If any commit in that + history says `feat:`, the first candidate is `v0.1.0rc1` rather than + `v0.0.1rc1`. + +## Components + +### 1. `tools/next_version.py` + +A port of hate_crack's policy module. Pure functions — `parse_final`, +`latest_final`, `has_feature`, `target_version`, `next_rc_number`, `compute` — +plus a thin git boundary (`git_tags`, `commit_messages`) and a +`--channel stable|nightly` CLI that prints the tag to create, or prints nothing +and exits 0 when there is nothing to tag. + +An empty batch returning "no tag" is not an error: a workflow re-run on an +already-tagged commit lands there, and the right answer is silence rather than a +version nobody asked for. This is *not* a "no feat/fix commits, skip" early +exit — a docs-only or chore-only merge is still a release, it just cuts a patch. + +The whole point of this file is that the policy lives in Python, where it can be +unit-tested, instead of in YAML, where it cannot. Neither workflow parses or +increments a version number. Additions go here, not into a workflow step. + +**Adaptation required for SpooNMAP:** hate_crack's module uses +`Version = tuple[int, int, int]` at module level and `X | None` return +annotations. The alias is a runtime subscript of `tuple` and fails on Python +3.8; SpooNMAP's `test-legacy` CI job runs the whole suite on 3.8 and 3.9, which +collects this module's tests. Use `typing.Tuple` / `typing.Optional` instead, in +both the module and its tests. + +### 2. Tagging workflows + +Three files under `.github/workflows/`: + +- **`nightly-tag.yml`** — `workflow_run` on a successful `CI` run on `nightly`. + Calls `next_version.py --channel nightly`, pushes `vX.Y.ZrcN`. Creates no + GitHub release; these tags exist to make nightly builds addressable and to + give the build backend a version. +- **`auto-tag.yml`** — `workflow_run` on a successful `CI` run on `main`. Calls + `next_version.py --channel stable`, pushes `vX.Y.Z`, then creates the GitHub + release with `gh release create --generate-notes`. The release is created here + rather than left to `release.yml` because GitHub does not dispatch workflow + events for refs pushed with `GITHUB_TOKEN`, so a tag pushed by this job would + never trigger a `push: tags:` workflow. +- **`release.yml`** — `push: tags: v*`. The path for tags a human pushes by + hand, which is what the "no automatic major" rule depends on existing. + +Requirements common to both tagging workflows: + +- `ref: ${{ github.event.workflow_run.head_sha }}` — `workflow_run` defaults to + the tip of the default branch, which is not necessarily the commit CI + validated. +- `fetch-depth: 0` — the version baseline is read from tags. Under a shallow + clone the project version reads as nothing and the job tags nonsense. +- `concurrency` group with `cancel-in-progress: false` — two merges landing + back-to-back would otherwise compute the same tag and the second push would + fail. Serialize rather than cancel so no merge gets skipped. +- Idempotent tag creation: a re-run must not fail the job if the tag or release + already exists. +- Guard on `workflow_run.conclusion == 'success'` so a broken commit is never + tagged or released. + +**`nightly-tag.yml` must live on `main`.** GitHub only dispatches `workflow_run` +for workflows present on the default branch; a copy existing solely on `nightly` +never fires. + +**Credential exception.** SpooNMAP's convention is `persist-credentials: false` +on every checkout. The two tagging jobs push a tag and so must keep credentials +persisted. This is a deliberate, commented exception at each site, not drift. +`release.yml` keeps `persist-credentials: false`, since it only reads. + +### 3. `ci.yml` changes + +Two edits, both load-bearing: + +- **Add `nightly` to the `push` branches.** CI currently runs on `pull_request` + and `push` to `main` only, so a push to `nightly` runs no CI at all and + `nightly-tag.yml`'s `workflow_run` trigger would never fire. hate_crack's + workflow header records hitting exactly this. +- **`fetch-depth: 0` on the `build` job's checkout.** hatch-vcs cannot resolve a + version from a depth-1 clone with no tags, so `uv build` would fail there. + +The existing `workflow-lint` job already runs actionlint and zizmor against the +whole `.github/workflows/` directory, so the three new files are covered with no +edit to that job. + +### 4. `pyproject.toml` + +- `build-system.requires` gains `hatch-vcs`. +- `version = "0.1.0"` becomes `dynamic = ["version"]`, with + `[tool.hatch.version] source = "vcs"` and setuptools-scm's `no-guess-dev` + version scheme and `no-local-version` local scheme, matching hate_crack. +- `tools/` joins the sdist `include` allowlist. +- `pyyaml` joins the `dev` group, for the workflow guard tests. + +No commitizen. hate_crack pins it and configures `[tool.commitizen]`, but its +workflows call `next_version.py` and never actually run `cz bump`, so porting it +would add a pinned dependency that nothing executes. + +### 5. `--version` + +A module-level `_tool_version()` helper in `spoonmap.py` reading +`importlib.metadata.version('spoonmap')`, falling back to a "running from +source" string on `PackageNotFoundError` — the tool is frequently invoked as a +plain script from a checkout, where no distribution metadata exists. Wired into +`main()` beside the existing `--cleanup` dispatch. + +The helper lives outside `main()`'s `# pragma: no cover` region so it is +testable directly, the same reasoning that keeps `_operator_dir()` a module-level +function. + +### 6. Opt-in update checking + +`_check_for_updates()` in `spoonmap.py`: GET +`https://api.github.com/repos/trustedsec/spoonmap/releases/latest`, compare the +tag against `_tool_version()`, print a one-line notice if a newer release +exists. + +- **Off unless explicitly enabled.** `_load_config()` gains an optional + `check_for_updates` key, absent-means-false, and that key is the only way to + enable the launch-time check. It is not a required key; a config that never + mentions it is valid and inert. This needs a `_config_bool(key, value, + default)` helper alongside the existing `_config_int()`, warning and taking + the default on a non-boolean value. +- **`config.json.sample` ships it explicitly `false`,** so the safe state is + also the documented one. +- **`--check-update`** performs one check and exits, regardless of config, so an + operator can ask without leaving the startup check enabled. +- **Stdlib only.** `spoonmap.py` is deliberately dependency-free, so this uses + `urllib.request` with a short timeout — not `requests` — and a small local + version-tuple comparison rather than `packaging.parse`. `spoonmap.py` cannot + import `tools/next_version.py`; that module is build tooling, not a runtime + dependency, and the wheel ships `spoonmap.py` alone. +- **Every failure is swallowed** into at most a one-line warning. A failed or + slow update check must never delay, prompt, or abort a scan. +- **An unknown local version is not an update.** When `_tool_version()` returns + its running-from-source fallback there is nothing to compare against, so the + check reports the latest release as information and never claims an upgrade is + available. Guessing "newer" there would nag every operator running from a + checkout, which is most of them. +- **Nightly RCs stay invisible.** GitHub's `releases/latest` endpoint excludes + prereleases, so candidate tags are never advertised as updates. + +### 7. Documentation + +`CLAUDE.md` gains a section covering the release policy, the branch-to-channel +mapping, why `nightly-tag.yml` must live on `main`, the `persist-credentials` +exception, and the opt-in-off-by-default rule for update checking. `README.md` +documents `--version`, `--check-update`, and the `check_for_updates` config key +with its default. + +## Testing + +- **`tests/test_next_version.py`** — the pure policy: bump selection across + feat/fix/docs/breaking batches, subject anchoring (a `feat` mentioned + mid-sentence in a fix body must not promote the batch), RC numbering including + the target-changed-mid-cycle restart, baseline selection from a mixed tag + list, and the empty-batch `None`. +- **`tests/test_release_versioning.py`** — the wiring that breaks silently: + that `ci.yml` pushes on `nightly`; that each tagging workflow calls + `next_version.py` exactly once and pushes the tag it printed; that no shell + version arithmetic has crept back in. Following hate_crack's recorded lesson, + the load-bearing guards (computed tag value, tag idempotency, empty-batch + path) assert behaviour by extracting the step script from the YAML and running + it against a real git repository and a real bare remote — substring assertions + on YAML are sensitive to formatting and blind to behaviour, and a reviewer + defeated hate_crack's substring version without any test failing. +- **Inert-default guard** — with a config that does not mention + `check_for_updates`, `urllib.request.urlopen` is patched to raise if called at + all, so a future change that reintroduces a launch-time network call fails the + suite instead of shipping. This follows the precedent already recorded in + `CLAUDE.md` for defaulting a config to something inert and asserting it. +- **`_check_for_updates()` and `_tool_version()`** — the explicit-true path, the + swallowed-failure path, both branches of the metadata lookup, and the version + comparison. + +The repo's 95% coverage floor and `ruff`/`bandit` gates apply as usual. +`tools/next_version.py` is not under `--cov=spoonmap`, so its tests run without +contributing to that floor; coverage stays scoped to `spoonmap.py`. + +## Out of scope + +- Publishing to PyPI. hate_crack has a `pypi-placeholder.yml`; SpooNMAP has no + publish infrastructure and a prototyped hatch build hook was already dropped + once for that reason. +- Automatic major bumps. +- A `--update` self-update command. hate_crack has one; nothing here needs it, + and it is a separate decision from knowing an update exists. +- Rewriting `CHANGELOG.md`. Release notes come from + `gh release --generate-notes`. From cbf825fb102463d2b5d7e6e77abb1063449d37a9 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Wed, 26 Aug 2026 13:12:45 -0400 Subject: [PATCH 02/22] docs: implementation plan for auto-versioning 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 --- .../plans/2026-08-26-auto-versioning.md | 1561 +++++++++++++++++ 1 file changed, 1561 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-26-auto-versioning.md diff --git a/docs/superpowers/plans/2026-08-26-auto-versioning.md b/docs/superpowers/plans/2026-08-26-auto-versioning.md new file mode 100644 index 0000000..7ac0245 --- /dev/null +++ b/docs/superpowers/plans/2026-08-26-auto-versioning.md @@ -0,0 +1,1561 @@ +# Auto-Versioning Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Give SpooNMAP tag-driven release versioning — CI-gated tags cut automatically from conventional-commit content, a package version derived from git rather than a hand-maintained string, an operator-visible `--version`, and an update check that is off unless explicitly enabled. + +**Architecture:** A pure policy module (`tools/next_version.py`) decides what the next tag is; two `workflow_run`-triggered workflows call it and push the tag it prints, one per channel (`nightly` cuts `vX.Y.ZrcN` candidates, `main` cuts the `vX.Y.Z` final plus a GitHub release). `hatch-vcs` reads the resulting tags to produce the package version, and `spoonmap.py` reads that version back out of installed distribution metadata. + +**Tech Stack:** Python 3.8+ stdlib, hatchling + hatch-vcs, GitHub Actions, pytest, uv. + +**Spec:** `docs/superpowers/specs/2026-08-26-auto-versioning-design.md` — read it before starting. Where this plan and the spec disagree, this plan wins: three spec claims were checked against reality while writing it and corrected (noted inline at Tasks 2, 3 and 5). + +## Global Constraints + +- **Working directory is the worktree `/tmp/spoonmap-auto-versioning`, branch `feat/auto-versioning`.** Do not edit `/Users/justinbollinger/projects/spoonmap` directly. +- **`spoonmap.py` stays dependency-free stdlib.** No `requests`, no `packaging`, no import of anything under `tools/`. The wheel ships `spoonmap.py` alone. +- **Python floor is 3.8** for `spoonmap.py`, `tools/`, and everything under `tests/`. The `test-legacy` CI job runs the whole suite on 3.8 and 3.9. That means no `tuple[int, int, int]` / `list[str]` / `X | None` evaluated at runtime — use `typing.Tuple`, `typing.List`, `typing.Optional`. Under `from __future__ import annotations`, function *annotations* are fine; a module-level type alias is not, because it is evaluated. +- **Test commands:** `uv run pytest tests/` for the suite; `uv run pytest tests/test_next_version.py -v` for one module. The 95% coverage floor lives in `pyproject.toml`'s `addopts` and applies to every run. +- **Lint/SAST:** `uv run --frozen ruff check spoonmap.py tests/ tools/` and `uv run --frozen bandit -r spoonmap.py -c pyproject.toml -b .bandit-baseline.json`. +- **No `# nosec`, no `# noqa`, no rule downgrades.** If bandit reports a new finding, regenerate `.bandit-baseline.json` deliberately and justify the addition in the commit message. +- **Conventional commits.** Subjects are `feat:`, `fix:`, `docs:`, `chore:`, `test:`. This is now load-bearing: `tools/next_version.py` reads these subjects to pick the bump. +- **Repo slug is `trustedsec/spoonmap`.** Default branch `main`, dev branch `nightly`. +- **No seed tag is pushed.** The repo has no tags and stays that way; the policy's zero baseline is intended. + +## File Structure + +| File | Status | Responsibility | +|---|---|---| +| `tools/next_version.py` | create | The entire version policy. Pure functions plus a thin git boundary and a `--channel` CLI. Nothing else parses or increments a version. | +| `tests/test_next_version.py` | create | Unit tests for the policy. | +| `tests/test_release_versioning.py` | create | Guards the wiring that breaks silently: CI triggers, workflow steps, config coherence. | +| `.github/workflows/nightly-tag.yml` | create | RC tags on `nightly`. | +| `.github/workflows/auto-tag.yml` | create | Final tags + GitHub release on `main`. | +| `.github/workflows/release.yml` | create | Release for hand-pushed tags. | +| `.github/workflows/ci.yml` | modify | Add `nightly` to push triggers; `fetch-depth: 0` on `build`; extend ruff and legacy-test deps. | +| `pyproject.toml` | modify | hatch-vcs dynamic version; sdist includes `tools/`; dev group gains `pyyaml` + `packaging`. | +| `spoonmap.py` | modify | `_tool_version()`, `_check_for_updates()`, `_maybe_check_for_updates()`, `--version` / `--check-update` dispatch, `check_for_updates` config key. | +| `tests/test_spoonmap.py` | modify | Tests for the four functions above, including the inert-default network guard. | +| `config.json.sample` | modify | Document `check_for_updates`, explicitly `false`. | +| `README.md` | modify | Document the flags and the config key. | +| `CLAUDE.md` | modify | Document the release policy and its non-obvious constraints. | + +--- + +### Task 1: The version policy module + +**Files:** +- Create: `tools/next_version.py` +- Test: `tests/test_next_version.py` + +**Interfaces:** +- Consumes: nothing. +- Produces, all importable as `from tools.next_version import ...`: + - `parse_final(tag: str) -> Optional[Tuple[int, int, int]]` + - `latest_final(tags: List[str]) -> Tuple[int, int, int]` + - `has_feature(messages: List[str]) -> bool` + - `target_version(base: Tuple[int, int, int], messages: List[str]) -> Optional[Tuple[int, int, int]]` + - `next_rc_number(target: Tuple[int, int, int], tags: List[str]) -> int` + - `format_version(version: Tuple[int, int, int]) -> str` + - `compute(channel: str, tags: List[str], messages: List[str]) -> Optional[str]` + - `git_tags(repo_dir: str) -> List[str]` + - `commit_messages(repo_dir: str, base: Tuple[int, int, int]) -> List[str]` + - `main(argv: Optional[List[str]] = None) -> int` + - CLI: `python3 tools/next_version.py --channel {stable,nightly} [--repo-dir DIR]` prints the tag to create, or prints nothing, and exits 0 either way. + +This module is a port of `/Users/justinbollinger/projects/hate_crack/tools/next_version.py`. Copy it rather than retyping it — the policy is subtle and transcription errors here are silent. + +- [ ] **Step 1: Copy the module and its tests** + +```bash +cd /tmp/spoonmap-auto-versioning +mkdir -p tools +cp /Users/justinbollinger/projects/hate_crack/tools/next_version.py tools/next_version.py +cp /Users/justinbollinger/projects/hate_crack/tests/test_next_version.py tests/test_next_version.py +``` + +- [ ] **Step 2: Run the tests to see where the copy stands** + +Run: `uv run pytest tests/test_next_version.py -v` + +Expected: PASS. If anything fails, fix it before continuing — you are looking at a policy bug, not a porting artifact. + +- [ ] **Step 3: Make the module Python 3.8-safe** + +In `tools/next_version.py`, the module-level alias is evaluated at import and fails on 3.8. Replace: + +```python +Version = tuple[int, int, int] +``` + +with: + +```python +from typing import List, Optional, Tuple + +# Evaluated at import, so it cannot use PEP 585 builtin generics: the whole +# suite runs on 3.8 in the `test-legacy` CI job. +Version = Tuple[int, int, int] +``` + +placing the `typing` import with the other imports at the top. Then replace every `X | None` annotation with `Optional[X]` and every `list[str]` with `List[str]` throughout the file. `from __future__ import annotations` stays. + +- [ ] **Step 4: Verify it imports on the actual floor** + +Run: +```bash +uv run --isolated --no-project --python 3.8 python -c "import sys; sys.path.insert(0, '.'); import tools.next_version as n; print(n.compute('nightly', [], ['fix: x']))" +``` +Expected: prints `v0.0.1rc1`. A `TypeError: 'type' object is not subscriptable` means a PEP 585 generic survived Step 3. + +- [ ] **Step 5: Adapt the tests to SpooNMAP** + +In `tests/test_next_version.py`: apply the same 3.8 fixes if any annotation in it uses builtin generics, and rewrite the module docstring so it describes SpooNMAP's branches (`nightly`, not `nightly-dev`) and does not reference hate_crack's two abandoned schemes, which never existed here. Keep every test — the policy is identical and the historical hazards it guards are real. Then append the two cases specific to starting from zero: + +```python +def test_a_repository_with_no_tags_cuts_the_first_patch(): + """SpooNMAP starts from zero: no seed tag is pushed, deliberately.""" + assert compute("stable", [], ["fix: first fix"]) == "v0.0.1" + + +def test_a_first_batch_containing_a_feature_cuts_the_first_minor(): + """The whole history is one batch on the first run, so a single `feat` + anywhere in it takes the first release to 0.1.0 rather than 0.0.1.""" + assert compute("nightly", [], ["fix: a", "feat: b", "docs: c"]) == "v0.1.0rc1" +``` + +- [ ] **Step 6: Run the adapted tests** + +Run: `uv run pytest tests/test_next_version.py -v` +Expected: PASS, including the two new tests. + +- [ ] **Step 7: Add `packaging` to the dev group** + +`tests/test_next_version.py` imports `packaging.version.parse` to assert tag ordering against a real PEP 440 parser. Add it to `[dependency-groups].dev` in `pyproject.toml`: + +```toml + # Test-only. tests/test_next_version.py asserts candidate/release ordering + # against a real PEP 440 parser rather than by eyeball, because two + # different pre-release schemes have been got wrong before. Not a runtime + # dependency: spoonmap.py is stdlib-only. + "packaging>=24.0", +``` + +Then run `uv lock` (the `lint` CI job runs `uv lock --check` and fails on a stale lock). + +- [ ] **Step 8: Lint and commit** + +```bash +cd /tmp/spoonmap-auto-versioning +uv run --frozen ruff check spoonmap.py tests/ tools/ +uv run pytest tests/test_next_version.py -v +git add tools/next_version.py tests/test_next_version.py pyproject.toml uv.lock +git commit -m "feat: add tools/next_version.py, the release version policy + +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." +``` + +--- + +### Task 2: Derive the package version from git tags + +**Files:** +- Modify: `pyproject.toml` (build-system, `[project]`, sdist include, new `[tool.hatch.version]`) +- Modify: `.github/workflows/ci.yml` (`build` job checkout, ~line 363) + +**Interfaces:** +- Consumes: nothing from Task 1 at runtime; the tags Task 3 pushes are what this reads. +- Produces: a distribution whose version comes from `git describe`. `importlib.metadata.version('spoonmap')` returns it once installed — Task 4 depends on that. + +**Correction to the spec:** the spec says a shallow clone makes `uv build` *fail*. It does not. Verified against a real build: a depth-1 clone builds successfully and produces a silently **wrong** version (`0.0.post1.dev1` instead of `0.0.1.post1.dev1`), because the tag it should have described from was never fetched. `fetch-depth: 0` therefore guards against silent mis-versioning, which is worse than a crash, not against a build error. The spec's related worry about `uv build` building the wheel from the sdist (where there is no `.git`) is also unfounded — verified working, because hatch-vcs records the version in the sdist metadata. + +- [ ] **Step 1: Switch the build to hatch-vcs** + +In `pyproject.toml`, change the build backend requirements: + +```toml +[build-system] +requires = ["hatchling", "hatch-vcs"] +build-backend = "hatchling.build" +``` + +In `[project]`, delete `version = "0.1.0"` and add `dynamic`: + +```toml +[project] +name = "spoonmap" +dynamic = ["version"] +description = "masscan + nmap orchestration wrapper for fast network scanning" +``` + +Add a new section (put it directly above `[tool.hatch.build.targets.wheel]`): + +```toml +# The version is derived from git tags, not stored here. Tags are cut by +# .github/workflows/{auto,nightly}-tag.yml from tools/next_version.py, so a +# hand-maintained version string would only ever be a second, drifting copy +# of what the tags already say. +# +# no-guess-dev an untagged commit after v0.0.1 reads 0.0.1.post1.dev1 +# rather than guessing the next release it might become. +# no-local-version drops the +g suffix, which is not a valid version +# for an index and makes tag-to-artifact comparison noisy. +[tool.hatch.version] +source = "vcs" +raw-options = { version_scheme = "no-guess-dev", local_scheme = "no-local-version" } +``` + +- [ ] **Step 2: Ship the policy module in the sdist** + +In `[tool.hatch.build.targets.sdist]`'s `include` list, add `"tools/"` after `"tests/"`. The wheel deliberately does not get it: `tools/` is build tooling, and the wheel ships `spoonmap.py` alone. + +- [ ] **Step 3: Verify the version actually resolves** + +Run: +```bash +cd /tmp/spoonmap-auto-versioning && rm -rf dist && uv build 2>&1 | tail -3 +``` +Expected: two artifacts build. With no tags in the repo yet, the version reads `0.0.post1.devN` — that is correct for a zero baseline, not a bug. Confirm the tag path works too: + +```bash +git tag v0.0.1-planverify && rm -rf dist && uv build 2>&1 | tail -2 && git tag -d v0.0.1-planverify && rm -rf dist +``` +Expected: artifacts named `spoonmap-0.0.1...`. **Delete that scratch tag** — the command above does; confirm with `git tag` printing nothing. + +- [ ] **Step 4: Stop the build job from mis-versioning silently** + +In `.github/workflows/ci.yml`, the `build` job's checkout (~line 363) needs full history. Change it to: + +```yaml + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + # hatch-vcs derives the version from git describe. Under the default + # depth-1 clone this does not fail -- it silently produces a version + # computed from no tag at all (0.0.post1.dev1 where the answer is + # 0.0.1.post1.dev1), so every artifact this job inspects would carry + # a version no release ever had. + fetch-depth: 0 +``` + +- [ ] **Step 5: Assert the built artifacts carry a real version** + +Still in the `build` job, add a step after `Build sdist and wheel`: + +```yaml + - name: Assert artifacts carry a VCS-derived version + run: | + python3 - <<'PYEOF' + import glob + import os + import sys + + # A depth-1 clone yields 0.0.post1.dev1 -- a version derived from no + # tag. Once a tag exists, anything starting 0.0.post means the + # checkout could not see it. This is the assertion that would have + # caught a fetch-depth regression. + names = [os.path.basename(p) for p in glob.glob('dist/*')] + if not names: + sys.exit('no artifacts were built') + import subprocess + tags = subprocess.run( + ['git', 'tag'], capture_output=True, text=True, check=True + ).stdout.split() + if tags and any(n.startswith('spoonmap-0.0.post') for n in names): + sys.exit( + 'artifacts were versioned from no tag despite tags existing ' + '(shallow checkout?): ' + ', '.join(names) + ) + print('artifact versions: ' + ', '.join(names)) + PYEOF +``` + +- [ ] **Step 6: Run the suite and lint** + +Run: `uv run pytest tests/ -q && uv run --frozen ruff check spoonmap.py tests/ tools/` +Expected: PASS, coverage still at or above 95%. + +- [ ] **Step 7: Commit** + +```bash +git add pyproject.toml .github/workflows/ci.yml +git commit -m "feat: derive the package version from git tags via hatch-vcs + +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." +``` + +--- + +### Task 3: The tagging workflows + +**Files:** +- Create: `.github/workflows/nightly-tag.yml` +- Create: `.github/workflows/auto-tag.yml` +- Create: `.github/workflows/release.yml` +- Modify: `.github/workflows/ci.yml` (push triggers, line 5-6) + +**Interfaces:** +- Consumes: `python3 tools/next_version.py --channel {stable,nightly}` from Task 1 — prints a tag or prints nothing. +- Produces: tags `vX.Y.Z` on `main` and `vX.Y.ZrcN` on `nightly`, which Task 2's build reads. + +**Two things that will silently do nothing if you get them wrong:** + +1. `nightly-tag.yml` must exist on `main`. GitHub only dispatches `workflow_run` for workflows present on the *default* branch. A copy living only on `nightly` never fires. Since this branch merges to `nightly` and then down to `main`, that resolves itself — but do not "tidy" the file onto `nightly` only. +2. CI must actually run on `nightly`, or there is no successful CI run for `workflow_run` to key on. That is Step 1. + +- [ ] **Step 1: Make CI run on the nightly branch** + +In `.github/workflows/ci.yml`, change lines 5-6: + +```yaml + push: + # `nightly` is here because nightly-tag.yml triggers on a completed CI run + # for that branch. Without it, pushes to nightly run no CI at all and the + # tagging workflow silently never fires. + branches: [main, nightly] +``` + +- [ ] **Step 2: Create `.github/workflows/nightly-tag.yml`** + +```yaml +name: Nightly Tag + +# Tags `nightly` after CI passes, as a RELEASE CANDIDATE for whichever version +# the batch is heading toward: v0.0.1rc1, v0.0.1rc2, ... for a fix-only cycle, +# v0.1.0rc1 for one containing a feature. Merging down to main then promotes +# that same target to its final release. +# +# These are real PEP 440 pre-releases, so they order correctly at both ends: +# +# 0.0.0 < 0.0.1rc1 < 0.0.1rc2 < 0.0.1 < 0.1.0rc1 < 0.1.0 +# +# Aiming one version forward is what makes that true. A candidate named for the +# *current* version would sort below the release it is heading for. +# +# The target can change mid-cycle: the first `feat` to land moves it from +# X.Y.(Z+1) to X.(Y+1).0 and candidate numbering restarts. That is intended -- +# the number always names what the batch would ship as today. +# +# The policy lives in tools/next_version.py, shared with auto-tag.yml and +# unit-tested in tests/test_next_version.py. Nothing here parses or increments a +# version number. Do not add that here -- add to the module, where it is tested. +# +# No GitHub release is created; see the end of this file. +# +# This file MUST live on the default branch (main). GitHub only dispatches +# workflow_run for workflows present on the default branch, so a copy existing +# solely on `nightly` never fires. +on: + workflow_run: + workflows: ["CI"] + types: + - completed + branches: + - nightly + +permissions: + contents: write + +# Two pushes landing back-to-back would otherwise both compute the same tag and +# the second push would fail. Serialize instead of cancelling so no push is +# skipped. +concurrency: + group: nightly-tag + cancel-in-progress: false + +jobs: + tag: + runs-on: ubuntu-latest + timeout-minutes: 10 + if: >- + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # workflow_run defaults to the tip of the default branch, which is not + # the commit CI validated. + ref: ${{ github.event.workflow_run.head_sha }} + # The baseline is read from tags. Under a shallow clone the project + # version reads as 0.0.0 and this would tag nonsense. + fetch-depth: 0 + # Deliberate exception to this repo's persist-credentials: false + # convention: this job pushes a tag and needs the token to do it. + persist-credentials: true + + - name: Configure git identity + run: | + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + + - name: Compute nightly tag + id: bump + run: | + set -euo pipefail + # tools/next_version.py owns the decision; see the header. This step + # deliberately contains no version logic of its own. + new_tag=$(python3 tools/next_version.py --channel nightly) + echo "Nightly tag: ${new_tag:-}" + echo "new_tag=$new_tag" >> "$GITHUB_OUTPUT" + + - name: Create tag + env: + NEW_TAG: ${{ steps.bump.outputs.new_tag }} + run: | + set -euo pipefail + # Empty means no commits since the last release: nothing to build a + # candidate from. Not an error -- a workflow re-run lands here, and + # `git tag ""` fails with a message about nothing in particular. + if [ -z "$NEW_TAG" ]; then + echo "No commits since the last release; nothing to tag" + exit 0 + fi + # Idempotent: a re-run of this workflow must not fail the job. + if git rev-parse -q --verify "refs/tags/$NEW_TAG" >/dev/null; then + echo "Tag $NEW_TAG already exists, nothing to push" + else + git tag "$NEW_TAG" + git push origin "refs/tags/$NEW_TAG" + fi + + # No GitHub release is created. These tags exist to make nightly builds + # addressable and to give hatch-vcs a version; releases are cut on main by + # auto-tag.yml. +``` + +- [ ] **Step 3: Create `.github/workflows/auto-tag.yml`** + +```yaml +name: Auto Tag + +# Cuts the stable release on main by promoting the candidate that `nightly` has +# been building: a fix-only cycle ends at X.Y.(Z+1), a cycle containing any +# feature ends at X.(Y+1).0. +# +# The bump is NOT forced per branch. main is not always X.Y.0. Deriving the bump +# from the batch is the point: forcing a minor on every merge takes a project +# two minor versions in an hour for two bugfixes. +# +# The policy lives in tools/next_version.py, shared with nightly-tag.yml and +# unit-tested in tests/test_next_version.py. Nothing here parses or increments a +# version number. Do not add that here -- add to the module, where it is tested. +# +# Runs only after CI finishes successfully on main, so a broken commit is never +# tagged or released. +on: + workflow_run: + workflows: ["CI"] + types: + - completed + branches: + - main + +permissions: + contents: write + +# Two merges landing back-to-back would otherwise both compute the same new tag +# and the second push would fail. Serialize instead of cancelling so no merge is +# skipped. +concurrency: + group: auto-tag + cancel-in-progress: false + +jobs: + tag: + runs-on: ubuntu-latest + timeout-minutes: 10 + if: >- + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # workflow_run defaults to the tip of the default branch, which is not + # necessarily the commit CI validated. + ref: ${{ github.event.workflow_run.head_sha }} + # The baseline is read from tags. Under a shallow clone the project + # version reads as 0.0.0 and this would tag nonsense. + fetch-depth: 0 + # Deliberate exception to this repo's persist-credentials: false + # convention: this job pushes a tag and needs the token to do it. + persist-credentials: true + + - name: Configure git identity + run: | + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + + - name: Compute release tag + id: bump + run: | + set -euo pipefail + # The whole decision -- which component moves, and to what -- is + # tools/next_version.py's. Keeping it out of YAML is the point: this + # step cannot be unit-tested and the policy can. + new_tag=$(python3 tools/next_version.py --channel stable) + echo "Release tag: ${new_tag:-}" + echo "new_tag=$new_tag" >> "$GITHUB_OUTPUT" + + - name: Create tag + env: + NEW_TAG: ${{ steps.bump.outputs.new_tag }} + run: | + set -euo pipefail + # Empty means no commits since the last release -- a re-run on an + # already-released commit. Nothing to do, and not an error. + # + # This is not a "no feat/fix commits, skip" early exit: a docs- or + # chore-only merge is still a release, it just cuts a patch rather + # than a minor. Only a genuinely empty batch is skipped. + if [ -z "$NEW_TAG" ]; then + echo "No commits since the last release; nothing to tag" + exit 0 + fi + # Idempotent: a re-run of this workflow must not fail the job. + if git rev-parse -q --verify "refs/tags/$NEW_TAG" >/dev/null; then + echo "Tag $NEW_TAG already exists, nothing to push" + else + git tag "$NEW_TAG" + git push origin "refs/tags/$NEW_TAG" + fi + + # GitHub never dispatches workflow events for refs pushed with + # GITHUB_TOKEN, so release.yml will not fire for the tag above. Create the + # release here instead. release.yml remains the path for tags pushed + # manually by a human. + - name: Create GitHub release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NEW_TAG: ${{ steps.bump.outputs.new_tag }} + run: | + set -euo pipefail + if [ -z "$NEW_TAG" ]; then + echo "No tag was created; no release to publish" + exit 0 + fi + if gh release view "$NEW_TAG" >/dev/null 2>&1; then + echo "Release $NEW_TAG already exists, nothing to do" + exit 0 + fi + gh release create "$NEW_TAG" --generate-notes +``` + +- [ ] **Step 4: Create `.github/workflows/release.yml`** + +```yaml +name: Release + +# The path for tags a human pushes by hand. The automatic policy never bumps the +# major component -- a breaking marker counts as a feature, because an automatic +# major is an irreversible published mistake waiting for one mistyped subject +# line -- so a major release is `git tag v1.0.0 && git push`, and this is what +# turns that into a release. +# +# Tags pushed by auto-tag.yml do NOT reach here: GitHub does not dispatch +# workflow events for refs pushed with GITHUB_TOKEN. That job creates its own +# release. +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # Read-only: this job creates a release from a tag that already + # exists, so unlike the two tagging workflows it needs no credentials. + persist-credentials: false + + - uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 + with: + generate_release_notes: true +``` + +- [ ] **Step 5: Lint the workflows the way CI will** + +Run: +```bash +cd /tmp/spoonmap-auto-versioning +uvx actionlint .github/workflows/*.yml +uvx zizmor .github/workflows/ +``` +Expected: no errors. zizmor will likely flag `persist-credentials: true` on the two tagging jobs. If it fails the run rather than merely noting it, do **not** silence it with an inline ignore — check how the existing `workflow-lint` job invokes zizmor (`.github/workflows/ci.yml`, job `workflow-lint`) and match whatever severity threshold it already uses. Report the finding in your summary either way. + +- [ ] **Step 6: Prove the computed tag is what gets pushed, locally** + +Before trusting any of this in CI, run the policy against this very repository: + +```bash +cd /tmp/spoonmap-auto-versioning +python3 tools/next_version.py --channel nightly +python3 tools/next_version.py --channel stable +``` +Expected: with no tags and this branch's history, both print something. Record both values in your task summary — if `--channel nightly` prints `v0.1.0rc1` rather than `v0.0.1rc1`, some commit in the repo's history says `feat:`, which is the first-batch consequence the spec calls out. + +- [ ] **Step 7: Commit** + +```bash +git add .github/workflows/ +git commit -m "feat: tag releases automatically from CI on main and nightly + +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." +``` + +--- + +### Task 4: `--version` + +**Files:** +- Modify: `spoonmap.py` (new `_tool_version()` near `_operator_dir()`; dispatch in `main()` around line 5855) +- Test: `tests/test_spoonmap.py` + +**Interfaces:** +- Consumes: distribution metadata produced by Task 2. +- Produces: `_tool_version() -> str` and `_UNKNOWN_VERSION` — Task 5's update check calls both. + +- [ ] **Step 1: Write the failing tests** + +Add to `tests/test_spoonmap.py` (put the class next to the other small-helper test classes): + +Note the existing conventions in that file: it imports `from unittest.mock import MagicMock, patch`, so use bare `patch` / `MagicMock`, not `mock.patch`. + +```python +class TestToolVersion: + """_tool_version() reports the installed version, or says it cannot.""" + + def test_reports_the_installed_distribution_version(self): + with patch('spoonmap.metadata.version', return_value='1.2.3'): + assert spoonmap._tool_version() == '1.2.3' + + def test_running_from_a_checkout_is_not_a_version(self): + """No distribution metadata exists when spoonmap.py is run as a plain + script from a clone, which is the documented invocation. That must read + as 'unknown', never as a version number that could be compared.""" + with patch('spoonmap.metadata.version', + side_effect=spoonmap.metadata.PackageNotFoundError): + assert spoonmap._tool_version() == spoonmap._UNKNOWN_VERSION + + def test_the_unknown_sentinel_is_not_mistakable_for_a_version(self): + assert not spoonmap._UNKNOWN_VERSION[0].isdigit() +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `uv run pytest tests/test_spoonmap.py::TestToolVersion -v` +Expected: FAIL — `AttributeError: module 'spoonmap' has no attribute '_tool_version'`. + +- [ ] **Step 3: Implement** + +In `spoonmap.py`, add to the imports at the top: + +```python +from importlib import metadata +``` + +Then add, immediately after `_operator_dir()` (near line 2459, beside `_DIR`/`_NSE_DIR`): + +```python +# What _tool_version() reports when there is no distribution metadata to read. +# Deliberately not a number: it flows into the update check, where anything +# parseable as a version would be compared against the latest release and +# produce a confident wrong answer. +_UNKNOWN_VERSION = 'unknown (running from source)' + + +def _tool_version(): + """The installed SpooNMAP version, or _UNKNOWN_VERSION. + + Read from distribution metadata rather than a string in this file, because + the version is derived from git tags at build time (see pyproject.toml's + [tool.hatch.version]) and a literal here would be a second, drifting copy. + + The documented invocation `./spoonmap.py` from a clone installs nothing, so + PackageNotFoundError is the *normal* case for a developer or an operator + running from a checkout -- not an error worth a warning. + """ + try: + return metadata.version('spoonmap') + except metadata.PackageNotFoundError: + return _UNKNOWN_VERSION +``` + +- [ ] **Step 4: Run the tests** + +Run: `uv run pytest tests/test_spoonmap.py::TestToolVersion -v` +Expected: PASS. + +- [ ] **Step 5: Wire up the flag** + +In `main()`, the `--cleanup` dispatch currently reads: + +```python + if '--cleanup' in sys.argv: + _cleanup_cmd(dir_path) # prints result and exits +``` + +`--version` must print a clean, scriptable line with no banner above it, so handle it *before* `ascii_art()`. At the very top of `main()`, immediately after `global output_path` and before `initial_term_state = save_terminal_state()`, insert: + +```python + # Handled before the banner and before any terminal state is touched: + # `spoonmap --version` should emit one parseable line and nothing else. + if '--version' in sys.argv: + print(_tool_version()) + sys.exit(0) +``` + +- [ ] **Step 6: Verify by hand** + +Run: `cd /tmp/spoonmap-auto-versioning && python3 spoonmap.py --version` +Expected: prints exactly `unknown (running from source)` and exits 0, with no ASCII banner. (`unknown` is correct here — this is a checkout, not an install.) + +- [ ] **Step 7: Full suite, lint, commit** + +```bash +uv run pytest tests/ -q +uv run --frozen ruff check spoonmap.py tests/ tools/ +uv run --frozen bandit -r spoonmap.py -c pyproject.toml -b .bandit-baseline.json +git add spoonmap.py tests/test_spoonmap.py +git commit -m "feat: add --version + +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." +``` + +--- + +### Task 5: Opt-in update checking + +**Files:** +- Modify: `spoonmap.py` (three new functions; `_load_config()` at line 5708; `main()` dispatch) +- Modify: `config.json.sample` +- Test: `tests/test_spoonmap.py` + +**Interfaces:** +- Consumes: `_tool_version()`, `_UNKNOWN_VERSION` (Task 4); the existing `_config_bool(key, value, default)` (line 5621) and `_COLOR_ERROR` / `_COLOR_RESET`. +- Produces: `_parse_release_tag(tag) -> Optional[tuple]`, `_check_for_updates(timeout=...) -> None`, `_maybe_check_for_updates(enabled) -> None`, and a `'check_for_updates'` key in `_load_config()`'s returned dict. + +**Correction to the spec:** the spec says to add a `_config_bool()` helper. It already exists at `spoonmap.py:5621` and already accepts both JSON booleans and the legacy quoted spellings. Use it; do not add a second one. + +**The rule this task exists to enforce:** SpooNMAP runs from jumpboxes inside client networks. Nothing here may touch the network at launch unless the operator explicitly set `check_for_updates` to true. Absent key means off. Step 1's first test is the one that holds that line. + +- [ ] **Step 1: Write the failing tests** + +Add to `tests/test_spoonmap.py`: + +```python +class TestUpdateCheckIsOptIn: + """The launch-time update check is off unless explicitly enabled. + + SpooNMAP runs from jumpboxes inside client networks, where an unprompted + call to api.github.com is an outbound beacon from an engagement host that + nobody authorised. hate_crack defaults this to True; SpooNMAP inverts it, + and these tests are what keep it inverted. + """ + + def test_a_config_that_never_mentions_the_key_makes_no_network_call(self): + def explode(*args, **kwargs): + raise AssertionError( + 'a default config performed a network call at launch' + ) + + with patch('spoonmap.urllib.request.urlopen', side_effect=explode): + spoonmap._maybe_check_for_updates(False) + + def test_enabling_it_performs_the_check(self): + with patch('spoonmap._check_for_updates') as checked: + spoonmap._maybe_check_for_updates(True) + checked.assert_called_once() + + def test_load_config_defaults_the_key_to_false(self): + cfg = _config_dict() + assert 'check_for_updates' not in cfg + assert _load_config(cfg, '/t')['check_for_updates'] is False + + def test_load_config_honours_an_explicit_true(self): + cfg = _config_dict(check_for_updates=True) + assert _load_config(cfg, '/t')['check_for_updates'] is True + + def test_load_config_accepts_the_legacy_quoted_spelling(self): + """_config_bool() accepts "True"/"False" indefinitely for hand-edited + configs; this key is no exception.""" + cfg = _config_dict(check_for_updates='True') + assert _load_config(cfg, '/t')['check_for_updates'] is True + + +class TestCheckForUpdates: + """The check itself: comparison, output, and total failure tolerance.""" + + def _response(self, tag): + body = json.dumps({'tag_name': tag}).encode() + resp = MagicMock() + resp.read.return_value = body + resp.__enter__.return_value = resp + return resp + + def test_a_newer_release_is_reported(self, capsys): + with patch('spoonmap._tool_version', return_value='0.0.1'), \ + patch('spoonmap.urllib.request.urlopen', + return_value=self._response('v0.1.0')): + spoonmap._check_for_updates() + out = capsys.readouterr().out + assert '0.1.0' in out + + def test_being_up_to_date_says_so_without_claiming_an_update(self, capsys): + with patch('spoonmap._tool_version', return_value='0.1.0'), \ + patch('spoonmap.urllib.request.urlopen', + return_value=self._response('v0.1.0')): + spoonmap._check_for_updates() + assert 'Update available' not in capsys.readouterr().out + + def test_an_older_release_is_not_an_update(self, capsys): + with patch('spoonmap._tool_version', return_value='0.2.0'), \ + patch('spoonmap.urllib.request.urlopen', + return_value=self._response('v0.1.0')): + spoonmap._check_for_updates() + assert 'Update available' not in capsys.readouterr().out + + def test_an_unknown_local_version_never_claims_an_update(self, capsys): + """Running from a checkout has no version to compare. Reporting the + latest release is fine; asserting the operator is behind is not -- + it would nag everyone running from a clone, which is most of them.""" + with patch('spoonmap._tool_version', + return_value=spoonmap._UNKNOWN_VERSION), \ + patch('spoonmap.urllib.request.urlopen', + return_value=self._response('v0.1.0')): + spoonmap._check_for_updates() + out = capsys.readouterr().out + assert 'Update available' not in out + assert '0.1.0' in out + + def test_a_network_failure_is_swallowed(self, capsys): + """A failed update check must never delay, prompt, or abort a scan.""" + with patch('spoonmap._tool_version', return_value='0.0.1'), \ + patch('spoonmap.urllib.request.urlopen', + side_effect=OSError('no route to host')): + spoonmap._check_for_updates() # must not raise + assert 'Update available' not in capsys.readouterr().out + + def test_unparseable_json_is_swallowed(self, capsys): + resp = MagicMock() + resp.read.return_value = b'404' + resp.__enter__.return_value = resp + with patch('spoonmap._tool_version', return_value='0.0.1'), \ + patch('spoonmap.urllib.request.urlopen', return_value=resp): + spoonmap._check_for_updates() # must not raise + + def test_a_release_with_no_tag_name_is_swallowed(self, capsys): + resp = MagicMock() + resp.read.return_value = b'{}' + resp.__enter__.return_value = resp + with patch('spoonmap._tool_version', return_value='0.0.1'), \ + patch('spoonmap.urllib.request.urlopen', return_value=resp): + spoonmap._check_for_updates() # must not raise + + +class TestParseReleaseTag: + """Version comparison, without a packaging dependency.""" + + @pytest.mark.parametrize('text,expected', [ + ('v0.1.0', (0, 1, 0)), + ('0.1.0', (0, 1, 0)), + ('v10.4.7', (10, 4, 7)), + # Not a comparable release: a candidate, a dev build, junk, and the + # running-from-source sentinel. + ('v0.1.0rc1', None), + ('0.0.1.post1.dev1', None), + ('nightly', None), + ('', None), + ]) + def test_only_plain_releases_compare(self, text, expected): + assert spoonmap._parse_release_tag(text) == expected + + def test_comparison_is_numeric_not_lexical(self): + assert (spoonmap._parse_release_tag('v0.10.0') + > spoonmap._parse_release_tag('v0.9.0')) +``` + +`_config_dict(**overrides)` and `_load_config` are already defined in `tests/test_spoonmap.py` (line 2375 and the module's import block respectively) — use them as-is, do not add a second helper. `json`, `pytest`, `patch` and `MagicMock` are already imported there too. + +- [ ] **Step 2: Run them to verify they fail** + +Run: `uv run pytest tests/test_spoonmap.py -k "UpdateCheck or CheckForUpdates or ParseReleaseTag" -v` +Expected: FAIL — the three functions do not exist. + +- [ ] **Step 3: Implement** + +Add to `spoonmap.py`'s imports: `import urllib.error` and `import urllib.request` (`json` and `re` are already imported; confirm). + +Add these functions immediately after `_tool_version()` from Task 4: + +```python +# Latest *release* specifically: GitHub's /releases/latest excludes +# pre-releases, so the vX.Y.ZrcN candidates cut on `nightly` are never +# advertised to an operator as an available update. +_RELEASE_API_URL = ( + 'https://api.github.com/repos/trustedsec/spoonmap/releases/latest' +) +_RELEASES_URL = 'https://github.com/trustedsec/spoonmap/releases' +# Short: this runs before a scan, and a hung TCP connection to a network the +# jumpbox cannot reach must not become a stalled engagement. +_UPDATE_CHECK_TIMEOUT = 5 + +_RELEASE_TAG_RE = re.compile(r'^v?(\d+)\.(\d+)\.(\d+)$') + + +def _parse_release_tag(text): + """(major, minor, patch) for a plain release, else None. + + Deliberately strict. A candidate (0.1.0rc1) or a dev build + (0.0.1.post1.dev1) is not comparable against a release without PEP 440 + semantics, and spoonmap.py is stdlib-only by design -- there is no + `packaging` here to do it properly, so anything that is not an unambiguous + X.Y.Z is declined rather than guessed at. + """ + match = _RELEASE_TAG_RE.match((text or '').strip()) + if not match: + return None + return (int(match.group(1)), int(match.group(2)), int(match.group(3))) + + +def _check_for_updates(timeout=_UPDATE_CHECK_TIMEOUT): + """Report whether a newer release exists. Never raises, never blocks long. + + Every failure mode -- no route, DNS, TLS, rate limiting, an HTML error page + where JSON was expected, a release with no tag_name -- is swallowed. An + update check is a courtesy; a scan must never fail or stall because one did. + """ + try: + with urllib.request.urlopen(_RELEASE_API_URL, timeout=timeout) as resp: + payload = json.loads(resp.read().decode('utf-8', 'replace')) + latest_text = payload.get('tag_name', '') + except Exception: + # Intentionally broad: see the docstring. There is no failure here + # worth interrupting an operator for, and the set of exceptions urllib + # and json can raise between them is not worth enumerating wrongly. + return + + latest = _parse_release_tag(latest_text) + if latest is None: + return + + current_text = _tool_version() + current = _parse_release_tag(current_text) + if current is None: + # Running from a checkout, or on a dev build. There is nothing to + # compare, so report the fact and make no claim about it -- telling + # every operator running from a clone that they are out of date would + # be wrong far more often than right. + print(f'Latest release: {latest_text} (local version unknown). ' + f'See {_RELEASES_URL}') + return + + if latest > current: + print(_COLOR_ERROR + + f'Update available: {latest_text} (current: {current_text}). ' + f'See {_RELEASES_URL}' + + _COLOR_RESET) + else: + print(f'SpooNMAP {current_text} is up to date.') + + +def _maybe_check_for_updates(enabled): + """Run the update check only if the operator turned it on. + + Separate from _check_for_updates() so the gate itself is testable: main() + is under `pragma: no cover`, and "does a default config reach the network" + is exactly the question that must not go untested. + """ + if enabled: + _check_for_updates() +``` + +- [ ] **Step 4: Add the config key** + +In `_load_config()`, beside the other `_config_bool` calls (near `banner_scan`, line ~5752), add: + +```python + # Absent means off, and absent is the normal case. This is the only way to + # enable a launch-time network call; see _check_for_updates(). + check_for_updates = _config_bool( + 'check_for_updates', config_parser.get('check_for_updates', False), False) +``` + +and add `'check_for_updates': check_for_updates,` to the dict `_load_config()` returns. Do **not** add it to `_CONFIG_REQUIRED_KEYS` — a config that never mentions it must stay valid. + +- [ ] **Step 5: Run the tests** + +Run: `uv run pytest tests/test_spoonmap.py -k "UpdateCheck or CheckForUpdates or ParseReleaseTag" -v` +Expected: PASS. + +- [ ] **Step 6: Wire up `--check-update` and the config-gated call** + +In `main()`, extend the block added in Task 4 Step 5 so it reads: + +```python + # Handled before the banner and before any terminal state is touched: + # `spoonmap --version` should emit one parseable line and nothing else. + if '--version' in sys.argv: + print(_tool_version()) + sys.exit(0) + # On-demand, regardless of config: asking whether an update exists should + # not require leaving the launch-time check switched on. + if '--check-update' in sys.argv: + _check_for_updates() + sys.exit(0) +``` + +Then, at the point where the loaded config's values are unpacked (after `cfg = _load_config(config_parser, dir_path, resume)`), add: + +```python + _maybe_check_for_updates(cfg['check_for_updates']) +``` + +There is no equivalent call on the interactive path: a config that does not exist cannot have opted in. + +- [ ] **Step 7: Document the key in `config.json.sample`** + +Add these two lines before `"target_file"`, matching the file's existing `__note__` convention: + +```json + "__check_for_updates_note__": "Optional. When true, SpooNMAP contacts api.github.com at startup to see whether a newer release exists. Default false, and absent means false: the tool makes no network connection other than the scan itself unless you turn this on. Use --check-update for a one-off check without enabling it here.", + "check_for_updates": false, +``` + +- [ ] **Step 8: Verify by hand, including the flag** + +```bash +cd /tmp/spoonmap-auto-versioning +python3 spoonmap.py --check-update +python3 -c "import json; json.load(open('config.json.sample')); print('sample is valid JSON')" +``` +Expected: the first prints either a latest-release line or nothing at all (if the network is unavailable — that is the swallowed path working, not a failure); the second confirms the sample still parses. + +- [ ] **Step 9: Full suite, lint, SAST** + +```bash +uv run pytest tests/ -q +uv run --frozen ruff check spoonmap.py tests/ tools/ +uv run --frozen bandit -r spoonmap.py -c pyproject.toml -b .bandit-baseline.json +``` + +Bandit will likely raise **B310 (`urllib.request.urlopen` with an unverified scheme)** — it is scheme-blind even for a hardcoded `https://` literal. Per this repo's rules, do **not** add `# nosec`. Regenerate the baseline instead and justify it: + +```bash +uv run --frozen bandit -r spoonmap.py -c pyproject.toml -f json -o .bandit-baseline.json +git diff --stat .bandit-baseline.json +``` + +Read the diff before staging it. Exactly one new finding should appear, for the `urlopen` call. If more appeared, stop and report — something else changed. + +- [ ] **Step 10: Commit** + +```bash +git add spoonmap.py tests/test_spoonmap.py config.json.sample .bandit-baseline.json +git commit -m "feat: add opt-in update checking, off by default + +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." +``` + +--- + +### Task 6: Guard the wiring that breaks silently + +**Files:** +- Create: `tests/test_release_versioning.py` +- Modify: `pyproject.toml` (dev group gains `pyyaml`) +- Modify: `.github/workflows/ci.yml` (`test-legacy` job's `uv run` line, ~line 118) + +**Interfaces:** +- Consumes: the workflow files from Task 3, `tools/next_version.py` from Task 1. +- Produces: nothing other tasks consume. + +The policy is already tested. What is untested is everything around it: a trigger that never fires, a step that stops using the policy module, a tag that gets pushed without being the one that was computed. Those fail *silently* — no tag simply appears, and nobody notices for weeks. + +Assert behaviour by running the extracted step scripts, not by substring-matching YAML. hate_crack learned this the hard way: its substring assertions were defeated by replacing an entire `if`/`else` with an unconditional `git tag && git push`, and every test still passed because the substring lived elsewhere in the file. + +- [ ] **Step 1: Make the test dependencies available on every job** + +In `pyproject.toml`'s `[dependency-groups].dev`, add: + +```toml + # Test-only. tests/test_release_versioning.py parses the workflow YAML to + # assert the CI triggers and tagging steps still wire together. + "pyyaml>=6.0", +``` + +Then in `.github/workflows/ci.yml`, the `test-legacy` job resolves its own dependencies outside the project (`uv run --isolated --no-project ... --with pytest --with pytest-cov`), so it would hit an ImportError collecting the new modules. Extend that line: + +```yaml + - name: Run tests + run: > + uv run --isolated --no-project --python ${{ matrix.python-version }} + --with pytest --with pytest-cov --with pyyaml --with packaging + pytest tests/ -v -rs +``` + +`packaging` is for `tests/test_next_version.py` (Task 1). Do not solve this with `pytest.importorskip` — a skipped guard is a guard that silently is not running, which is the exact failure this whole file exists to prevent. + +Run `uv lock` after editing the dev group. + +- [ ] **Step 2: Write the tests** + +Create `tests/test_release_versioning.py`: + +```python +"""Guards on the release-versioning wiring. + +The policy itself -- which component moves, and to what -- lives in +tools/next_version.py and is tested in tests/test_next_version.py. Nothing here +re-implements it. + +What this file guards is everything around the policy, all of which fails +*silently*: + +* A trigger that never fires. nightly-tag.yml keys on a completed CI run for + the `nightly` branch, so if ci.yml stops running on pushes to `nightly`, no + candidate is ever tagged and there is no error anywhere to notice. +* The policy module ceasing to be the only thing that produces a version, + asserted as a positive invariant (exactly one next_version.py call per + workflow, and the pushed tag read back from its output) with a denylist of + shell version arithmetic as a second line of defence. +* The behaviour of the shell that remains -- tag idempotency and the + empty-batch path -- asserted by extracting the step script from the YAML and + running it against a real git repository and a real bare remote. + +Substring assertions on YAML are sensitive to formatting and blind to +behaviour, which is backwards. Do not convert these back into them. +""" + +import os +import re +import subprocess + +import pytest +import yaml + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +WORKFLOWS = os.path.join(REPO_ROOT, '.github', 'workflows') + + +def _load(name): + with open(os.path.join(WORKFLOWS, name)) as handle: + return yaml.safe_load(handle) + + +def _steps(workflow): + (job,) = workflow['jobs'].values() + return job['steps'] + + +def _step_script(workflow, name): + for step in _steps(workflow): + if step.get('name') == name: + return step['run'] + raise AssertionError(f'no step named {name!r}') + + +# `on` is the YAML 1.1 boolean True, so a parsed workflow keys the trigger +# block under True rather than 'on'. This bites everyone once. +def _triggers(workflow): + return workflow.get('on', workflow.get(True)) + + +# --- the triggers ------------------------------------------------------------ + + +def test_ci_runs_on_pushes_to_nightly(): + """Without this, nightly-tag.yml's workflow_run trigger has nothing to key + on and no candidate is ever cut. Nothing errors; tags just stop appearing.""" + branches = _triggers(_load('ci.yml'))['push']['branches'] + assert 'nightly' in branches + assert 'main' in branches + + +@pytest.mark.parametrize('name,branch', [ + ('nightly-tag.yml', 'nightly'), + ('auto-tag.yml', 'main'), +]) +def test_tagging_workflows_wait_for_a_successful_ci_run(name, branch): + trigger = _triggers(_load(name))['workflow_run'] + assert trigger['workflows'] == ['CI'] + assert trigger['branches'] == [branch] + + (job,) = _load(name)['jobs'].values() + assert "workflow_run.conclusion == 'success'" in job['if'] + + +@pytest.mark.parametrize('name', ['nightly-tag.yml', 'auto-tag.yml']) +def test_tagging_workflows_check_out_the_commit_ci_validated(name): + """workflow_run defaults to the default branch's tip, which is not + necessarily the commit that passed CI.""" + checkout = _steps(_load(name))[0] + assert checkout['with']['ref'] == '${{ github.event.workflow_run.head_sha }}' + + +@pytest.mark.parametrize('name', ['nightly-tag.yml', 'auto-tag.yml']) +def test_tagging_workflows_fetch_all_history(name): + """The baseline is the highest final tag. A shallow clone has none, so the + job would compute from 0.0.0 and tag a version that already shipped.""" + assert _steps(_load(name))[0]['with']['fetch-depth'] == 0 + + +@pytest.mark.parametrize('name', ['nightly-tag.yml', 'auto-tag.yml']) +def test_tagging_workflows_do_not_cancel_each_other(name): + """Two merges landing together would compute the same tag; the second push + would fail. Serialize rather than cancel so no merge is skipped.""" + assert _load(name)['concurrency']['cancel-in-progress'] is False + + +# --- the policy module is the only thing that produces a version ------------- + + +@pytest.mark.parametrize('name,channel', [ + ('nightly-tag.yml', 'nightly'), + ('auto-tag.yml', 'stable'), +]) +def test_exactly_one_call_to_the_policy_module(name, channel): + with open(os.path.join(WORKFLOWS, name)) as handle: + body = handle.read() + calls = re.findall(r'tools/next_version\.py --channel (\w+)', body) + assert calls == [channel], ( + 'the tag must come from exactly one next_version.py call' + ) + + +@pytest.mark.parametrize('name', ['nightly-tag.yml', 'auto-tag.yml']) +def test_the_pushed_tag_is_the_one_the_policy_computed(name): + workflow = _load(name) + compute = [s for s in _steps(workflow) if 'next_version.py' in s.get('run', '')] + assert len(compute) == 1 + step_id = compute[0]['id'] + create = _step_script(workflow, 'Create tag') + assert 'NEW_TAG' in create + env = [s for s in _steps(workflow) if s.get('name') == 'Create tag'][0]['env'] + assert env['NEW_TAG'] == '${{ steps.%s.outputs.new_tag }}' % step_id + + +@pytest.mark.parametrize('name', ['nightly-tag.yml', 'auto-tag.yml']) +def test_no_shell_version_arithmetic(name): + """Second line of defence. Version math in YAML cannot be unit-tested, + which is the entire reason tools/next_version.py exists.""" + with open(os.path.join(WORKFLOWS, name)) as handle: + body = handle.read() + for banned in ('cut -d.', 'cut -d ".', '$((', 'awk -F.', 'sed -E s/v'): + assert banned not in body, f'version arithmetic in YAML: {banned}' + + +# --- the behaviour of the shell that remains --------------------------------- + + +def _git(repo, *args): + return subprocess.run( + ['git', *args], cwd=repo, capture_output=True, text=True, check=True + ).stdout + + +@pytest.fixture +def repo_with_remote(tmp_path): + """A real repository with a real bare origin, so `git push` is exercised.""" + remote = tmp_path / 'remote.git' + subprocess.run(['git', 'init', '-q', '--bare', str(remote)], check=True) + repo = tmp_path / 'repo' + subprocess.run(['git', 'init', '-q', str(repo)], check=True) + _git(repo, 'config', 'user.email', 'test@example.com') + _git(repo, 'config', 'user.name', 'test') + (repo / 'f.txt').write_text('x') + _git(repo, 'add', '-A') + _git(repo, 'commit', '-qm', 'fix: initial') + _git(repo, 'remote', 'add', 'origin', str(remote)) + return repo, remote + + +def _run_create_tag(repo, script, new_tag): + env = dict(os.environ, NEW_TAG=new_tag) + return subprocess.run( + ['bash', '-c', script], cwd=repo, env=env, + capture_output=True, text=True, + ) + + +@pytest.mark.parametrize('name', ['nightly-tag.yml', 'auto-tag.yml']) +def test_create_tag_pushes_the_tag(repo_with_remote, name): + repo, remote = repo_with_remote + script = _step_script(_load(name), 'Create tag') + result = _run_create_tag(repo, script, 'v0.0.1') + assert result.returncode == 0, result.stderr + assert 'v0.0.1' in _git(remote, 'tag') + + +@pytest.mark.parametrize('name', ['nightly-tag.yml', 'auto-tag.yml']) +def test_create_tag_is_idempotent(repo_with_remote, name): + """A re-run of the workflow must not fail the job.""" + repo, _ = repo_with_remote + script = _step_script(_load(name), 'Create tag') + assert _run_create_tag(repo, script, 'v0.0.1').returncode == 0 + second = _run_create_tag(repo, script, 'v0.0.1') + assert second.returncode == 0, second.stderr + + +@pytest.mark.parametrize('name', ['nightly-tag.yml', 'auto-tag.yml']) +def test_an_empty_batch_tags_nothing_and_is_not_an_error(repo_with_remote, name): + """No commits since the last release is a re-run, not a failure. Tagging + "" would fail with a message about nothing in particular.""" + repo, remote = repo_with_remote + script = _step_script(_load(name), 'Create tag') + result = _run_create_tag(repo, script, '') + assert result.returncode == 0, result.stderr + assert _git(remote, 'tag').strip() == '' + + +def test_the_policy_module_agrees_with_this_repository(): + """End to end against the real repo: the CLI runs and prints a usable tag + or nothing at all. Catches an import error or a bad shebang that no unit + test would see.""" + result = subprocess.run( + ['python3', os.path.join(REPO_ROOT, 'tools', 'next_version.py'), + '--channel', 'nightly', '--repo-dir', REPO_ROOT], + capture_output=True, text=True, + ) + assert result.returncode == 0, result.stderr + output = result.stdout.strip() + assert output == '' or re.match(r'^v\d+\.\d+\.\d+rc\d+$', output), output +``` + +- [ ] **Step 3: Run them** + +Run: `uv run pytest tests/test_release_versioning.py -v` +Expected: PASS. If `_triggers()` returns `None`, the workflow parsed `on` as the boolean `True` — that is what the helper handles; check you copied it intact. + +- [ ] **Step 4: Prove the guards actually guard** + +A test that cannot fail is not a guard. Mutate and confirm each one bites, on a scratch copy so the real files are never left broken: + +```bash +cd /tmp/spoonmap-auto-versioning +cp .github/workflows/ci.yml /tmp/ci.yml.good +python3 - <<'EOF' +import re +p = '.github/workflows/ci.yml' +s = open(p).read().replace('branches: [main, nightly]', 'branches: [main]') +open(p, 'w').write(s) +EOF +uv run pytest tests/test_release_versioning.py::test_ci_runs_on_pushes_to_nightly -q +# Expected: FAIL +cp /tmp/ci.yml.good .github/workflows/ci.yml +``` + +Repeat for one behavioural guard: temporarily replace the `Create tag` step's `if`/`else` in `nightly-tag.yml` with an unconditional `git tag "$NEW_TAG" && git push origin "refs/tags/$NEW_TAG"`, confirm `test_create_tag_is_idempotent` and `test_an_empty_batch_tags_nothing_and_is_not_an_error` both FAIL, then restore. Report both mutation results in your summary — "the tests pass" is not evidence here. + +- [ ] **Step 5: Confirm nothing is left mutated** + +Run: `git diff --stat && uv run pytest tests/ -q` +Expected: the only diffs are the intended new/modified files, and the full suite passes at or above 95% coverage. + +- [ ] **Step 6: Commit** + +```bash +git add tests/test_release_versioning.py pyproject.toml uv.lock .github/workflows/ci.yml +git commit -m "test: guard the release-versioning wiring + +The policy is unit-tested; the wiring around it is what fails silently. A +missing nightly push trigger, a step that stops calling next_version.py, or +a tag pushed without being the one computed all produce no error -- tags +just quietly stop appearing. + +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." +``` + +--- + +### Task 7: Documentation + +**Files:** +- Modify: `README.md` (Usage section, after the `--cleanup` block ending ~line 190) +- Modify: `CLAUDE.md` (new section after "Operator Path Resolution") + +**Interfaces:** none. + +- [ ] **Step 1: Document the flags and the config key in `README.md`** + +After the `--cleanup` block (~line 190) and before `## Where Files Live`, add: + +````markdown +To print the installed version: + +```bash +spoonmap --version +``` + +The version comes from the installed package's metadata, which is derived from +the repository's git tags at build time. Running `./spoonmap.py` directly from a +clone installs nothing, so that prints `unknown (running from source)` — which +is expected, not an error. + +To check whether a newer release exists: + +```bash +./spoonmap.py --check-update +``` + +**SpooNMAP never checks for updates on its own.** It makes no network connection +other than the scan itself unless you explicitly opt in, because it is routinely +run from jumpboxes inside client networks where an unprompted call out to +`api.github.com` is unwanted traffic from an engagement host. `--check-update` +performs a single check on demand. To enable the check at every startup, set +`"check_for_updates": true` in `config.json`; the key defaults to `false` and +omitting it entirely means `false`. Only stable releases are reported — +nightly release candidates are never advertised as updates. +```` + +Also add `check_for_updates` to the `## config.json Parameters` section (~line 244), matching the surrounding format: default `false`, "Contact api.github.com at startup to check for a newer release. Off unless set; see `--check-update` for a one-off check." + +- [ ] **Step 2: Document the release process in `CLAUDE.md`** + +Add a section after "Operator Path Resolution": + +```markdown +## Release Versioning + +Versions are tags, not a string in a file. `pyproject.toml` has no `version`; +hatch-vcs derives it from `git describe`, so `importlib.metadata.version('spoonmap')` +— what `--version` prints — is whatever tag the artifact was built from. + +Tags are cut by CI, from the commits themselves. `tools/next_version.py` owns the +entire policy: any `feat:` commit (or a `!` subject, or a `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 waiting for one mistyped subject +line. Push a major by hand and `release.yml` will publish it. + +`nightly` cuts candidates for the version the batch is heading toward +(`v0.0.1rc1`, `v0.0.1rc2`, …) and `main` promotes that same target to its final +release. Aiming candidates one version *forward* is what makes them sort +correctly: `0.0.0 < 0.0.1rc1 < 0.0.1 < 0.1.0rc1 < 0.1.0`. This makes conventional +commit subjects load-bearing — a `feat:` typo'd as `fix:` ships as a patch. + +Four things here fail silently rather than loudly, all guarded by +`tests/test_release_versioning.py`: + +- **`ci.yml` must run on pushes to `nightly`.** `nightly-tag.yml` triggers on a + completed CI run for that branch; with no CI run there is nothing to key on and + no candidate is ever tagged, with no error anywhere. +- **`nightly-tag.yml` must live on `main`.** GitHub only dispatches + `workflow_run` for workflows present on the default branch. A copy existing + only on `nightly` never fires. +- **Both tagging jobs need `fetch-depth: 0`.** The baseline is the highest final + tag; a shallow clone sees none and computes from 0.0.0, handing out a version + that already shipped. The `build` job needs it for the same reason — verified: + a depth-1 clone does not fail there, it silently versions artifacts from no tag + at all. +- **Both tagging jobs set `persist-credentials: true`**, against this repo's + convention everywhere else, because they push a tag. That exception is + commented at each site; do not "fix" it. + +Version arithmetic belongs in `tools/next_version.py`, where it is unit-tested, +never in a workflow step. hate_crack carried ~70 lines of `cut -d.` duplicated +across two YAML files before extracting this module; do not reintroduce it here. + +## Update Checking + +`check_for_updates` in `config.json` defaults to **false**, and an absent key +means false. It is the only thing that can cause a network connection at startup. +hate_crack's equivalent defaults to true; that is deliberately inverted here, +because SpooNMAP runs from jumpboxes inside client networks where an unprompted +call to `api.github.com` is an unauthorised outbound beacon from an engagement +host. `--check-update` is the on-demand path and ignores the config. + +The gate lives in `_maybe_check_for_updates()` rather than inline in `main()` +specifically so it can be tested — `main()` is under `pragma: no cover`, and +"does a default config reach the network" is the one question here that must not +go untested. Its test patches `urllib.request.urlopen` to raise if it is called +at all. `_check_for_updates()` swallows every failure: a courtesy check must +never delay, prompt, or abort a scan. An unknown local version (running from a +checkout) reports the latest release but never claims an update is available. +``` + +- [ ] **Step 3: Verify the docs match reality** + +Re-read both edits against the code as it now stands. Every flag named must exist, every default stated must be the actual default, every line number or path referenced must resolve. Check specifically that `--version`, `--check-update`, and `check_for_updates` are spelled exactly as implemented in Tasks 4 and 5. + +- [ ] **Step 4: Final full verification** + +```bash +cd /tmp/spoonmap-auto-versioning +uv run pytest tests/ -q +uv run --frozen ruff check spoonmap.py tests/ tools/ +uv run --frozen bandit -r spoonmap.py -c pyproject.toml -b .bandit-baseline.json +uv lock --check +uvx actionlint .github/workflows/*.yml +git status --short +``` +Expected: suite green at or above 95% coverage, lint and SAST clean, lock current, workflows valid, no unintended files. + +- [ ] **Step 5: Commit** + +```bash +git add README.md CLAUDE.md +git commit -m "docs: document release versioning and opt-in update checking + +Records the four things in this setup that fail silently rather than +loudly -- the nightly CI trigger, nightly-tag.yml having to live on main, +fetch-depth on three jobs, and the persist-credentials exception -- since +each one produces no error, just tags that quietly stop appearing." +``` + +--- + +## Post-Implementation Notes + +Two consequences to expect on the first real run, both intended and both already +recorded in the spec: + +1. **The first `nightly` push treats the entire history as one batch**, since + there is no baseline tag to bound it. If any commit in that history says + `feat:`, the first candidate is `v0.1.0rc1` rather than `v0.0.1rc1`. Task 3 + Step 6 tells you which it will be before you push. +2. **Early releases read as `v0.0.x`.** That was chosen deliberately; a human can + push `v1.0.0` by hand whenever that stops being the right description, and the + policy builds on it from there. From 205b42b1717d35b7e6c2f969374df30dde72edf5 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Wed, 26 Aug 2026 13:17:57 -0400 Subject: [PATCH 03/22] feat: add tools/next_version.py, the release version policy 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 --- pyproject.toml | 5 + tests/test_next_version.py | 346 +++++++++++++++++++++++++++++++++++++ tools/next_version.py | 212 +++++++++++++++++++++++ uv.lock | 4 +- 4 files changed, 566 insertions(+), 1 deletion(-) create mode 100644 tests/test_next_version.py create mode 100644 tools/next_version.py diff --git a/pyproject.toml b/pyproject.toml index 6b5c427..cd65451 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,11 @@ dev = [ # pinned bandit itself but let its transitive deps float. Locking it here # means `uv run --frozen bandit` gets a fully reproducible dependency set. "bandit[toml]==1.9.4", + # Test-only. tests/test_next_version.py asserts candidate/release ordering + # against a real PEP 440 parser rather than by eyeball, because two + # different pre-release schemes have been got wrong before. Not a runtime + # dependency: spoonmap.py is stdlib-only. + "packaging>=24.0", ] [tool.uv] diff --git a/tests/test_next_version.py b/tests/test_next_version.py new file mode 100644 index 0000000..b098db0 --- /dev/null +++ b/tests/test_next_version.py @@ -0,0 +1,346 @@ +"""Tests for tools/next_version.py -- the release policy itself. + +The policy is ordinary semver with the bump derived from the batch: the second +component moves only for features, `nightly` cuts release candidates for the +version the batch is heading toward, and `main` cuts the final of that same +target. + +Aiming candidates one version forward ensures they sort above the release that +precedes them and below the release they become, and they are pre-releases. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess + +import pytest +from packaging.version import parse + +from tools.next_version import ( + commit_messages, + compute, + has_feature, + latest_final, + next_rc_number, + parse_final, + target_version, +) + +# --- the baseline ------------------------------------------------------------ + + +@pytest.mark.parametrize( + "tag,expected", + [ + ("v2.20.0", (2, 20, 0)), + ("v10.4.7", (10, 4, 7)), + # Not released versions: a candidate, a dev build, a local version, and + # the shapes a hand-pushed tag might take. + ("v2.20.1rc1", None), + ("v2.20.1.dev3", None), + ("v2.20.0+g1234567", None), + ("2.20.0", None), + ("v2.20", None), + ("nightly", None), + ], +) +def test_only_released_versions_are_baseline_candidates(tag, expected): + """The baseline must be something that actually shipped. + + Letting a candidate be the baseline would compound: 2.20.1rc1 would beget + 2.20.2rc1 without 2.20.1 ever existing. + """ + assert parse_final(tag) == expected + + +def test_latest_final_ignores_candidates_and_picks_the_highest(): + tags = ["v2.19.0", "v2.20.0", "v2.20.1rc1", "v2.20.1rc2", "v2.21.0rc1"] + assert latest_final(tags) == (2, 20, 0) + + +def test_latest_final_compares_numerically_not_lexically(): + """The bug that started all of this was a lexical tie-break: '2.19.15' sorts + above '2.20.0' as text, and below it as a version.""" + assert latest_final(["v2.9.0", "v2.10.0"]) == (2, 10, 0) + assert latest_final(["v2.19.15", "v2.20.0"]) == (2, 20, 0) + + +def test_no_tags_at_all_starts_from_zero(): + assert latest_final([]) == (0, 0, 0) + assert latest_final(["nightly", "some-marker"]) == (0, 0, 0) + + +# --- feature detection ------------------------------------------------------ + + +@pytest.mark.parametrize( + "subject", + [ + "feat: add a thing", + "feat(config): add a thing", + "feat!: replace a thing", + "feat(config)!: replace a thing", + "FEAT: shouting still counts", + ], +) +def test_feature_subjects_are_detected(subject): + assert has_feature([subject]) + + +@pytest.mark.parametrize( + "subject", + [ + "fix: repair a thing", + "fix(config): repair a thing", + "docs(changelog): write a thing", + "chore: bump a thing", + "test: cover a thing", + "refactor: move a thing", + "perf: speed a thing up", + "ci: retag a thing", + ], +) +def test_non_feature_subjects_are_not_features(subject): + assert not has_feature([subject]) + + +def test_the_word_feature_in_a_body_does_not_promote_the_batch(): + """Anchored at the subject on purpose. A fix whose body explains which + feature it repairs must not cut a minor release.""" + message = "fix(attacks): correct the mask\n\nThis feature was broken: feat\n" + assert not has_feature([message]) + + +def test_a_breaking_footer_counts_as_a_feature_not_a_major(): + """Deliberate: an automatic major is one mistyped subject away from an + irreversible published release, so major stays a human act.""" + assert has_feature(["fix: something\n\nBREAKING CHANGE: it moved\n"]) + assert has_feature(["refactor!: rename the entry point"]) + base = (2, 20, 0) + assert target_version(base, ["refactor!: rename it"]) == (2, 21, 0) + + +def test_one_feature_among_many_fixes_still_cuts_a_minor(): + messages = ["fix: a", "docs: b", "feat: c", "chore: d"] + assert target_version((2, 20, 0), messages) == (2, 21, 0) + + +# --- the target version ----------------------------------------------------- + + +def test_fix_only_batch_moves_the_third_component(): + assert target_version((2, 20, 0), ["fix: a", "docs: b"]) == (2, 20, 1) + + +def test_fix_only_batch_builds_on_a_previous_patch(): + assert target_version((2, 20, 1), ["fix: a"]) == (2, 20, 2) + + +def test_feature_batch_moves_the_second_and_zeroes_the_third(): + assert target_version((2, 20, 7), ["feat: a"]) == (2, 21, 0) + + +def test_an_empty_batch_has_no_target(): + """A workflow re-run on an already-tagged commit must cut nothing rather + than invent a version.""" + assert target_version((2, 20, 0), []) is None + assert compute("stable", ["v2.20.0"], []) is None + assert compute("nightly", ["v2.20.0"], []) is None + + +# --- candidate numbering ---------------------------------------------------- + + +def test_first_candidate_for_a_target_is_rc1(): + assert next_rc_number((2, 20, 1), ["v2.20.0"]) == 1 + + +def test_candidates_count_upward_within_a_target(): + tags = ["v2.20.0", "v2.20.1rc1", "v2.20.1rc2"] + assert next_rc_number((2, 20, 1), tags) == 3 + + +def test_candidate_numbering_is_per_target(): + """A feature landing mid-cycle changes the target, and the new target starts + its own count rather than inheriting the old one.""" + tags = ["v2.20.0", "v2.20.1rc1", "v2.20.1rc2"] + assert next_rc_number((2, 21, 0), tags) == 1 + + +def test_candidate_numbering_survives_a_deleted_tag(): + """Counts from the highest seen, not from how many exist, so deleting rc2 + cannot hand out rc2 again to a different commit.""" + tags = ["v2.20.0", "v2.20.1rc1", "v2.20.1rc3"] + assert next_rc_number((2, 20, 1), tags) == 4 + + +# --- the two channels, and the ordering that motivates the whole design ----- + + +def test_nightly_cuts_a_candidate_for_the_next_version(): + tags = ["v2.20.0"] + assert compute("nightly", tags, ["fix: a"]) == "v2.20.1rc1" + assert compute("nightly", tags, ["feat: a"]) == "v2.21.0rc1" + + +def test_main_cuts_the_final_of_the_same_target(): + """Merging nightly-dev down promotes the candidate rather than inventing a + different number: the fix-only cycle above ends at 2.20.1, not 2.21.0.""" + tags = ["v2.20.0", "v2.20.1rc1", "v2.20.1rc2"] + assert compute("stable", tags, ["fix: a"]) == "v2.20.1" + assert compute("stable", ["v2.20.0", "v2.21.0rc1"], ["feat: a"]) == "v2.21.0" + + +def test_candidates_sort_above_the_previous_release_and_below_their_own(): + """The property both earlier schemes failed, checked with the real parser. + + A candidate must look newer than what shipped before it and older than what + it becomes. Asserted end to end across a whole fix-only cycle. + """ + shipped = parse("2.20.0") + rc1 = parse(compute("nightly", ["v2.20.0"], ["fix: a"]).lstrip("v")) + rc2 = parse( + compute("nightly", ["v2.20.0", "v2.20.1rc1"], ["fix: a", "fix: b"]).lstrip("v") + ) + final = parse( + compute("stable", ["v2.20.0", "v2.20.1rc1", "v2.20.1rc2"], ["fix: a"]).lstrip( + "v" + ) + ) + + assert shipped < rc1 < rc2 < final + assert rc1.is_prerelease and rc2.is_prerelease + assert not final.is_prerelease, "main must publish a real release, not a candidate" + + +def test_a_feature_cycle_also_orders_correctly_against_the_fix_cycle(): + """2.20.1 < 2.21.0rc1 < 2.21.0 -- a candidate for the next minor must not + look older than the patch release that preceded it.""" + patch_release = parse("2.20.1") + rc = parse(compute("nightly", ["v2.20.1"], ["feat: a"]).lstrip("v")) + final = parse(compute("stable", ["v2.20.1", "v2.21.0rc1"], ["feat: a"]).lstrip("v")) + assert patch_release < rc < final + + +def test_unknown_channel_is_a_loud_error(): + with pytest.raises(ValueError): + compute("beta", ["v2.20.0"], ["fix: a"]) + + +# --- the git boundary ------------------------------------------------------- + + +def _git(*args, cwd): + return subprocess.run( + [str(shutil.which("git")), *args], + cwd=cwd, + capture_output=True, + text=True, + check=True, + env={ + **os.environ, + "GIT_AUTHOR_NAME": "t", + "GIT_AUTHOR_EMAIL": "t@example.invalid", + "GIT_COMMITTER_NAME": "t", + "GIT_COMMITTER_EMAIL": "t@example.invalid", + }, + ).stdout + + +def test_commit_messages_keeps_multi_line_bodies_intact(tmp_path): + """NUL-delimited for a reason: a body with a blank line would otherwise be + split into separate 'commits', and a BREAKING CHANGE footer would be read as + its own subject.""" + repo = tmp_path / "repo" + repo.mkdir() + _git("init", "-q", "-b", "main", cwd=repo) + (repo / "f.txt").write_text("a\n") + _git("add", "-A", cwd=repo) + _git("commit", "-qm", "fix: the first thing", cwd=repo) + _git("tag", "v2.20.0", cwd=repo) + + (repo / "f.txt").write_text("b\n") + _git("add", "-A", cwd=repo) + _git( + "commit", + "-qm", + "fix: the second thing\n\nA body with a blank line.\n\nBREAKING CHANGE: yes\n", + cwd=repo, + ) + + messages = commit_messages(str(repo), (2, 20, 0)) + + assert len(messages) == 1, f"body split into separate messages: {messages}" + assert "BREAKING CHANGE: yes" in messages[0] + # And the footer is therefore seen, which is the point. + assert has_feature(messages) + + +def test_commit_messages_since_baseline_excludes_the_baseline_itself(tmp_path): + repo = tmp_path / "repo" + repo.mkdir() + _git("init", "-q", "-b", "main", cwd=repo) + (repo / "f.txt").write_text("a\n") + _git("add", "-A", cwd=repo) + _git("commit", "-qm", "feat: shipped already", cwd=repo) + _git("tag", "v2.20.0", cwd=repo) + + assert commit_messages(str(repo), (2, 20, 0)) == [] + + (repo / "f.txt").write_text("b\n") + _git("add", "-A", cwd=repo) + _git("commit", "-qm", "fix: not yet shipped", cwd=repo) + + messages = commit_messages(str(repo), (2, 20, 0)) + assert len(messages) == 1 + assert "not yet shipped" in messages[0] + assert not has_feature(messages), "the shipped feat must not leak into this batch" + + +def test_baseline_need_not_be_reachable_from_head(tmp_path): + """main's release tag can sit on a commit nightly-dev does not contain. + + A reachability-restricted baseline would compute the next nightly from a + stale release and hand out a version below what already shipped. + """ + repo = tmp_path / "repo" + repo.mkdir() + _git("init", "-q", "-b", "main", cwd=repo) + (repo / "f.txt").write_text("a\n") + _git("add", "-A", cwd=repo) + _git("commit", "-qm", "fix: base", cwd=repo) + _git("tag", "v2.20.0", cwd=repo) + + # A release that happened on main, on a commit this branch will not contain. + (repo / "f.txt").write_text("released\n") + _git("add", "-A", cwd=repo) + _git("commit", "-qm", "fix: released on main", cwd=repo) + _git("tag", "v2.20.1", cwd=repo) + + _git("checkout", "-q", "-b", "nightly-dev", "v2.20.0", cwd=repo) + (repo / "f.txt").write_text("nightly\n") + _git("add", "-A", cwd=repo) + _git("commit", "-qm", "fix: on the nightly branch", cwd=repo) + + tags = [t for t in _git("tag", cwd=repo).splitlines() if t.strip()] + assert latest_final(tags) == (2, 20, 1), "baseline must see main's release tag" + + messages = commit_messages(str(repo), (2, 20, 1)) + got = compute("nightly", tags, messages) + assert got == "v2.20.2rc1", ( + f"the next nightly must sort above the release that already shipped, got {got}" + ) + assert parse("2.20.1") < parse(got.lstrip("v")) + + +def test_a_repository_with_no_tags_cuts_the_first_patch(): + """SpooNMAP starts from zero: no seed tag is pushed, deliberately.""" + assert compute("stable", [], ["fix: first fix"]) == "v0.0.1" + + +def test_a_first_batch_containing_a_feature_cuts_the_first_minor(): + """The whole history is one batch on the first run, so a single `feat` + anywhere in it takes the first release to 0.1.0 rather than 0.0.1.""" + assert compute("nightly", [], ["fix: a", "feat: b", "docs: c"]) == "v0.1.0rc1" diff --git a/tools/next_version.py b/tools/next_version.py new file mode 100644 index 0000000..0fc3b17 --- /dev/null +++ b/tools/next_version.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +"""Compute the next version tag for a branch, per SpooNMAP's release policy. + +The policy is ordinary semver, with the bump derived from what is actually in +the batch: + +* **The second component moves only for features.** Any ``feat`` commit since + the last release means the batch is heading for ``X.(Y+1).0``. A batch of + nothing but fixes, docs and chores is heading for ``X.Y.(Z+1)``. +* **``nightly`` cuts release candidates** for whichever version the batch is + heading toward: ``v2.20.1rc1``, ``v2.20.1rc2``, … These are real PEP 440 + pre-releases, so they sort *above* the release that precedes them and *below* + the release they become:: + + 2.20.0 < 2.20.1rc1 < 2.20.1rc2 < 2.20.1 < 2.21.0rc1 < 2.21.0 + + That ordering is the whole point of targeting the *next* version rather than + the current one. +* **``main`` cuts the final** of that same target. Merging ``nightly`` down + promotes the candidate: a fix-only cycle ends at ``2.20.1``, a cycle with a + feature ends at ``2.21.0``. + +The major component is never bumped automatically. A ``!`` subject or a +``BREAKING CHANGE:`` footer is treated as a feature here, because an automatic +major is an irreversible published mistake waiting for one mistyped subject +line; a major release stays an explicit human act (tag and push it by hand). + +Baseline is the highest final tag in the repository, deliberately NOT restricted +to tags reachable from HEAD. ``main``'s release tag can sit on a commit that the +``nightly`` tip does not contain, and a reachability-restricted lookup would +then compute the next nightly from a stale baseline and hand out a version below +the release that already shipped. + +Everything above the git boundary is pure and unit-tested in +tests/test_next_version.py. Both tagging workflows call this so the policy lives +in exactly one place, expressed in Python where it can be tested rather than in +YAML where it cannot. +""" + +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +from typing import List, Optional, Tuple + +# A released version: exactly vX.Y.Z. Anything with a pre-release, post-release +# or local segment is deliberately excluded -- the baseline must be a version +# that actually shipped. +FINAL_TAG = re.compile(r"^v(\d+)\.(\d+)\.(\d+)$") + +# A candidate this policy produces: vX.Y.ZrcN. +RC_TAG = re.compile(r"^v(\d+)\.(\d+)\.(\d+)rc(\d+)$") + +# A conventional-commit subject introducing a feature: `feat:`, `feat(scope):`, +# and the breaking forms `feat!:` / `feat(scope)!:`. Anchored at the start of the +# subject so a `feat` mentioned mid-sentence in a fix's body cannot promote the +# whole batch to a minor bump. +FEATURE_SUBJECT = re.compile(r"^feat(\([^)]*\))?!?:", re.IGNORECASE) + +# A breaking change under conventional commits: any type with `!` before the +# colon, or a `BREAKING CHANGE:` footer. Treated as a feature, not a major -- +# see the module docstring. +BREAKING = re.compile( + r"^[a-z]+(\([^)]*\))?!:|^BREAKING[ -]CHANGE:", re.IGNORECASE | re.MULTILINE +) + +# Evaluated at import, so it cannot use PEP 585 builtin generics: the whole +# suite runs on 3.8 in the `test-legacy` CI job. +Version = Tuple[int, int, int] + + +def parse_final(tag: str) -> Optional[Version]: + """``(major, minor, patch)`` for a released tag, else ``None``.""" + match = FINAL_TAG.match(tag.strip()) + if not match: + return None + return (int(match[1]), int(match[2]), int(match[3])) + + +def latest_final(tags: List[str]) -> Version: + """Highest released version among *tags*, or ``(0, 0, 0)`` if there is none. + + ``(0, 0, 0)`` means "nothing has shipped yet", which makes the first fix-only + batch 0.0.1 and the first batch with a feature 0.1.0. + """ + finals = [v for v in (parse_final(tag) for tag in tags) if v is not None] + return max(finals) if finals else (0, 0, 0) + + +def has_feature(messages: List[str]) -> bool: + """Does any commit in *messages* introduce a feature (or break something)? + + Each element is a whole commit message, so the subject is its first line; + the ``BREAKING CHANGE:`` footer is matched anywhere in the body. + """ + for message in messages: + subject = message.strip().splitlines()[0] if message.strip() else "" + if FEATURE_SUBJECT.match(subject.strip()): + return True + if BREAKING.search(message): + return True + return False + + +def target_version(base: Version, messages: List[str]) -> Optional[Version]: + """The version this batch is heading for, or ``None`` if it is empty. + + ``None`` is not an error: a re-run of a workflow on an already-tagged commit + has no commits since the baseline, and the right answer there is "nothing to + tag" rather than a version nobody asked for. + """ + if not messages: + return None + major, minor, patch = base + if has_feature(messages): + return (major, minor + 1, 0) + return (major, minor, patch + 1) + + +def next_rc_number(target: Version, tags: List[str]) -> int: + """The next candidate number for *target*: one above the highest seen. + + Counts from the tags rather than from a stored counter so a deleted or + re-pushed tag cannot make this hand out a number that is already taken. + """ + highest = 0 + for tag in tags: + match = RC_TAG.match(tag.strip()) + if not match: + continue + if (int(match[1]), int(match[2]), int(match[3])) == target: + highest = max(highest, int(match[4])) + return highest + 1 + + +def format_version(version: Version) -> str: + return f"v{version[0]}.{version[1]}.{version[2]}" + + +def compute(channel: str, tags: List[str], messages: List[str]) -> Optional[str]: + """The tag to create for *channel*, or ``None`` when there is nothing to tag. + + Pure: every input is passed in, so the whole policy is testable without a + repository. ``stable`` is ``main``'s final release; ``nightly`` is the + candidate heading for the same target. + """ + if channel not in ("stable", "nightly"): + raise ValueError(f"unknown channel {channel!r}") + target = target_version(latest_final(tags), messages) + if target is None: + return None + if channel == "stable": + return format_version(target) + return f"{format_version(target)}rc{next_rc_number(target, tags)}" + + +# --------------------------------------------------------------------------- +# git boundary -- the only impure part, kept as thin as possible +# --------------------------------------------------------------------------- + + +def _git(args: List[str], repo_dir: str) -> str: + result = subprocess.run( + ["git", *args], + cwd=repo_dir, + capture_output=True, + text=True, + check=True, + ) + return result.stdout + + +def git_tags(repo_dir: str) -> List[str]: + """Every tag in the repository, unordered. + + Ordering is this module's job, not git's: asking git to sort would put the + policy back in a shell pipeline, which is what having this file avoids. + """ + return [line for line in _git(["tag"], repo_dir).splitlines() if line.strip()] + + +def commit_messages(repo_dir: str, base: Version) -> List[str]: + """Whole commit messages on HEAD since the *base* release, newest first. + + A ``(0, 0, 0)`` base means nothing has shipped, so the entire history counts. + NUL-delimited because a commit body contains blank lines and any line-based + split would chop one message into several. + """ + rev_range = "HEAD" if base == (0, 0, 0) else f"{format_version(base)}..HEAD" + out = _git(["log", rev_range, "--format=%B%x00"], repo_dir) + return [chunk for chunk in out.split("\0") if chunk.strip()] + + +def main(argv: Optional[List[str]] = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--channel", required=True, choices=["stable", "nightly"]) + parser.add_argument("--repo-dir", default=".") + args = parser.parse_args(argv) + + tags = git_tags(args.repo_dir) + messages = commit_messages(args.repo_dir, latest_final(tags)) + tag = compute(args.channel, tags, messages) + if tag is None: + return 0 + print(tag) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/uv.lock b/uv.lock index ac7c673..2f2cbe3 100644 --- a/uv.lock +++ b/uv.lock @@ -147,7 +147,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version >= '3.10' and python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version == '3.10.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -369,6 +369,7 @@ source = { editable = "." } [package.dev-dependencies] dev = [ { name = "bandit", extra = ["toml"], marker = "python_full_version >= '3.10'" }, + { name = "packaging", marker = "python_full_version >= '3.10'" }, { name = "pytest", marker = "python_full_version >= '3.10'" }, { name = "pytest-cov", marker = "python_full_version >= '3.10'" }, { name = "ruff", marker = "python_full_version >= '3.10'" }, @@ -379,6 +380,7 @@ dev = [ [package.metadata.requires-dev] dev = [ { name = "bandit", extras = ["toml"], specifier = "==1.9.4" }, + { name = "packaging", specifier = ">=24.0" }, { name = "pytest", specifier = ">=9.0.3" }, { name = "pytest-cov", specifier = ">=5.0.0" }, { name = "ruff", specifier = "==0.16.4" }, From aabca9ae5d31b19b6a6e627105cb60555f67afa3 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Wed, 26 Aug 2026 13:33:09 -0400 Subject: [PATCH 04/22] fix: replace nightly-dev refs with nightly and add CLI boundary tests 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 --- tests/test_next_version.py | 98 +++++++++++++++++++++++++++++++++++--- 1 file changed, 92 insertions(+), 6 deletions(-) diff --git a/tests/test_next_version.py b/tests/test_next_version.py index b098db0..4d08c8b 100644 --- a/tests/test_next_version.py +++ b/tests/test_next_version.py @@ -107,9 +107,10 @@ def test_non_feature_subjects_are_not_features(subject): def test_the_word_feature_in_a_body_does_not_promote_the_batch(): - """Anchored at the subject on purpose. A fix whose body explains which - feature it repairs must not cut a minor release.""" - message = "fix(attacks): correct the mask\n\nThis feature was broken: feat\n" + """Anchored at the subject on purpose. A fix whose body contains a line + starting with `feat:` (e.g., from git revert or squash-merge) must not cut + a minor release — only the subject matters.""" + message = "fix: revert the thing\n\nThis reverts commit abc123.\n\nfeat: add the thing\n" assert not has_feature([message]) @@ -186,7 +187,7 @@ def test_nightly_cuts_a_candidate_for_the_next_version(): def test_main_cuts_the_final_of_the_same_target(): - """Merging nightly-dev down promotes the candidate rather than inventing a + """Merging nightly down promotes the candidate rather than inventing a different number: the fix-only cycle above ends at 2.20.1, not 2.21.0.""" tags = ["v2.20.0", "v2.20.1rc1", "v2.20.1rc2"] assert compute("stable", tags, ["fix: a"]) == "v2.20.1" @@ -300,7 +301,7 @@ def test_commit_messages_since_baseline_excludes_the_baseline_itself(tmp_path): def test_baseline_need_not_be_reachable_from_head(tmp_path): - """main's release tag can sit on a commit nightly-dev does not contain. + """main's release tag can sit on a commit nightly does not contain. A reachability-restricted baseline would compute the next nightly from a stale release and hand out a version below what already shipped. @@ -319,7 +320,7 @@ def test_baseline_need_not_be_reachable_from_head(tmp_path): _git("commit", "-qm", "fix: released on main", cwd=repo) _git("tag", "v2.20.1", cwd=repo) - _git("checkout", "-q", "-b", "nightly-dev", "v2.20.0", cwd=repo) + _git("checkout", "-q", "-b", "nightly", "v2.20.0", cwd=repo) (repo / "f.txt").write_text("nightly\n") _git("add", "-A", cwd=repo) _git("commit", "-qm", "fix: on the nightly branch", cwd=repo) @@ -335,6 +336,91 @@ def test_baseline_need_not_be_reachable_from_head(tmp_path): assert parse("2.20.1") < parse(got.lstrip("v")) +def test_main_prints_candidate_for_nightly(tmp_path, capsys): + """main(["--channel", "nightly", "--repo-dir", str(repo)]) returns 0 and + prints a candidate tag matching ^v\\d+\\.\\d+\\.\\d+rc\\d+$.""" + repo = tmp_path / "repo" + repo.mkdir() + _git("init", "-q", "-b", "main", cwd=repo) + (repo / "f.txt").write_text("a\n") + _git("add", "-A", cwd=repo) + _git("commit", "-qm", "fix: first thing", cwd=repo) + + from tools.next_version import main + + result = main(["--channel", "nightly", "--repo-dir", str(repo)]) + assert result == 0 + captured = capsys.readouterr() + assert captured.out.strip(), "nightly must print a tag" + assert captured.out.strip().startswith("v") + # Verify format: v0.0.1rc1 + import re + + assert re.match(r"^v\d+\.\d+\.\d+rc\d+$", captured.out.strip()) + + +def test_main_prints_final_for_stable(tmp_path, capsys): + """main(["--channel", "stable", "--repo-dir", str(repo)]) returns 0 and + prints a final tag matching ^v\\d+\\.\\d+\\.\\d+$.""" + repo = tmp_path / "repo" + repo.mkdir() + _git("init", "-q", "-b", "main", cwd=repo) + (repo / "f.txt").write_text("a\n") + _git("add", "-A", cwd=repo) + _git("commit", "-qm", "fix: first thing", cwd=repo) + + from tools.next_version import main + + result = main(["--channel", "stable", "--repo-dir", str(repo)]) + assert result == 0 + captured = capsys.readouterr() + assert captured.out.strip(), "stable must print a tag" + assert captured.out.strip().startswith("v") + # Verify format: v0.0.1 (no rc suffix) + import re + + assert re.match(r"^v\d+\.\d+\.\d+$", captured.out.strip()) + + +def test_main_prints_nothing_when_already_tagged(tmp_path, capsys): + """main returns 0 and prints NOTHING when HEAD is already at the latest + final tag — the empty-batch case. This is what workflows treat as + 'nothing to tag'.""" + repo = tmp_path / "repo" + repo.mkdir() + _git("init", "-q", "-b", "main", cwd=repo) + (repo / "f.txt").write_text("a\n") + _git("add", "-A", cwd=repo) + _git("commit", "-qm", "fix: first thing", cwd=repo) + _git("tag", "v0.0.1", cwd=repo) + + from tools.next_version import main + + result = main(["--channel", "stable", "--repo-dir", str(repo)]) + assert result == 0 + captured = capsys.readouterr() + assert ( + captured.out.strip() == "" + ), f"empty batch must print nothing, got: {captured.out!r}" + + +def test_git_tags_returns_tags_from_repo(tmp_path): + """git_tags() returns the tags of a real repository.""" + repo = tmp_path / "repo" + repo.mkdir() + _git("init", "-q", "-b", "main", cwd=repo) + (repo / "f.txt").write_text("a\n") + _git("add", "-A", cwd=repo) + _git("commit", "-qm", "feat: initial", cwd=repo) + _git("tag", "v0.1.0", cwd=repo) + _git("tag", "v0.1.0rc1", cwd=repo) + + from tools.next_version import git_tags + + tags = git_tags(str(repo)) + assert sorted(tags) == ["v0.1.0", "v0.1.0rc1"] + + def test_a_repository_with_no_tags_cuts_the_first_patch(): """SpooNMAP starts from zero: no seed tag is pushed, deliberately.""" assert compute("stable", [], ["fix: first fix"]) == "v0.0.1" From dd3c2de3ff16a7d356fafba98c588dd187131224 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Wed, 26 Aug 2026 13:42:09 -0400 Subject: [PATCH 05/22] feat: derive the package version from git tags via hatch-vcs 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 --- .github/workflows/ci.yml | 32 ++++++++++++++++++++++++++++++++ pyproject.toml | 18 ++++++++++++++++-- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 834eeff..7868413 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -363,6 +363,12 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + # hatch-vcs derives the version from git describe. Under the default + # depth-1 clone this does not fail -- it silently produces a version + # computed from no tag at all (0.0.post1.dev1 where the answer is + # 0.0.1.post1.dev1), so every artifact this job inspects would carry + # a version no release ever had. + fetch-depth: 0 - name: Install uv uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 @@ -373,6 +379,32 @@ jobs: - name: Build sdist and wheel run: uv build + - name: Assert artifacts carry a VCS-derived version + run: | + python3 - <<'PYEOF' + import glob + import os + import sys + + # A depth-1 clone yields 0.0.post1.dev1 -- a version derived from no + # tag. Once a tag exists, anything starting 0.0.post means the + # checkout could not see it. This is the assertion that would have + # caught a fetch-depth regression. + names = [os.path.basename(p) for p in glob.glob('dist/*')] + if not names: + sys.exit('no artifacts were built') + import subprocess + tags = subprocess.run( + ['git', 'tag'], capture_output=True, text=True, check=True + ).stdout.split() + if tags and any(n.startswith('spoonmap-0.0.post') for n in names): + sys.exit( + 'artifacts were versioned from no tag despite tags existing ' + '(shallow checkout?): ' + ', '.join(names) + ) + print('artifact versions: ' + ', '.join(names)) + PYEOF + - name: Assert sdist excludes local scratch run: | python3 - <<'PYEOF' diff --git a/pyproject.toml b/pyproject.toml index cd65451..41fca59 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,10 @@ [build-system] -requires = ["hatchling"] +requires = ["hatchling", "hatch-vcs"] build-backend = "hatchling.build" [project] name = "spoonmap" -version = "0.1.0" +dynamic = ["version"] description = "masscan + nmap orchestration wrapper for fast network scanning" readme = "README.md" requires-python = ">=3.8" @@ -72,6 +72,19 @@ exclude_lines = [ "if __name__ == .__main__.:", ] +# The version is derived from git tags, not stored here. Tags are cut by +# .github/workflows/{auto,nightly}-tag.yml from tools/next_version.py, so a +# hand-maintained version string would only ever be a second, drifting copy +# of what the tags already say. +# +# no-guess-dev an untagged commit after v0.0.1 reads 0.0.1.post1.dev1 +# rather than guessing the next release it might become. +# no-local-version drops the +g suffix, which is not a valid version +# for an index and makes tag-to-artifact comparison noisy. +[tool.hatch.version] +source = "vcs" +raw-options = { version_scheme = "no-guess-dev", local_scheme = "no-local-version" } + [tool.hatch.build.targets.wheel] only-include = ["spoonmap.py"] @@ -111,6 +124,7 @@ include = [ "spoonmap.py", "nse/", "tests/", + "tools/", "README.md", "CLAUDE.md", "config.json.sample", From 72615fb0f2300effef4b80dab99444fd6b70e76b Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Wed, 26 Aug 2026 13:50:44 -0400 Subject: [PATCH 06/22] fix: add tools/ to sdist verification and assert set equality 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 --- .github/workflows/ci.yml | 23 +++++++++++++++++++++-- uv.lock | 1 - 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7868413..2fba7af 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -474,7 +474,7 @@ jobs: # today, but is intentionally a separate, hand-maintained list — see # the step comment for why it must not be read from that same file. required = ( - 'spoonmap.py', 'nse/', 'tests/', 'README.md', 'CLAUDE.md', + 'spoonmap.py', 'nse/', 'tests/', 'tools/', 'README.md', 'CLAUDE.md', 'config.json.sample', 'exclusions.txt', 'pyproject.toml', 'uv.lock', '.bandit-baseline.json', '.github/workflows/', ) @@ -512,9 +512,28 @@ jobs: 'sdist nse/ does not match git-tracked nse/ exactly ' f'— missing: {nse_missing}, extra: {nse_extra}' ) + + tracked_tools = subprocess.run( + ['git', 'ls-files', 'tools/'], + capture_output=True, text=True, check=True, + ).stdout.splitlines() + tracked_tools_set = {t[len('tools/'):] for t in tracked_tools} + sdist_tools = { + s[len('tools/'):] for s in stripped + if s.startswith('tools/') and not s.endswith('/') + } + tools_missing = sorted(tracked_tools_set - sdist_tools) + tools_extra = sorted(sdist_tools - tracked_tools_set) + if tools_missing or tools_extra: + sys.exit( + 'sdist tools/ does not match git-tracked tools/ exactly ' + f'— missing: {tools_missing}, extra: {tools_extra}' + ) + print( f'sdist has all {len(required)} required entries; ' - f'nse/ matches git-tracked files exactly ({len(tracked_nse)} files)' + f'nse/ matches git-tracked files exactly ({len(tracked_nse)} files); ' + f'tools/ matches git-tracked files exactly ({len(tracked_tools_set)} files)' ) PYEOF diff --git a/uv.lock b/uv.lock index 2f2cbe3..f8f1f64 100644 --- a/uv.lock +++ b/uv.lock @@ -363,7 +363,6 @@ wheels = [ [[package]] name = "spoonmap" -version = "0.1.0" source = { editable = "." } [package.dev-dependencies] From fe38d9475d35b1fdaf7f2056bd84982079b9588f Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Wed, 26 Aug 2026 13:58:59 -0400 Subject: [PATCH 07/22] feat: tag releases automatically from CI on main and nightly 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 --- .github/workflows/auto-tag.yml | 112 ++++++++++++++++++++++++++++++ .github/workflows/ci.yml | 5 +- .github/workflows/nightly-tag.yml | 103 +++++++++++++++++++++++++++ .github/workflows/release.yml | 33 +++++++++ 4 files changed, 252 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/auto-tag.yml create mode 100644 .github/workflows/nightly-tag.yml create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/auto-tag.yml b/.github/workflows/auto-tag.yml new file mode 100644 index 0000000..58a0fea --- /dev/null +++ b/.github/workflows/auto-tag.yml @@ -0,0 +1,112 @@ +name: Auto Tag + +# Cuts the stable release on main by promoting the candidate that `nightly` has +# been building: a fix-only cycle ends at X.Y.(Z+1), a cycle containing any +# feature ends at X.(Y+1).0. +# +# The bump is NOT forced per branch. main is not always X.Y.0. Deriving the bump +# from the batch is the point: forcing a minor on every merge takes a project +# two minor versions in an hour for two bugfixes. +# +# The policy lives in tools/next_version.py, shared with nightly-tag.yml and +# unit-tested in tests/test_next_version.py. Nothing here parses or increments a +# version number. Do not add that here -- add to the module, where it is tested. +# +# Runs only after CI finishes successfully on main, so a broken commit is never +# tagged or released. +on: + workflow_run: + workflows: ["CI"] + types: + - completed + branches: + - main + +permissions: + contents: write + +# Two merges landing back-to-back would otherwise both compute the same new tag +# and the second push would fail. Serialize instead of cancelling so no merge is +# skipped. +concurrency: + group: auto-tag + cancel-in-progress: false + +jobs: + tag: + runs-on: ubuntu-latest + timeout-minutes: 10 + if: >- + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # workflow_run defaults to the tip of the default branch, which is not + # necessarily the commit CI validated. + ref: ${{ github.event.workflow_run.head_sha }} + # The baseline is read from tags. Under a shallow clone the project + # version reads as 0.0.0 and this would tag nonsense. + fetch-depth: 0 + # Deliberate exception to this repo's persist-credentials: false + # convention: this job pushes a tag and needs the token to do it. + persist-credentials: true + + - name: Configure git identity + run: | + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + + - name: Compute release tag + id: bump + run: | + set -euo pipefail + # The whole decision -- which component moves, and to what -- is + # tools/next_version.py's. Keeping it out of YAML is the point: this + # step cannot be unit-tested and the policy can. + new_tag=$(python3 tools/next_version.py --channel stable) + echo "Release tag: ${new_tag:-}" + echo "new_tag=$new_tag" >> "$GITHUB_OUTPUT" + + - name: Create tag + env: + NEW_TAG: ${{ steps.bump.outputs.new_tag }} + run: | + set -euo pipefail + # Empty means no commits since the last release -- a re-run on an + # already-released commit. Nothing to do, and not an error. + # + # This is not a "no feat/fix commits, skip" early exit: a docs- or + # chore-only merge is still a release, it just cuts a patch rather + # than a minor. Only a genuinely empty batch is skipped. + if [ -z "$NEW_TAG" ]; then + echo "No commits since the last release; nothing to tag" + exit 0 + fi + # Idempotent: a re-run of this workflow must not fail the job. + if git rev-parse -q --verify "refs/tags/$NEW_TAG" >/dev/null; then + echo "Tag $NEW_TAG already exists, nothing to push" + else + git tag "$NEW_TAG" + git push origin "refs/tags/$NEW_TAG" + fi + + # GitHub never dispatches workflow events for refs pushed with + # GITHUB_TOKEN, so release.yml will not fire for the tag above. Create the + # release here instead. release.yml remains the path for tags pushed + # manually by a human. + - name: Create GitHub release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NEW_TAG: ${{ steps.bump.outputs.new_tag }} + run: | + set -euo pipefail + if [ -z "$NEW_TAG" ]; then + echo "No tag was created; no release to publish" + exit 0 + fi + if gh release view "$NEW_TAG" >/dev/null 2>&1; then + echo "Release $NEW_TAG already exists, nothing to do" + exit 0 + fi + gh release create "$NEW_TAG" --generate-notes diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2fba7af..45eee58 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,10 @@ name: CI on: pull_request: push: - branches: [main] + # `nightly` is here because nightly-tag.yml triggers on a completed CI run + # for that branch. Without it, pushes to nightly run no CI at all and the + # tagging workflow silently never fires. + branches: [main, nightly] # A new push to the same PR supersedes the previous run. Scoped to pull_request # only: on `main`, cancelling the previous commit's in-progress run because a diff --git a/.github/workflows/nightly-tag.yml b/.github/workflows/nightly-tag.yml new file mode 100644 index 0000000..d5cc3cb --- /dev/null +++ b/.github/workflows/nightly-tag.yml @@ -0,0 +1,103 @@ +name: Nightly Tag + +# Tags `nightly` after CI passes, as a RELEASE CANDIDATE for whichever version +# the batch is heading toward: v0.0.1rc1, v0.0.1rc2, ... for a fix-only cycle, +# v0.1.0rc1 for one containing a feature. Merging down to main then promotes +# that same target to its final release. +# +# These are real PEP 440 pre-releases, so they order correctly at both ends: +# +# 0.0.0 < 0.0.1rc1 < 0.0.1rc2 < 0.0.1 < 0.1.0rc1 < 0.1.0 +# +# Aiming one version forward is what makes that true. A candidate named for the +# *current* version would sort below the release it is heading for. +# +# The target can change mid-cycle: the first `feat` to land moves it from +# X.Y.(Z+1) to X.(Y+1).0 and candidate numbering restarts. That is intended -- +# the number always names what the batch would ship as today. +# +# The policy lives in tools/next_version.py, shared with auto-tag.yml and +# unit-tested in tests/test_next_version.py. Nothing here parses or increments a +# version number. Do not add that here -- add to the module, where it is tested. +# +# No GitHub release is created; see the end of this file. +# +# This file MUST live on the default branch (main). GitHub only dispatches +# workflow_run for workflows present on the default branch, so a copy existing +# solely on `nightly` never fires. +on: + workflow_run: + workflows: ["CI"] + types: + - completed + branches: + - nightly + +permissions: + contents: write + +# Two pushes landing back-to-back would otherwise both compute the same tag and +# the second push would fail. Serialize instead of cancelling so no push is +# skipped. +concurrency: + group: nightly-tag + cancel-in-progress: false + +jobs: + tag: + runs-on: ubuntu-latest + timeout-minutes: 10 + if: >- + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # workflow_run defaults to the tip of the default branch, which is not + # the commit CI validated. + ref: ${{ github.event.workflow_run.head_sha }} + # The baseline is read from tags. Under a shallow clone the project + # version reads as 0.0.0 and this would tag nonsense. + fetch-depth: 0 + # Deliberate exception to this repo's persist-credentials: false + # convention: this job pushes a tag and needs the token to do it. + persist-credentials: true + + - name: Configure git identity + run: | + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + + - name: Compute nightly tag + id: bump + run: | + set -euo pipefail + # tools/next_version.py owns the decision; see the header. This step + # deliberately contains no version logic of its own. + new_tag=$(python3 tools/next_version.py --channel nightly) + echo "Nightly tag: ${new_tag:-}" + echo "new_tag=$new_tag" >> "$GITHUB_OUTPUT" + + - name: Create tag + env: + NEW_TAG: ${{ steps.bump.outputs.new_tag }} + run: | + set -euo pipefail + # Empty means no commits since the last release: nothing to build a + # candidate from. Not an error -- a workflow re-run lands here, and + # `git tag ""` fails with a message about nothing in particular. + if [ -z "$NEW_TAG" ]; then + echo "No commits since the last release; nothing to tag" + exit 0 + fi + # Idempotent: a re-run of this workflow must not fail the job. + if git rev-parse -q --verify "refs/tags/$NEW_TAG" >/dev/null; then + echo "Tag $NEW_TAG already exists, nothing to push" + else + git tag "$NEW_TAG" + git push origin "refs/tags/$NEW_TAG" + fi + + # No GitHub release is created. These tags exist to make nightly builds + # addressable and to give hatch-vcs a version; releases are cut on main by + # auto-tag.yml. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..0d59259 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,33 @@ +name: Release + +# The path for tags a human pushes by hand. The automatic policy never bumps the +# major component -- a breaking marker counts as a feature, because an automatic +# major is an irreversible published mistake waiting for one mistyped subject +# line -- so a major release is `git tag v1.0.0 && git push`, and this is what +# turns that into a release. +# +# Tags pushed by auto-tag.yml do NOT reach here: GitHub does not dispatch +# workflow events for refs pushed with GITHUB_TOKEN. That job creates its own +# release. +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # Read-only: this job creates a release from a tag that already + # exists, so unlike the two tagging workflows it needs no credentials. + persist-credentials: false + + - uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 + with: + generate_release_notes: true From b49e9a1804bae69e7b00a32e87e1a61604c22421 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Wed, 26 Aug 2026 14:10:17 -0400 Subject: [PATCH 08/22] refactor: move tagging from separate workflows into ci.yml job 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 --- .github/workflows/auto-tag.yml | 112 ---------------------------- .github/workflows/ci.yml | 120 ++++++++++++++++++++++++++++++ .github/workflows/nightly-tag.yml | 103 ------------------------- .github/workflows/release.yml | 23 ++++-- 4 files changed, 137 insertions(+), 221 deletions(-) delete mode 100644 .github/workflows/auto-tag.yml delete mode 100644 .github/workflows/nightly-tag.yml diff --git a/.github/workflows/auto-tag.yml b/.github/workflows/auto-tag.yml deleted file mode 100644 index 58a0fea..0000000 --- a/.github/workflows/auto-tag.yml +++ /dev/null @@ -1,112 +0,0 @@ -name: Auto Tag - -# Cuts the stable release on main by promoting the candidate that `nightly` has -# been building: a fix-only cycle ends at X.Y.(Z+1), a cycle containing any -# feature ends at X.(Y+1).0. -# -# The bump is NOT forced per branch. main is not always X.Y.0. Deriving the bump -# from the batch is the point: forcing a minor on every merge takes a project -# two minor versions in an hour for two bugfixes. -# -# The policy lives in tools/next_version.py, shared with nightly-tag.yml and -# unit-tested in tests/test_next_version.py. Nothing here parses or increments a -# version number. Do not add that here -- add to the module, where it is tested. -# -# Runs only after CI finishes successfully on main, so a broken commit is never -# tagged or released. -on: - workflow_run: - workflows: ["CI"] - types: - - completed - branches: - - main - -permissions: - contents: write - -# Two merges landing back-to-back would otherwise both compute the same new tag -# and the second push would fail. Serialize instead of cancelling so no merge is -# skipped. -concurrency: - group: auto-tag - cancel-in-progress: false - -jobs: - tag: - runs-on: ubuntu-latest - timeout-minutes: 10 - if: >- - github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.event == 'push' - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - # workflow_run defaults to the tip of the default branch, which is not - # necessarily the commit CI validated. - ref: ${{ github.event.workflow_run.head_sha }} - # The baseline is read from tags. Under a shallow clone the project - # version reads as 0.0.0 and this would tag nonsense. - fetch-depth: 0 - # Deliberate exception to this repo's persist-credentials: false - # convention: this job pushes a tag and needs the token to do it. - persist-credentials: true - - - name: Configure git identity - run: | - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - - - name: Compute release tag - id: bump - run: | - set -euo pipefail - # The whole decision -- which component moves, and to what -- is - # tools/next_version.py's. Keeping it out of YAML is the point: this - # step cannot be unit-tested and the policy can. - new_tag=$(python3 tools/next_version.py --channel stable) - echo "Release tag: ${new_tag:-}" - echo "new_tag=$new_tag" >> "$GITHUB_OUTPUT" - - - name: Create tag - env: - NEW_TAG: ${{ steps.bump.outputs.new_tag }} - run: | - set -euo pipefail - # Empty means no commits since the last release -- a re-run on an - # already-released commit. Nothing to do, and not an error. - # - # This is not a "no feat/fix commits, skip" early exit: a docs- or - # chore-only merge is still a release, it just cuts a patch rather - # than a minor. Only a genuinely empty batch is skipped. - if [ -z "$NEW_TAG" ]; then - echo "No commits since the last release; nothing to tag" - exit 0 - fi - # Idempotent: a re-run of this workflow must not fail the job. - if git rev-parse -q --verify "refs/tags/$NEW_TAG" >/dev/null; then - echo "Tag $NEW_TAG already exists, nothing to push" - else - git tag "$NEW_TAG" - git push origin "refs/tags/$NEW_TAG" - fi - - # GitHub never dispatches workflow events for refs pushed with - # GITHUB_TOKEN, so release.yml will not fire for the tag above. Create the - # release here instead. release.yml remains the path for tags pushed - # manually by a human. - - name: Create GitHub release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - NEW_TAG: ${{ steps.bump.outputs.new_tag }} - run: | - set -euo pipefail - if [ -z "$NEW_TAG" ]; then - echo "No tag was created; no release to publish" - exit 0 - fi - if gh release view "$NEW_TAG" >/dev/null 2>&1; then - echo "Release $NEW_TAG already exists, nothing to do" - exit 0 - fi - gh release create "$NEW_TAG" --generate-notes diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 45eee58..33f53cd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -633,3 +633,123 @@ jobs: ) print(f'installed wheel: {len(paths)} NSE paths all resolve on disk') PYEOF + + # Cuts release tags from the commits themselves, once every other job in this + # run has passed. `nightly` cuts release candidates for whichever version the + # batch is heading toward (v0.1.0rc1, v0.1.0rc2, ...) and `main` promotes that + # same target to its final release, so they order correctly at both ends: + # + # 0.0.0 < 0.1.0rc1 < 0.1.0rc2 < 0.1.0 < 0.2.0rc1 < 0.2.0 + # + # This lives inside ci.yml, gated on `needs`, rather than in a separate + # workflow triggered by `workflow_run`. That was the original design and it + # was rejected: zizmor rates `workflow_run` an error-level dangerous trigger + # (it is the standard privilege-escalation vector, since the triggered + # workflow runs with write permissions against a ref the triggering run + # chose), and silencing that with an ignore comment is not something this + # repo does. Being a `needs` dependent of the jobs that validate the commit + # gets the same "only tag what passed CI" guarantee without the trigger, and + # without needing to check out an explicitly-passed head SHA. + # + # Nothing here parses or increments a version number. The whole policy -- + # which component moves, and to what -- lives in tools/next_version.py, where + # it is unit-tested in tests/test_next_version.py. Do not add version + # arithmetic to this job; add it to the module, where it can be tested. + tag: + name: tag release + runs-on: ubuntu-latest + timeout-minutes: 10 + # Every job that validates this commit. A tag must never appear on a commit + # that failed anything: `needs` treats a skipped or failed dependency as + # not-success, so this job simply does not run. + needs: [test, test-legacy, lint, bandit, nse-root, workflow-lint, build] + # Pushes only, and only to the two release branches. ci.yml also runs on + # pull_request, where tagging would be actively wrong. + if: >- + github.event_name == 'push' && + (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/nightly') + # This workflow declares contents: read at the top level. This job alone + # needs write, to push a tag and create a release. + permissions: + contents: write + # Two pushes landing back-to-back would otherwise both compute the same tag + # and the second push would fail. Serialize per branch instead of + # cancelling, so no push is skipped. + concurrency: + group: tag-${{ github.ref }} + cancel-in-progress: false + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # The baseline is the highest final tag in the repository. Under the + # default depth-1 clone there are none, so this would compute from + # 0.0.0 and hand out a version that already shipped. + fetch-depth: 0 + # Deliberate exception to this repo's persist-credentials: false + # convention: this job pushes a tag and needs the token to do it. + persist-credentials: true + + - name: Configure git identity + run: | + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + + - name: Compute tag + id: bump + run: | + set -euo pipefail + # main cuts the final release; nightly cuts a candidate for the same + # target. tools/next_version.py owns the decision entirely. + if [ "$GITHUB_REF" = "refs/heads/main" ]; then + channel=stable + else + channel=nightly + fi + new_tag=$(python3 tools/next_version.py --channel "$channel") + echo "Channel: $channel; tag: ${new_tag:-}" + echo "new_tag=$new_tag" >> "$GITHUB_OUTPUT" + echo "channel=$channel" >> "$GITHUB_OUTPUT" + + - name: Create tag + env: + NEW_TAG: ${{ steps.bump.outputs.new_tag }} + run: | + set -euo pipefail + # Empty means no commits since the last release -- a re-run on an + # already-tagged commit. Nothing to do, and not an error; `git tag ""` + # would fail with a message about nothing in particular. + # + # This is NOT a "no feat/fix commits, skip" early exit: a docs- or + # chore-only batch is still a release, it just cuts a patch rather + # than a minor. Only a genuinely empty batch is skipped. + if [ -z "$NEW_TAG" ]; then + echo "No commits since the last release; nothing to tag" + exit 0 + fi + # Idempotent: a re-run of this workflow must not fail the job. + if git rev-parse -q --verify "refs/tags/$NEW_TAG" >/dev/null; then + echo "Tag $NEW_TAG already exists, nothing to push" + else + git tag "$NEW_TAG" + git push origin "refs/tags/$NEW_TAG" + fi + + # Only main publishes. Nightly candidate tags exist to make builds + # addressable and to give hatch-vcs a version; they are deliberately not + # releases, so nothing ranking releases ever sees a nightly as latest. + - name: Create GitHub release + if: steps.bump.outputs.channel == 'stable' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NEW_TAG: ${{ steps.bump.outputs.new_tag }} + run: | + set -euo pipefail + if [ -z "$NEW_TAG" ]; then + echo "No tag was created; no release to publish" + exit 0 + fi + if gh release view "$NEW_TAG" >/dev/null 2>&1; then + echo "Release $NEW_TAG already exists, nothing to do" + exit 0 + fi + gh release create "$NEW_TAG" --generate-notes diff --git a/.github/workflows/nightly-tag.yml b/.github/workflows/nightly-tag.yml deleted file mode 100644 index d5cc3cb..0000000 --- a/.github/workflows/nightly-tag.yml +++ /dev/null @@ -1,103 +0,0 @@ -name: Nightly Tag - -# Tags `nightly` after CI passes, as a RELEASE CANDIDATE for whichever version -# the batch is heading toward: v0.0.1rc1, v0.0.1rc2, ... for a fix-only cycle, -# v0.1.0rc1 for one containing a feature. Merging down to main then promotes -# that same target to its final release. -# -# These are real PEP 440 pre-releases, so they order correctly at both ends: -# -# 0.0.0 < 0.0.1rc1 < 0.0.1rc2 < 0.0.1 < 0.1.0rc1 < 0.1.0 -# -# Aiming one version forward is what makes that true. A candidate named for the -# *current* version would sort below the release it is heading for. -# -# The target can change mid-cycle: the first `feat` to land moves it from -# X.Y.(Z+1) to X.(Y+1).0 and candidate numbering restarts. That is intended -- -# the number always names what the batch would ship as today. -# -# The policy lives in tools/next_version.py, shared with auto-tag.yml and -# unit-tested in tests/test_next_version.py. Nothing here parses or increments a -# version number. Do not add that here -- add to the module, where it is tested. -# -# No GitHub release is created; see the end of this file. -# -# This file MUST live on the default branch (main). GitHub only dispatches -# workflow_run for workflows present on the default branch, so a copy existing -# solely on `nightly` never fires. -on: - workflow_run: - workflows: ["CI"] - types: - - completed - branches: - - nightly - -permissions: - contents: write - -# Two pushes landing back-to-back would otherwise both compute the same tag and -# the second push would fail. Serialize instead of cancelling so no push is -# skipped. -concurrency: - group: nightly-tag - cancel-in-progress: false - -jobs: - tag: - runs-on: ubuntu-latest - timeout-minutes: 10 - if: >- - github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.event == 'push' - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - # workflow_run defaults to the tip of the default branch, which is not - # the commit CI validated. - ref: ${{ github.event.workflow_run.head_sha }} - # The baseline is read from tags. Under a shallow clone the project - # version reads as 0.0.0 and this would tag nonsense. - fetch-depth: 0 - # Deliberate exception to this repo's persist-credentials: false - # convention: this job pushes a tag and needs the token to do it. - persist-credentials: true - - - name: Configure git identity - run: | - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - - - name: Compute nightly tag - id: bump - run: | - set -euo pipefail - # tools/next_version.py owns the decision; see the header. This step - # deliberately contains no version logic of its own. - new_tag=$(python3 tools/next_version.py --channel nightly) - echo "Nightly tag: ${new_tag:-}" - echo "new_tag=$new_tag" >> "$GITHUB_OUTPUT" - - - name: Create tag - env: - NEW_TAG: ${{ steps.bump.outputs.new_tag }} - run: | - set -euo pipefail - # Empty means no commits since the last release: nothing to build a - # candidate from. Not an error -- a workflow re-run lands here, and - # `git tag ""` fails with a message about nothing in particular. - if [ -z "$NEW_TAG" ]; then - echo "No commits since the last release; nothing to tag" - exit 0 - fi - # Idempotent: a re-run of this workflow must not fail the job. - if git rev-parse -q --verify "refs/tags/$NEW_TAG" >/dev/null; then - echo "Tag $NEW_TAG already exists, nothing to push" - else - git tag "$NEW_TAG" - git push origin "refs/tags/$NEW_TAG" - fi - - # No GitHub release is created. These tags exist to make nightly builds - # addressable and to give hatch-vcs a version; releases are cut on main by - # auto-tag.yml. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0d59259..023faae 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,9 +6,9 @@ name: Release # line -- so a major release is `git tag v1.0.0 && git push`, and this is what # turns that into a release. # -# Tags pushed by auto-tag.yml do NOT reach here: GitHub does not dispatch -# workflow events for refs pushed with GITHUB_TOKEN. That job creates its own -# release. +# Tags pushed by the tag job in ci.yml do NOT reach here: GitHub does not +# dispatch workflow events for refs pushed with GITHUB_TOKEN. That job creates +# its own release. on: push: tags: @@ -28,6 +28,17 @@ jobs: # exists, so unlike the two tagging workflows it needs no credentials. persist-credentials: false - - uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 - with: - generate_release_notes: true + # `gh release create` rather than a third-party action: the runner already + # ships gh, and the tag job in ci.yml already publishes this way. Two + # release paths doing the same thing two different ways is one too many. + - name: Create GitHub release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + if gh release view "$TAG" >/dev/null 2>&1; then + echo "Release $TAG already exists, nothing to do" + exit 0 + fi + gh release create "$TAG" --generate-notes From b82eeb94af7687019171f3e3b7a78e7f02fa67c5 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Wed, 26 Aug 2026 14:11:30 -0400 Subject: [PATCH 09/22] docs: record the workflow_run rejection in the plan 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 --- .../plans/2026-08-26-auto-versioning.md | 394 ++++++++++++------ 1 file changed, 270 insertions(+), 124 deletions(-) diff --git a/docs/superpowers/plans/2026-08-26-auto-versioning.md b/docs/superpowers/plans/2026-08-26-auto-versioning.md index 7ac0245..6cd95a0 100644 --- a/docs/superpowers/plans/2026-08-26-auto-versioning.md +++ b/docs/superpowers/plans/2026-08-26-auto-versioning.md @@ -298,6 +298,18 @@ all, which is why the job now asserts the version it produced." ### Task 3: The tagging workflows +> **Superseded in part, 2026-08-26.** The two `workflow_run`-triggered files +> below (`nightly-tag.yml`, `auto-tag.yml`) were built, then removed: zizmor — +> a required CI job — rates `workflow_run` an error-level dangerous trigger and +> exits 14, and this repo does not silence findings with ignore comments. +> Tagging is now a single `tag` job inside `.github/workflows/ci.yml`, gated on +> `needs: [test, test-legacy, lint, bandit, nse-root, workflow-lint, build]` +> and on `github.event_name == 'push'` for `main`/`nightly` only, with +> job-level `permissions: contents: write`. `release.yml` survives for +> hand-pushed tags but publishes via `gh release create` instead of a +> third-party action. The YAML below is kept as the record of what was tried +> and why it was rejected; see Tasks 6 and 7 for the current shape. + **Files:** - Create: `.github/workflows/nightly-tag.yml` - Create: `.github/workflows/auto-tag.yml` @@ -1099,18 +1111,38 @@ is a hardcoded https literal; B310 is scheme-blind and cannot see that." ### Task 6: Guard the wiring that breaks silently +> **Design change, 2026-08-26 (supersedes this task's original form).** Tagging +> no longer lives in separate `workflow_run`-triggered workflows. zizmor — a +> required CI job — rejects `workflow_run` at error level as a +> privilege-escalation vector and exits 14, and this repo does not silence +> findings with ignore comments. Tagging is now a single `tag` job inside +> `.github/workflows/ci.yml`, gated on `needs: [test, test-legacy, lint, +> bandit, nse-root, workflow-lint, build]` and `if: github.event_name == +> 'push'` restricted to `main`/`nightly`, with job-level `permissions: contents: +> write`. `auto-tag.yml` and `nightly-tag.yml` no longer exist. `release.yml` +> remains, for hand-pushed tags, and publishes via `gh release create` rather +> than a third-party action. + **Files:** - Create: `tests/test_release_versioning.py` - Modify: `pyproject.toml` (dev group gains `pyyaml`) -- Modify: `.github/workflows/ci.yml` (`test-legacy` job's `uv run` line, ~line 118) +- Modify: `.github/workflows/ci.yml` (`test-legacy` job's `uv run` line, ~line 118; `lint` job's ruff line, ~line 151) **Interfaces:** -- Consumes: the workflow files from Task 3, `tools/next_version.py` from Task 1. +- Consumes: the `tag` job in `ci.yml` and `release.yml` from Task 3, `tools/next_version.py` from Task 1. - Produces: nothing other tasks consume. -The policy is already tested. What is untested is everything around it: a trigger that never fires, a step that stops using the policy module, a tag that gets pushed without being the one that was computed. Those fail *silently* — no tag simply appears, and nobody notices for weeks. +The policy is already tested. What is untested is everything around it: a tag +job that stops depending on a test job, a step that stops using the policy +module, a tag pushed without being the one that was computed, a `fetch-depth` +quietly reverted. Those fail *silently* — no tag simply appears, or a wrong one +does, and nobody notices for weeks. -Assert behaviour by running the extracted step scripts, not by substring-matching YAML. hate_crack learned this the hard way: its substring assertions were defeated by replacing an entire `if`/`else` with an unconditional `git tag && git push`, and every test still passed because the substring lived elsewhere in the file. +Assert behaviour by running the extracted step scripts, not by +substring-matching YAML. hate_crack learned this the hard way: its substring +assertions were defeated by replacing an entire `if`/`else` with an +unconditional `git tag && git push`, and every test still passed because the +substring lived elsewhere in the file. - [ ] **Step 1: Make the test dependencies available on every job** @@ -1122,7 +1154,10 @@ In `pyproject.toml`'s `[dependency-groups].dev`, add: "pyyaml>=6.0", ``` -Then in `.github/workflows/ci.yml`, the `test-legacy` job resolves its own dependencies outside the project (`uv run --isolated --no-project ... --with pytest --with pytest-cov`), so it would hit an ImportError collecting the new modules. Extend that line: +Then in `.github/workflows/ci.yml`, the `test-legacy` job resolves its own +dependencies outside the project (`uv run --isolated --no-project ... --with +pytest --with pytest-cov`), so it would hit an ImportError collecting the new +modules. Extend that line: ```yaml - name: Run tests @@ -1132,7 +1167,19 @@ Then in `.github/workflows/ci.yml`, the `test-legacy` job resolves its own depen pytest tests/ -v -rs ``` -`packaging` is for `tests/test_next_version.py` (Task 1). Do not solve this with `pytest.importorskip` — a skipped guard is a guard that silently is not running, which is the exact failure this whole file exists to prevent. +`packaging` is for `tests/test_next_version.py`. It currently resolves only +because pytest happens to depend on it transitively — one pytest release away +from breaking. Do not solve any of this with `pytest.importorskip`: a skipped +guard is a guard that silently is not running, which is the exact failure this +whole file exists to prevent. + +Also extend the `lint` job's ruff invocation (~line 151) to cover the new +directory, which is currently never linted in CI: + +```yaml + - name: Ruff + run: uv run --frozen ruff check spoonmap.py tests/ tools/ +``` Run `uv lock` after editing the dev group. @@ -1150,13 +1197,14 @@ re-implements it. What this file guards is everything around the policy, all of which fails *silently*: -* A trigger that never fires. nightly-tag.yml keys on a completed CI run for - the `nightly` branch, so if ci.yml stops running on pushes to `nightly`, no - candidate is ever tagged and there is no error anywhere to notice. +* The tag job ceasing to depend on the jobs that validate the commit, which + would let a tag land on a commit that failed its tests. * The policy module ceasing to be the only thing that produces a version, - asserted as a positive invariant (exactly one next_version.py call per - workflow, and the pushed tag read back from its output) with a denylist of - shell version arithmetic as a second line of defence. + asserted as a positive invariant (exactly one next_version.py call, and the + pushed tag read back from its output) with a denylist of shell version + arithmetic as a second line of defence. +* A `fetch-depth` reverted to the default, which does not fail anything -- it + silently computes versions from a baseline of no tags at all. * The behaviour of the shell that remains -- tag idempotency and the empty-batch path -- asserted by extracting the step script from the YAML and running it against a real git repository and a real bare remote. @@ -1181,108 +1229,153 @@ def _load(name): return yaml.safe_load(handle) -def _steps(workflow): - (job,) = workflow['jobs'].values() - return job['steps'] +# `on` is the YAML 1.1 boolean True, so a parsed workflow keys the trigger +# block under True rather than 'on'. This bites everyone once. +def _triggers(workflow): + return workflow.get('on', workflow.get(True)) + + +def _job(name, job_id): + return _load(name)['jobs'][job_id] -def _step_script(workflow, name): - for step in _steps(workflow): - if step.get('name') == name: +def _step_script(job, step_name): + for step in job['steps']: + if step.get('name') == step_name: return step['run'] - raise AssertionError(f'no step named {name!r}') + raise AssertionError(f'no step named {step_name!r}') -# `on` is the YAML 1.1 boolean True, so a parsed workflow keys the trigger -# block under True rather than 'on'. This bites everyone once. -def _triggers(workflow): - return workflow.get('on', workflow.get(True)) +def _checkout(job): + for step in job['steps']: + if 'actions/checkout' in str(step.get('uses', '')): + return step + raise AssertionError('no checkout step') -# --- the triggers ------------------------------------------------------------ +# --- triggers and gating ----------------------------------------------------- -def test_ci_runs_on_pushes_to_nightly(): - """Without this, nightly-tag.yml's workflow_run trigger has nothing to key - on and no candidate is ever cut. Nothing errors; tags just stop appearing.""" +def test_ci_runs_on_pushes_to_both_release_branches(): + """A push to `nightly` that runs no CI would never reach the tag job, and + no candidate would ever be cut. Nothing errors; tags just stop appearing.""" branches = _triggers(_load('ci.yml'))['push']['branches'] assert 'nightly' in branches assert 'main' in branches -@pytest.mark.parametrize('name,branch', [ - ('nightly-tag.yml', 'nightly'), - ('auto-tag.yml', 'main'), -]) -def test_tagging_workflows_wait_for_a_successful_ci_run(name, branch): - trigger = _triggers(_load(name))['workflow_run'] - assert trigger['workflows'] == ['CI'] - assert trigger['branches'] == [branch] +def test_the_tag_job_waits_for_every_validating_job(): + """A tag must never appear on a commit that failed anything. `needs` treats + a failed or skipped dependency as not-success, so the job simply does not + run -- but only for jobs actually listed here.""" + ci = _load('ci.yml') + needs = set(ci['jobs']['tag']['needs']) + validating = {j for j in ci['jobs'] if j != 'tag'} + missing = validating - needs + assert not missing, f'tag job does not depend on: {sorted(missing)}' + + +def test_the_tag_job_never_runs_on_pull_requests(): + """ci.yml also runs on pull_request, where tagging would be actively + wrong.""" + condition = _job('ci.yml', 'tag')['if'] + assert "github.event_name == 'push'" in condition + assert "refs/heads/main" in condition + assert "refs/heads/nightly" in condition + + +def test_only_the_tag_job_can_write(): + """The workflow is read-only; exactly one job escalates, and only to what + pushing a tag and cutting a release requires.""" + ci = _load('ci.yml') + assert ci['permissions'] == {'contents': 'read'} + assert ci['jobs']['tag']['permissions'] == {'contents': 'write'} + for job_id, job in ci['jobs'].items(): + if job_id != 'tag': + assert 'permissions' not in job, job_id + + +def test_the_tag_job_does_not_cancel_itself(): + """Two pushes landing together would compute the same tag; the second push + would fail. Serialize per branch rather than cancel, so none is skipped.""" + concurrency = _job('ci.yml', 'tag')['concurrency'] + assert concurrency['cancel-in-progress'] is False - (job,) = _load(name)['jobs'].values() - assert "workflow_run.conclusion == 'success'" in job['if'] +# --- checkout depth ---------------------------------------------------------- -@pytest.mark.parametrize('name', ['nightly-tag.yml', 'auto-tag.yml']) -def test_tagging_workflows_check_out_the_commit_ci_validated(name): - """workflow_run defaults to the default branch's tip, which is not - necessarily the commit that passed CI.""" - checkout = _steps(_load(name))[0] - assert checkout['with']['ref'] == '${{ github.event.workflow_run.head_sha }}' +@pytest.mark.parametrize('job_id', ['tag', 'build']) +def test_version_deriving_jobs_fetch_all_history(job_id): + """Both jobs derive a version from git describe. A shallow clone does not + fail either of them -- it silently computes from a baseline of no tags, + which is how a wrong version ships without anything going red.""" + assert _checkout(_job('ci.yml', job_id))['with']['fetch-depth'] == 0 -@pytest.mark.parametrize('name', ['nightly-tag.yml', 'auto-tag.yml']) -def test_tagging_workflows_fetch_all_history(name): - """The baseline is the highest final tag. A shallow clone has none, so the - job would compute from 0.0.0 and tag a version that already shipped.""" - assert _steps(_load(name))[0]['with']['fetch-depth'] == 0 +def test_the_tag_job_keeps_its_credentials(): + """Deliberate exception to this repo's persist-credentials: false rule: + this job pushes a tag and needs the token. Pinned so a well-meaning + convention sweep cannot silently break tagging.""" + assert _checkout(_job('ci.yml', 'tag'))['with']['persist-credentials'] is True -@pytest.mark.parametrize('name', ['nightly-tag.yml', 'auto-tag.yml']) -def test_tagging_workflows_do_not_cancel_each_other(name): - """Two merges landing together would compute the same tag; the second push - would fail. Serialize rather than cancel so no merge is skipped.""" - assert _load(name)['concurrency']['cancel-in-progress'] is False + +def test_every_other_checkout_drops_its_credentials(): + ci = _load('ci.yml') + for job_id, job in ci['jobs'].items(): + if job_id == 'tag': + continue + assert _checkout(job)['with']['persist-credentials'] is False, job_id # --- the policy module is the only thing that produces a version ------------- -@pytest.mark.parametrize('name,channel', [ - ('nightly-tag.yml', 'nightly'), - ('auto-tag.yml', 'stable'), -]) -def test_exactly_one_call_to_the_policy_module(name, channel): - with open(os.path.join(WORKFLOWS, name)) as handle: +def test_exactly_one_call_to_the_policy_module(): + with open(os.path.join(WORKFLOWS, 'ci.yml')) as handle: body = handle.read() - calls = re.findall(r'tools/next_version\.py --channel (\w+)', body) - assert calls == [channel], ( - 'the tag must come from exactly one next_version.py call' - ) + calls = re.findall(r'tools/next_version\.py --channel', body) + assert len(calls) == 1, 'the tag must come from exactly one call' -@pytest.mark.parametrize('name', ['nightly-tag.yml', 'auto-tag.yml']) -def test_the_pushed_tag_is_the_one_the_policy_computed(name): - workflow = _load(name) - compute = [s for s in _steps(workflow) if 'next_version.py' in s.get('run', '')] +def test_both_channels_are_reachable(): + """main cuts the final release, nightly cuts a candidate for the same + target. A job that only ever computed one channel would silently tag + nightly builds as releases, or never cut a release at all.""" + script = _step_script(_job('ci.yml', 'tag'), 'Compute tag') + assert 'channel=stable' in script + assert 'channel=nightly' in script + + +def test_the_pushed_tag_is_the_one_the_policy_computed(): + job = _job('ci.yml', 'tag') + compute = [s for s in job['steps'] if 'next_version.py' in s.get('run', '')] assert len(compute) == 1 step_id = compute[0]['id'] - create = _step_script(workflow, 'Create tag') - assert 'NEW_TAG' in create - env = [s for s in _steps(workflow) if s.get('name') == 'Create tag'][0]['env'] - assert env['NEW_TAG'] == '${{ steps.%s.outputs.new_tag }}' % step_id + create = [s for s in job['steps'] if s.get('name') == 'Create tag'][0] + assert create['env']['NEW_TAG'] == '${{ steps.%s.outputs.new_tag }}' % step_id -@pytest.mark.parametrize('name', ['nightly-tag.yml', 'auto-tag.yml']) -def test_no_shell_version_arithmetic(name): +def test_no_shell_version_arithmetic(): """Second line of defence. Version math in YAML cannot be unit-tested, which is the entire reason tools/next_version.py exists.""" - with open(os.path.join(WORKFLOWS, name)) as handle: + with open(os.path.join(WORKFLOWS, 'ci.yml')) as handle: body = handle.read() - for banned in ('cut -d.', 'cut -d ".', '$((', 'awk -F.', 'sed -E s/v'): + for banned in ('cut -d.', '$((', 'awk -F.'): assert banned not in body, f'version arithmetic in YAML: {banned}' +def test_only_stable_publishes_a_release(): + """Nightly candidates exist to make builds addressable, not to be releases. + Publishing them would make anything ranking releases see a candidate as + latest.""" + release_step = [ + s for s in _job('ci.yml', 'tag')['steps'] + if s.get('name') == 'Create GitHub release' + ][0] + assert "== 'stable'" in release_step['if'] + + # --- the behaviour of the shell that remains --------------------------------- @@ -1316,31 +1409,28 @@ def _run_create_tag(repo, script, new_tag): ) -@pytest.mark.parametrize('name', ['nightly-tag.yml', 'auto-tag.yml']) -def test_create_tag_pushes_the_tag(repo_with_remote, name): +def test_create_tag_pushes_the_tag(repo_with_remote): repo, remote = repo_with_remote - script = _step_script(_load(name), 'Create tag') - result = _run_create_tag(repo, script, 'v0.0.1') + script = _step_script(_job('ci.yml', 'tag'), 'Create tag') + result = _run_create_tag(repo, script, 'v0.1.0') assert result.returncode == 0, result.stderr - assert 'v0.0.1' in _git(remote, 'tag') + assert 'v0.1.0' in _git(remote, 'tag') -@pytest.mark.parametrize('name', ['nightly-tag.yml', 'auto-tag.yml']) -def test_create_tag_is_idempotent(repo_with_remote, name): +def test_create_tag_is_idempotent(repo_with_remote): """A re-run of the workflow must not fail the job.""" repo, _ = repo_with_remote - script = _step_script(_load(name), 'Create tag') - assert _run_create_tag(repo, script, 'v0.0.1').returncode == 0 - second = _run_create_tag(repo, script, 'v0.0.1') + script = _step_script(_job('ci.yml', 'tag'), 'Create tag') + assert _run_create_tag(repo, script, 'v0.1.0').returncode == 0 + second = _run_create_tag(repo, script, 'v0.1.0') assert second.returncode == 0, second.stderr -@pytest.mark.parametrize('name', ['nightly-tag.yml', 'auto-tag.yml']) -def test_an_empty_batch_tags_nothing_and_is_not_an_error(repo_with_remote, name): +def test_an_empty_batch_tags_nothing_and_is_not_an_error(repo_with_remote): """No commits since the last release is a re-run, not a failure. Tagging "" would fail with a message about nothing in particular.""" repo, remote = repo_with_remote - script = _step_script(_load(name), 'Create tag') + script = _step_script(_job('ci.yml', 'tag'), 'Create tag') result = _run_create_tag(repo, script, '') assert result.returncode == 0, result.stderr assert _git(remote, 'tag').strip() == '' @@ -1358,37 +1448,64 @@ def test_the_policy_module_agrees_with_this_repository(): assert result.returncode == 0, result.stderr output = result.stdout.strip() assert output == '' or re.match(r'^v\d+\.\d+\.\d+rc\d+$', output), output + + +# --- the hand-pushed release path ------------------------------------------- + + +def test_release_workflow_still_exists_for_hand_pushed_tags(): + """The policy never bumps a major automatically, so a major release is + `git tag v1.0.0 && git push`. This is what turns that into a release.""" + assert _triggers(_load('release.yml'))['push']['tags'] == ['v*'] + + +def test_the_release_workflow_uses_no_third_party_action(): + """The runner already ships gh, and the tag job publishes the same way. + zizmor flags the third-party action as superfluous, and two release paths + doing the same thing differently is one too many.""" + (job,) = _load('release.yml')['jobs'].values() + for step in job['steps']: + uses = str(step.get('uses', '')) + assert 'action-gh-release' not in uses ``` - [ ] **Step 3: Run them** Run: `uv run pytest tests/test_release_versioning.py -v` -Expected: PASS. If `_triggers()` returns `None`, the workflow parsed `on` as the boolean `True` — that is what the helper handles; check you copied it intact. +Expected: PASS. If `_triggers()` returns `None`, the workflow parsed `on` as the +boolean `True` — that is what the helper handles; check you copied it intact. - [ ] **Step 4: Prove the guards actually guard** -A test that cannot fail is not a guard. Mutate and confirm each one bites, on a scratch copy so the real files are never left broken: +A test that cannot fail is not a guard. Mutate and confirm each bites, on a +scratch copy so the real file is never left broken: ```bash cd /tmp/spoonmap-auto-versioning cp .github/workflows/ci.yml /tmp/ci.yml.good python3 - <<'EOF' -import re p = '.github/workflows/ci.yml' s = open(p).read().replace('branches: [main, nightly]', 'branches: [main]') open(p, 'w').write(s) EOF -uv run pytest tests/test_release_versioning.py::test_ci_runs_on_pushes_to_nightly -q +uv run pytest tests/test_release_versioning.py::test_ci_runs_on_pushes_to_both_release_branches -q # Expected: FAIL cp /tmp/ci.yml.good .github/workflows/ci.yml ``` -Repeat for one behavioural guard: temporarily replace the `Create tag` step's `if`/`else` in `nightly-tag.yml` with an unconditional `git tag "$NEW_TAG" && git push origin "refs/tags/$NEW_TAG"`, confirm `test_create_tag_is_idempotent` and `test_an_empty_batch_tags_nothing_and_is_not_an_error` both FAIL, then restore. Report both mutation results in your summary — "the tests pass" is not evidence here. +Repeat, restoring from `/tmp/ci.yml.good` each time, for three more: + +- Remove `fetch-depth: 0` from the `build` job's checkout → `test_version_deriving_jobs_fetch_all_history[build]` must FAIL. (This is the guard that replaces the dormant artifact-version assertion, which cannot fire while the repo has no tags.) +- Drop one job from the `tag` job's `needs` list → `test_the_tag_job_waits_for_every_validating_job` must FAIL. +- Replace the `Create tag` step's `if`/`else` with an unconditional `git tag "$NEW_TAG" && git push origin "refs/tags/$NEW_TAG"` → both `test_create_tag_is_idempotent` and `test_an_empty_batch_tags_nothing_and_is_not_an_error` must FAIL. + +Report all four mutation results with real output. "The tests pass" is not evidence here. - [ ] **Step 5: Confirm nothing is left mutated** Run: `git diff --stat && uv run pytest tests/ -q` -Expected: the only diffs are the intended new/modified files, and the full suite passes at or above 95% coverage. +Expected: the only diffs are the intended new/modified files, and the full suite +passes at or above 95% coverage. - [ ] **Step 6: Commit** @@ -1397,9 +1514,10 @@ git add tests/test_release_versioning.py pyproject.toml uv.lock .github/workflow git commit -m "test: guard the release-versioning wiring The policy is unit-tested; the wiring around it is what fails silently. A -missing nightly push trigger, a step that stops calling next_version.py, or -a tag pushed without being the one computed all produce no error -- tags -just quietly stop appearing. +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 @@ -1408,9 +1526,13 @@ push while every test still passed." ``` --- - ### Task 7: Documentation +> **Design change, 2026-08-26 (supersedes this task's original form).** Tagging +> lives in a `tag` job inside `.github/workflows/ci.yml`, gated on `needs`, not +> in separate `workflow_run`-triggered workflows. `auto-tag.yml` and +> `nightly-tag.yml` do not exist. Do not document them. + **Files:** - Modify: `README.md` (Usage section, after the `--cleanup` block ending ~line 190) - Modify: `CLAUDE.md` (new section after "Operator Path Resolution") @@ -1449,7 +1571,10 @@ omitting it entirely means `false`. Only stable releases are reported — nightly release candidates are never advertised as updates. ```` -Also add `check_for_updates` to the `## config.json Parameters` section (~line 244), matching the surrounding format: default `false`, "Contact api.github.com at startup to check for a newer release. Off unless set; see `--check-update` for a one-off check." +Also add `check_for_updates` to the `## config.json Parameters` section (~line +244), matching the surrounding format: default `false`, "Contact api.github.com +at startup to check for a newer release. Off unless set; see `--check-update` +for a one-off check." - [ ] **Step 2: Document the release process in `CLAUDE.md`** @@ -1471,28 +1596,37 @@ major is an irreversible published mistake waiting for one mistyped subject line. Push a major by hand and `release.yml` will publish it. `nightly` cuts candidates for the version the batch is heading toward -(`v0.0.1rc1`, `v0.0.1rc2`, …) and `main` promotes that same target to its final +(`v0.1.0rc1`, `v0.1.0rc2`, …) and `main` promotes that same target to its final release. Aiming candidates one version *forward* is what makes them sort -correctly: `0.0.0 < 0.0.1rc1 < 0.0.1 < 0.1.0rc1 < 0.1.0`. This makes conventional +correctly: `0.0.0 < 0.1.0rc1 < 0.1.0 < 0.2.0rc1 < 0.2.0`. This makes conventional commit subjects load-bearing — a `feat:` typo'd as `fix:` ships as a patch. -Four things here fail silently rather than loudly, all guarded by +The tagging lives in a `tag` job **inside `ci.yml`**, gated on +`needs: [test, test-legacy, lint, bandit, nse-root, workflow-lint, build]` and +on `github.event_name == 'push'` for `main`/`nightly` only. It is deliberately +not a separate `workflow_run`-triggered workflow, which is how this was first +built: zizmor — a required job in this same file — rates `workflow_run` an +error-level dangerous trigger and exits 14, because it is the standard +privilege-escalation vector, and this repo does not silence findings with ignore +comments. Being a `needs` dependent buys the same "only tag what passed CI" +guarantee without the trigger, and without checking out an explicitly-passed +head SHA. Do not reintroduce `workflow_run` here. + +Things that fail silently rather than loudly, all guarded by `tests/test_release_versioning.py`: -- **`ci.yml` must run on pushes to `nightly`.** `nightly-tag.yml` triggers on a - completed CI run for that branch; with no CI run there is nothing to key on and - no candidate is ever tagged, with no error anywhere. -- **`nightly-tag.yml` must live on `main`.** GitHub only dispatches - `workflow_run` for workflows present on the default branch. A copy existing - only on `nightly` never fires. -- **Both tagging jobs need `fetch-depth: 0`.** The baseline is the highest final - tag; a shallow clone sees none and computes from 0.0.0, handing out a version - that already shipped. The `build` job needs it for the same reason — verified: - a depth-1 clone does not fail there, it silently versions artifacts from no tag - at all. -- **Both tagging jobs set `persist-credentials: true`**, against this repo's - convention everywhere else, because they push a tag. That exception is - commented at each site; do not "fix" it. +- **`ci.yml` must run on pushes to `nightly`.** Otherwise the tag job never runs + there and no candidate is ever cut, with no error anywhere. +- **The `tag` job must keep every validating job in `needs`.** Drop one and a + tag can land on a commit that failed it. +- **`fetch-depth: 0` on both the `tag` and `build` jobs.** The baseline is the + highest final tag; a shallow clone sees none and computes from 0.0.0, handing + out a version that already shipped. Verified: a depth-1 clone does not fail — + it silently versions from no tag at all. +- **The `tag` job sets `persist-credentials: true`**, against this repo's + convention everywhere else, because it pushes a tag. It is also the only job + with `contents: write`. That exception is commented at the site and pinned by + a test; do not "fix" it. Version arithmetic belongs in `tools/next_version.py`, where it is unit-tested, never in a workflow step. hate_crack carried ~70 lines of `cut -d.` duplicated @@ -1518,7 +1652,12 @@ checkout) reports the latest release but never claims an update is available. - [ ] **Step 3: Verify the docs match reality** -Re-read both edits against the code as it now stands. Every flag named must exist, every default stated must be the actual default, every line number or path referenced must resolve. Check specifically that `--version`, `--check-update`, and `check_for_updates` are spelled exactly as implemented in Tasks 4 and 5. +Re-read both edits against the code as it now stands. Every flag named must +exist, every default stated must be the actual default, every path referenced +must resolve, and no workflow file is named that does not exist. Check +specifically that `--version`, `--check-update`, and `check_for_updates` are +spelled exactly as implemented in Tasks 4 and 5, and that the `needs` list +quoted above matches `ci.yml` exactly. - [ ] **Step 4: Final full verification** @@ -1528,10 +1667,14 @@ uv run pytest tests/ -q uv run --frozen ruff check spoonmap.py tests/ tools/ uv run --frozen bandit -r spoonmap.py -c pyproject.toml -b .bandit-baseline.json uv lock --check -uvx actionlint .github/workflows/*.yml +uvx --from "actionlint-py==1.7.12.24" actionlint > /tmp/al.out 2>&1; echo "actionlint exit=$?" +uvx zizmor==1.29.0 --persona=regular .github/workflows/ > /tmp/zz.out 2>&1; echo "zizmor exit=$?" git status --short ``` -Expected: suite green at or above 95% coverage, lint and SAST clean, lock current, workflows valid, no unintended files. +Expected: suite green at or above 95% coverage, lint and SAST clean, lock +current, both workflow linters exiting 0, no unintended files. Capture each exit +code on its own line as shown — a pipeline would report the exit status of the +last command in the pipe, not the linter's. - [ ] **Step 5: Commit** @@ -1539,14 +1682,17 @@ Expected: suite green at or above 95% coverage, lint and SAST clean, lock curren git add README.md CLAUDE.md git commit -m "docs: document release versioning and opt-in update checking -Records the four things in this setup that fail silently rather than -loudly -- the nightly CI trigger, nightly-tag.yml having to live on main, -fetch-depth on three jobs, and the persist-credentials exception -- since -each one produces no error, just tags that quietly stop appearing." +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." ``` --- - ## Post-Implementation Notes Two consequences to expect on the first real run, both intended and both already From 2e768cb48ba996f35653a8e27b1cdc6e39acd483 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Wed, 26 Aug 2026 14:18:06 -0400 Subject: [PATCH 10/22] feat: add --version 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 --- spoonmap.py | 31 +++++++++++++++++++++++++++++++ tests/test_spoonmap.py | 19 +++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/spoonmap.py b/spoonmap.py index c755078..3132b02 100755 --- a/spoonmap.py +++ b/spoonmap.py @@ -25,6 +25,7 @@ import queue from queue import Queue import xml.etree.ElementTree as etree +from importlib import metadata _COLOR_INFO = '\x1b[38;5;51m' # electric cyan — "currently doing X" _COLOR_PROGRESS = '\x1b[38;5;118m' # neon lime green — completion status / results @@ -5820,6 +5821,30 @@ def _operator_dir(): return os.getcwd() +# What _tool_version() reports when there is no distribution metadata to read. +# Deliberately not a number: it flows into the update check, where anything +# parseable as a version would be compared against the latest release and +# produce a confident wrong answer. +_UNKNOWN_VERSION = 'unknown (running from source)' + + +def _tool_version(): + """The installed SpooNMAP version, or _UNKNOWN_VERSION. + + Read from distribution metadata rather than a string in this file, because + the version is derived from git tags at build time (see pyproject.toml's + [tool.hatch.version]) and a literal here would be a second, drifting copy. + + The documented invocation `./spoonmap.py` from a clone installs nothing, so + PackageNotFoundError is the *normal* case for a developer or an operator + running from a checkout -- not an error worth a warning. + """ + try: + return metadata.version('spoonmap') + except metadata.PackageNotFoundError: + return _UNKNOWN_VERSION + + # The Main Guts def main(): # pragma: no cover -- interactive CLI entry point; orchestrates # already-independently-tested functions behind input()-driven prompts, @@ -5827,6 +5852,12 @@ def main(): # pragma: no cover -- interactive CLI entry point; orchestrates # giant test versus exercising each called function directly. global output_path + # Handled before the banner and before any terminal state is touched: + # `spoonmap --version` should emit one parseable line and nothing else. + if '--version' in sys.argv: + print(_tool_version()) + sys.exit(0) + # Save initial terminal state initial_term_state = save_terminal_state() diff --git a/tests/test_spoonmap.py b/tests/test_spoonmap.py index 79f7444..b267905 100644 --- a/tests/test_spoonmap.py +++ b/tests/test_spoonmap.py @@ -156,6 +156,25 @@ def test_python_3_6_plus_is_ok(self): verify_python_version() # must not raise +class TestToolVersion: + """_tool_version() reports the installed version, or says it cannot.""" + + def test_reports_the_installed_distribution_version(self): + with patch('spoonmap.metadata.version', return_value='1.2.3'): + assert spoonmap._tool_version() == '1.2.3' + + def test_running_from_a_checkout_is_not_a_version(self): + """No distribution metadata exists when spoonmap.py is run as a plain + script from a clone, which is the documented invocation. That must read + as 'unknown', never as a version number that could be compared.""" + with patch('spoonmap.metadata.version', + side_effect=spoonmap.metadata.PackageNotFoundError): + assert spoonmap._tool_version() == spoonmap._UNKNOWN_VERSION + + def test_the_unknown_sentinel_is_not_mistakable_for_a_version(self): + assert not spoonmap._UNKNOWN_VERSION[0].isdigit() + + class TestRaiseFdLimit: def test_sets_soft_limit_to_hard_when_below_65535(self): with patch('spoonmap.resource.getrlimit', return_value=(1024, 4096)), \ From 4009cefa7c62373f34562941ccd9a2d137479c34 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Wed, 26 Aug 2026 14:29:10 -0400 Subject: [PATCH 11/22] feat: add opt-in update checking, off by default 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 --- .bandit-baseline.json | 310 ++++++++++++++++++++++------------------- config.json.sample | 2 + spoonmap.py | 104 +++++++++++++- tests/test_spoonmap.py | 135 ++++++++++++++++++ 4 files changed, 405 insertions(+), 146 deletions(-) diff --git a/.bandit-baseline.json b/.bandit-baseline.json index c621826..2db80bb 100644 --- a/.bandit-baseline.json +++ b/.bandit-baseline.json @@ -1,30 +1,30 @@ { "errors": [], - "generated_at": "2026-08-21T17:07:47Z", + "generated_at": "2026-08-26T18:28:21Z", "metrics": { "./spoonmap.py": { - "CONFIDENCE.HIGH": 32, + "CONFIDENCE.HIGH": 33, "CONFIDENCE.LOW": 0, "CONFIDENCE.MEDIUM": 0, "CONFIDENCE.UNDEFINED": 0, "SEVERITY.HIGH": 0, "SEVERITY.LOW": 20, - "SEVERITY.MEDIUM": 12, + "SEVERITY.MEDIUM": 13, "SEVERITY.UNDEFINED": 0, - "loc": 4730, + "loc": 5056, "nosec": 0, "skipped_tests": 0 }, "_totals": { - "CONFIDENCE.HIGH": 32, + "CONFIDENCE.HIGH": 33, "CONFIDENCE.LOW": 0, "CONFIDENCE.MEDIUM": 0, "CONFIDENCE.UNDEFINED": 0, "SEVERITY.HIGH": 0, "SEVERITY.LOW": 20, - "SEVERITY.MEDIUM": 12, + "SEVERITY.MEDIUM": 13, "SEVERITY.UNDEFINED": 0, - "loc": 4730, + "loc": 5056, "nosec": 0, "skipped_tests": 0 } @@ -51,7 +51,7 @@ "test_name": "blacklist" }, { - "code": "26 from queue import Queue\n27 import xml.etree.ElementTree as etree\n28 \n", + "code": "28 import urllib.request\n29 import xml.etree.ElementTree as etree\n30 from importlib import metadata\n", "col_offset": 0, "end_col_offset": 37, "filename": "./spoonmap.py", @@ -62,16 +62,16 @@ }, "issue_severity": "LOW", "issue_text": "Using xml.etree.ElementTree to parse untrusted XML data is known to be vulnerable to XML attacks. Replace xml.etree.ElementTree with the equivalent defusedxml package, or make sure defusedxml.defuse_stdlib() is called.", - "line_number": 27, + "line_number": 29, "line_range": [ - 27 + 29 ], "more_info": "https://bandit.readthedocs.io/en/1.9.4/blacklists/blacklist_imports.html#b405-import-xml-etree", "test_id": "B405", "test_name": "blacklist" }, { - "code": "81 try:\n82 subprocess.run(['stty', 'sane'], check=False, stderr=subprocess.DEVNULL)\n83 except (OSError, subprocess.SubprocessError):\n", + "code": "84 try:\n85 subprocess.run(['stty', 'sane'], check=False, stderr=subprocess.DEVNULL)\n86 except (OSError, subprocess.SubprocessError):\n", "col_offset": 8, "end_col_offset": 80, "filename": "./spoonmap.py", @@ -82,16 +82,16 @@ }, "issue_severity": "LOW", "issue_text": "Starting a process with a partial executable path", - "line_number": 82, + "line_number": 85, "line_range": [ - 82 + 85 ], "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b607_start_process_with_partial_path.html", "test_id": "B607", "test_name": "start_process_with_partial_path" }, { - "code": "81 try:\n82 subprocess.run(['stty', 'sane'], check=False, stderr=subprocess.DEVNULL)\n83 except (OSError, subprocess.SubprocessError):\n", + "code": "84 try:\n85 subprocess.run(['stty', 'sane'], check=False, stderr=subprocess.DEVNULL)\n86 except (OSError, subprocess.SubprocessError):\n", "col_offset": 8, "end_col_offset": 80, "filename": "./spoonmap.py", @@ -102,16 +102,16 @@ }, "issue_severity": "LOW", "issue_text": "subprocess call - check for execution of untrusted input.", - "line_number": 82, + "line_number": 85, "line_range": [ - 82 + 85 ], "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", "test_id": "B603", "test_name": "subprocess_without_shell_equals_true" }, { - "code": "709 try:\n710 root = etree.parse(xml_file)\n711 for host in root.findall('host'):\n", + "code": "921 try:\n922 root = etree.parse(xml_file)\n923 for host in root.findall('host'):\n", "col_offset": 15, "end_col_offset": 36, "filename": "./spoonmap.py", @@ -122,16 +122,16 @@ }, "issue_severity": "MEDIUM", "issue_text": "Using xml.etree.ElementTree.parse to parse untrusted XML data is known to be vulnerable to XML attacks. Replace xml.etree.ElementTree.parse with its defusedxml equivalent function or make sure defusedxml.defuse_stdlib() is called", - "line_number": 710, + "line_number": 922, "line_range": [ - 710 + 922 ], "more_info": "https://bandit.readthedocs.io/en/1.9.4/blacklists/blacklist_calls.html#b313-b320-xml-bad-elementtree", "test_id": "B314", "test_name": "blacklist" }, { - "code": "731 try:\n732 root = etree.parse(xml_file)\n733 for host in root.findall('host'):\n", + "code": "943 try:\n944 root = etree.parse(xml_file)\n945 for host in root.findall('host'):\n", "col_offset": 15, "end_col_offset": 36, "filename": "./spoonmap.py", @@ -142,16 +142,16 @@ }, "issue_severity": "MEDIUM", "issue_text": "Using xml.etree.ElementTree.parse to parse untrusted XML data is known to be vulnerable to XML attacks. Replace xml.etree.ElementTree.parse with its defusedxml equivalent function or make sure defusedxml.defuse_stdlib() is called", - "line_number": 732, + "line_number": 944, "line_range": [ - 732 + 944 ], "more_info": "https://bandit.readthedocs.io/en/1.9.4/blacklists/blacklist_calls.html#b313-b320-xml-bad-elementtree", "test_id": "B314", "test_name": "blacklist" }, { - "code": "813 try:\n814 proc = subprocess.Popen(masscan_cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,\n815 preexec_fn=_raise_fd_limit)\n816 progress_thread = threading.Thread(target=_stream_masscan_progress, args=(proc,), daemon=True)\n", + "code": "1025 try:\n1026 proc = subprocess.Popen(masscan_cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,\n1027 preexec_fn=_raise_fd_limit)\n1028 progress_thread = threading.Thread(target=_stream_masscan_progress, args=(proc,), daemon=True)\n", "col_offset": 15, "end_col_offset": 59, "filename": "./spoonmap.py", @@ -162,17 +162,17 @@ }, "issue_severity": "LOW", "issue_text": "subprocess call - check for execution of untrusted input.", - "line_number": 814, + "line_number": 1026, "line_range": [ - 814, - 815 + 1026, + 1027 ], "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", "test_id": "B603", "test_name": "subprocess_without_shell_equals_true" }, { - "code": "871 try:\n872 proc = subprocess.Popen(masscan_cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,\n873 preexec_fn=_raise_fd_limit)\n874 progress_thread = threading.Thread(target=_stream_masscan_progress, args=(proc,), daemon=True)\n", + "code": "1083 try:\n1084 proc = subprocess.Popen(masscan_cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,\n1085 preexec_fn=_raise_fd_limit)\n1086 progress_thread = threading.Thread(target=_stream_masscan_progress, args=(proc,), daemon=True)\n", "col_offset": 15, "end_col_offset": 59, "filename": "./spoonmap.py", @@ -183,17 +183,17 @@ }, "issue_severity": "LOW", "issue_text": "subprocess call - check for execution of untrusted input.", - "line_number": 872, + "line_number": 1084, "line_range": [ - 872, - 873 + 1084, + 1085 ], "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", "test_id": "B603", "test_name": "subprocess_without_shell_equals_true" }, { - "code": "1032 try:\n1033 proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)\n1034 proc.wait()\n", + "code": "1263 try:\n1264 proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)\n1265 proc.wait()\n", "col_offset": 15, "end_col_offset": 90, "filename": "./spoonmap.py", @@ -204,16 +204,16 @@ }, "issue_severity": "LOW", "issue_text": "subprocess call - check for execution of untrusted input.", - "line_number": 1033, + "line_number": 1264, "line_range": [ - 1033 + 1264 ], "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", "test_id": "B603", "test_name": "subprocess_without_shell_equals_true" }, { - "code": "1051 try:\n1052 root = etree.parse(output_xml)\n1053 for host in root.findall('host'):\n", + "code": "1282 try:\n1283 root = etree.parse(output_xml)\n1284 for host in root.findall('host'):\n", "col_offset": 15, "end_col_offset": 38, "filename": "./spoonmap.py", @@ -224,16 +224,16 @@ }, "issue_severity": "MEDIUM", "issue_text": "Using xml.etree.ElementTree.parse to parse untrusted XML data is known to be vulnerable to XML attacks. Replace xml.etree.ElementTree.parse with its defusedxml equivalent function or make sure defusedxml.defuse_stdlib() is called", - "line_number": 1052, + "line_number": 1283, "line_range": [ - 1052 + 1283 ], "more_info": "https://bandit.readthedocs.io/en/1.9.4/blacklists/blacklist_calls.html#b313-b320-xml-bad-elementtree", "test_id": "B314", "test_name": "blacklist" }, { - "code": "1102 try:\n1103 masscan_process = subprocess.Popen(\n1104 masscan_cmd,\n1105 stdout=subprocess.DEVNULL,\n1106 stderr=subprocess.PIPE,\n1107 preexec_fn=_raise_fd_limit,\n1108 )\n1109 progress_thread = threading.Thread(target=run_progress_and_capture, args=(masscan_process,), daemon=True)\n", + "code": "1338 try:\n1339 masscan_process = subprocess.Popen(\n1340 masscan_cmd,\n1341 stdout=subprocess.DEVNULL,\n1342 stderr=subprocess.PIPE,\n1343 preexec_fn=_raise_fd_limit,\n1344 )\n1345 progress_thread = threading.Thread(target=run_progress_and_capture, args=(masscan_process,), daemon=True)\n", "col_offset": 26, "end_col_offset": 9, "filename": "./spoonmap.py", @@ -244,21 +244,21 @@ }, "issue_severity": "LOW", "issue_text": "subprocess call - check for execution of untrusted input.", - "line_number": 1103, + "line_number": 1339, "line_range": [ - 1103, - 1104, - 1105, - 1106, - 1107, - 1108 + 1339, + 1340, + 1341, + 1342, + 1343, + 1344 ], "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", "test_id": "B603", "test_name": "subprocess_without_shell_equals_true" }, { - "code": "1156 try:\n1157 root = etree.parse(output_file)\n1158 for host in root.findall('host'):\n", + "code": "1398 try:\n1399 root = etree.parse(output_file)\n1400 for host in root.findall('host'):\n", "col_offset": 15, "end_col_offset": 39, "filename": "./spoonmap.py", @@ -269,16 +269,16 @@ }, "issue_severity": "MEDIUM", "issue_text": "Using xml.etree.ElementTree.parse to parse untrusted XML data is known to be vulnerable to XML attacks. Replace xml.etree.ElementTree.parse with its defusedxml equivalent function or make sure defusedxml.defuse_stdlib() is called", - "line_number": 1157, + "line_number": 1399, "line_range": [ - 1157 + 1399 ], "more_info": "https://bandit.readthedocs.io/en/1.9.4/blacklists/blacklist_calls.html#b313-b320-xml-bad-elementtree", "test_id": "B314", "test_name": "blacklist" }, { - "code": "1316 try:\n1317 proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)\n1318 proc.wait()\n", + "code": "1564 try:\n1565 proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)\n1566 proc.wait()\n", "col_offset": 15, "end_col_offset": 90, "filename": "./spoonmap.py", @@ -289,16 +289,16 @@ }, "issue_severity": "LOW", "issue_text": "subprocess call - check for execution of untrusted input.", - "line_number": 1317, + "line_number": 1565, "line_range": [ - 1317 + 1565 ], "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", "test_id": "B603", "test_name": "subprocess_without_shell_equals_true" }, { - "code": "1335 try:\n1336 root = etree.parse(output_file)\n1337 for host in root.findall('host'):\n", + "code": "1594 try:\n1595 root = etree.parse(output_file)\n1596 for host in root.findall('host'):\n", "col_offset": 15, "end_col_offset": 39, "filename": "./spoonmap.py", @@ -309,16 +309,16 @@ }, "issue_severity": "MEDIUM", "issue_text": "Using xml.etree.ElementTree.parse to parse untrusted XML data is known to be vulnerable to XML attacks. Replace xml.etree.ElementTree.parse with its defusedxml equivalent function or make sure defusedxml.defuse_stdlib() is called", - "line_number": 1336, + "line_number": 1595, "line_range": [ - 1336 + 1595 ], "more_info": "https://bandit.readthedocs.io/en/1.9.4/blacklists/blacklist_calls.html#b313-b320-xml-bad-elementtree", "test_id": "B314", "test_name": "blacklist" }, { - "code": "1488 try:\n1489 proc = subprocess.Popen(cmd, stdout=subprocess.PIPE,\n1490 stderr=subprocess.PIPE, text=True)\n1491 _t = threading.Thread(target=_progress_reader, args=(proc.stdout,), daemon=True)\n", + "code": "1753 try:\n1754 proc = subprocess.Popen(cmd, stdout=subprocess.PIPE,\n1755 stderr=subprocess.PIPE, text=True)\n1756 _t = threading.Thread(target=_progress_reader, args=(proc.stdout,), daemon=True)\n", "col_offset": 15, "end_col_offset": 66, "filename": "./spoonmap.py", @@ -329,17 +329,17 @@ }, "issue_severity": "LOW", "issue_text": "subprocess call - check for execution of untrusted input.", - "line_number": 1489, + "line_number": 1754, "line_range": [ - 1489, - 1490 + 1754, + 1755 ], "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", "test_id": "B603", "test_name": "subprocess_without_shell_equals_true" }, { - "code": "1523 try:\n1524 root = etree.parse(output_file)\n1525 for host in root.findall('host'):\n", + "code": "1793 try:\n1794 root = etree.parse(output_file)\n1795 for host in root.findall('host'):\n", "col_offset": 15, "end_col_offset": 39, "filename": "./spoonmap.py", @@ -350,16 +350,16 @@ }, "issue_severity": "MEDIUM", "issue_text": "Using xml.etree.ElementTree.parse to parse untrusted XML data is known to be vulnerable to XML attacks. Replace xml.etree.ElementTree.parse with its defusedxml equivalent function or make sure defusedxml.defuse_stdlib() is called", - "line_number": 1524, + "line_number": 1794, "line_range": [ - 1524 + 1794 ], "more_info": "https://bandit.readthedocs.io/en/1.9.4/blacklists/blacklist_calls.html#b313-b320-xml-bad-elementtree", "test_id": "B314", "test_name": "blacklist" }, { - "code": "2226 # non-None and work_queue.join() in nmap_scan() hangs forever.\n2227 nmap_process = subprocess.Popen(\n2228 nmap_cmd,\n2229 stdout=subprocess.DEVNULL,\n2230 stderr=subprocess.PIPE,\n2231 text=True,\n2232 start_new_session=True,\n2233 )\n2234 nmap_err_thread, nmap_err_lines = _start_stderr_reader(nmap_process.stderr)\n", + "code": "2547 # non-None and work_queue.join() in nmap_scan() hangs forever.\n2548 nmap_process = subprocess.Popen(\n2549 nmap_cmd,\n2550 stdout=subprocess.DEVNULL,\n2551 stderr=subprocess.PIPE,\n2552 text=True,\n2553 start_new_session=True,\n2554 )\n2555 nmap_err_thread, nmap_err_lines = _start_stderr_reader(nmap_process.stderr)\n", "col_offset": 31, "end_col_offset": 17, "filename": "./spoonmap.py", @@ -370,22 +370,22 @@ }, "issue_severity": "LOW", "issue_text": "subprocess call - check for execution of untrusted input.", - "line_number": 2227, + "line_number": 2548, "line_range": [ - 2227, - 2228, - 2229, - 2230, - 2231, - 2232, - 2233 + 2548, + 2549, + 2550, + 2551, + 2552, + 2553, + 2554 ], "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", "test_id": "B603", "test_name": "subprocess_without_shell_equals_true" }, { - "code": "2286 )\n2287 nse_process = subprocess.Popen(\n2288 nse_cmd,\n2289 stdout=subprocess.DEVNULL,\n2290 stderr=subprocess.PIPE,\n2291 text=True,\n2292 start_new_session=True,\n2293 )\n2294 # Same concurrent drain as the banner pass: an NSE run\n", + "code": "2615 _discard_coverage_record(nse_output)\n2616 nse_process = subprocess.Popen(\n2617 nse_cmd,\n2618 stdout=subprocess.DEVNULL,\n2619 stderr=subprocess.PIPE,\n2620 text=True,\n2621 start_new_session=True,\n2622 )\n2623 # Same concurrent drain as the banner pass: an NSE run\n", "col_offset": 38, "end_col_offset": 25, "filename": "./spoonmap.py", @@ -396,22 +396,22 @@ }, "issue_severity": "LOW", "issue_text": "subprocess call - check for execution of untrusted input.", - "line_number": 2287, + "line_number": 2616, "line_range": [ - 2287, - 2288, - 2289, - 2290, - 2291, - 2292, - 2293 + 2616, + 2617, + 2618, + 2619, + 2620, + 2621, + 2622 ], "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", "test_id": "B603", "test_name": "subprocess_without_shell_equals_true" }, { - "code": "2785 try:\n2786 root = etree.parse(fpath)\n2787 for host in root.findall('host'):\n", + "code": "3147 try:\n3148 root = etree.parse(fpath)\n3149 for host in root.findall('host'):\n", "col_offset": 19, "end_col_offset": 37, "filename": "./spoonmap.py", @@ -422,16 +422,16 @@ }, "issue_severity": "MEDIUM", "issue_text": "Using xml.etree.ElementTree.parse to parse untrusted XML data is known to be vulnerable to XML attacks. Replace xml.etree.ElementTree.parse with its defusedxml equivalent function or make sure defusedxml.defuse_stdlib() is called", - "line_number": 2786, + "line_number": 3148, "line_range": [ - 2786 + 3148 ], "more_info": "https://bandit.readthedocs.io/en/1.9.4/blacklists/blacklist_calls.html#b313-b320-xml-bad-elementtree", "test_id": "B314", "test_name": "blacklist" }, { - "code": "2823 try:\n2824 proc = subprocess.Popen([\n2825 'nmap', '-T4', '-sS', '-sV', '--version-intensity', '0',\n2826 '-Pn', '-p', port,\n2827 *(['--source-port', source_port] if source_port else []),\n2828 ip, '-oX', out_file\n2829 ])\n2830 proc.wait()\n", + "code": "3185 try:\n3186 proc = subprocess.Popen([\n3187 'nmap', '-T4', '-sS', '-sV', '--version-intensity', '0',\n3188 '-Pn', '-p', port,\n3189 *(['--source-port', source_port] if source_port else []),\n3190 ip, '-oX', out_file\n3191 ])\n3192 proc.wait()\n", "col_offset": 27, "end_col_offset": 22, "filename": "./spoonmap.py", @@ -442,21 +442,21 @@ }, "issue_severity": "LOW", "issue_text": "Starting a process with a partial executable path", - "line_number": 2824, + "line_number": 3186, "line_range": [ - 2824, - 2825, - 2826, - 2827, - 2828, - 2829 + 3186, + 3187, + 3188, + 3189, + 3190, + 3191 ], "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b607_start_process_with_partial_path.html", "test_id": "B607", "test_name": "start_process_with_partial_path" }, { - "code": "2823 try:\n2824 proc = subprocess.Popen([\n2825 'nmap', '-T4', '-sS', '-sV', '--version-intensity', '0',\n2826 '-Pn', '-p', port,\n2827 *(['--source-port', source_port] if source_port else []),\n2828 ip, '-oX', out_file\n2829 ])\n2830 proc.wait()\n", + "code": "3185 try:\n3186 proc = subprocess.Popen([\n3187 'nmap', '-T4', '-sS', '-sV', '--version-intensity', '0',\n3188 '-Pn', '-p', port,\n3189 *(['--source-port', source_port] if source_port else []),\n3190 ip, '-oX', out_file\n3191 ])\n3192 proc.wait()\n", "col_offset": 27, "end_col_offset": 22, "filename": "./spoonmap.py", @@ -467,21 +467,21 @@ }, "issue_severity": "LOW", "issue_text": "subprocess call - check for execution of untrusted input.", - "line_number": 2824, + "line_number": 3186, "line_range": [ - 2824, - 2825, - 2826, - 2827, - 2828, - 2829 + 3186, + 3187, + 3188, + 3189, + 3190, + 3191 ], "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", "test_id": "B603", "test_name": "subprocess_without_shell_equals_true" }, { - "code": "2846 try:\n2847 proc = subprocess.Popen([\n2848 'nmap', '-T4', '-sS', '-Pn', '-p', port,\n2849 '--script', f'{_DIR}/nse/azure-sql-detect.nse',\n2850 '--script-timeout', '30s',\n2851 *(['--source-port', source_port] if source_port else []),\n2852 ip, '-oX', nse_out_file\n2853 ])\n2854 proc.wait()\n", + "code": "3208 try:\n3209 proc = subprocess.Popen([\n3210 'nmap', '-T4', '-sS', '-Pn', '-p', port,\n3211 '--script', f'{_NSE_DIR}/azure-sql-detect.nse',\n3212 '--script-timeout', '30s',\n3213 *(['--source-port', source_port] if source_port else []),\n3214 ip, '-oX', nse_out_file\n3215 ])\n3216 proc.wait()\n", "col_offset": 23, "end_col_offset": 18, "filename": "./spoonmap.py", @@ -492,22 +492,22 @@ }, "issue_severity": "LOW", "issue_text": "Starting a process with a partial executable path", - "line_number": 2847, + "line_number": 3209, "line_range": [ - 2847, - 2848, - 2849, - 2850, - 2851, - 2852, - 2853 + 3209, + 3210, + 3211, + 3212, + 3213, + 3214, + 3215 ], "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b607_start_process_with_partial_path.html", "test_id": "B607", "test_name": "start_process_with_partial_path" }, { - "code": "2846 try:\n2847 proc = subprocess.Popen([\n2848 'nmap', '-T4', '-sS', '-Pn', '-p', port,\n2849 '--script', f'{_DIR}/nse/azure-sql-detect.nse',\n2850 '--script-timeout', '30s',\n2851 *(['--source-port', source_port] if source_port else []),\n2852 ip, '-oX', nse_out_file\n2853 ])\n2854 proc.wait()\n", + "code": "3208 try:\n3209 proc = subprocess.Popen([\n3210 'nmap', '-T4', '-sS', '-Pn', '-p', port,\n3211 '--script', f'{_NSE_DIR}/azure-sql-detect.nse',\n3212 '--script-timeout', '30s',\n3213 *(['--source-port', source_port] if source_port else []),\n3214 ip, '-oX', nse_out_file\n3215 ])\n3216 proc.wait()\n", "col_offset": 23, "end_col_offset": 18, "filename": "./spoonmap.py", @@ -518,22 +518,22 @@ }, "issue_severity": "LOW", "issue_text": "subprocess call - check for execution of untrusted input.", - "line_number": 2847, + "line_number": 3209, "line_range": [ - 2847, - 2848, - 2849, - 2850, - 2851, - 2852, - 2853 + 3209, + 3210, + 3211, + 3212, + 3213, + 3214, + 3215 ], "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", "test_id": "B603", "test_name": "subprocess_without_shell_equals_true" }, { - "code": "2868 try:\n2869 tree = etree.parse(xml_file)\n2870 except Exception:\n", + "code": "3230 try:\n3231 tree = etree.parse(xml_file)\n3232 except Exception:\n", "col_offset": 19, "end_col_offset": 40, "filename": "./spoonmap.py", @@ -544,16 +544,16 @@ }, "issue_severity": "MEDIUM", "issue_text": "Using xml.etree.ElementTree.parse to parse untrusted XML data is known to be vulnerable to XML attacks. Replace xml.etree.ElementTree.parse with its defusedxml equivalent function or make sure defusedxml.defuse_stdlib() is called", - "line_number": 2869, + "line_number": 3231, "line_range": [ - 2869 + 3231 ], "more_info": "https://bandit.readthedocs.io/en/1.9.4/blacklists/blacklist_calls.html#b313-b320-xml-bad-elementtree", "test_id": "B314", "test_name": "blacklist" }, { - "code": "2869 tree = etree.parse(xml_file)\n2870 except Exception:\n2871 continue\n2872 for host_elem in tree.findall('.//host'):\n", + "code": "3231 tree = etree.parse(xml_file)\n3232 except Exception:\n3233 continue\n3234 for host_elem in tree.findall('.//host'):\n", "col_offset": 8, "end_col_offset": 20, "filename": "./spoonmap.py", @@ -564,17 +564,17 @@ }, "issue_severity": "LOW", "issue_text": "Try, Except, Continue detected.", - "line_number": 2870, + "line_number": 3232, "line_range": [ - 2870, - 2871 + 3232, + 3233 ], "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b112_try_except_continue.html", "test_id": "B112", "test_name": "try_except_continue" }, { - "code": "2904 ]\n2905 result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)\n2906 if 'Valid credentials' in result.stdout:\n", + "code": "3266 ]\n3267 result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)\n3268 if 'Valid credentials' in result.stdout:\n", "col_offset": 29, "end_col_offset": 92, "filename": "./spoonmap.py", @@ -585,16 +585,16 @@ }, "issue_severity": "LOW", "issue_text": "subprocess call - check for execution of untrusted input.", - "line_number": 2905, + "line_number": 3267, "line_range": [ - 2905 + 3267 ], "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", "test_id": "B603", "test_name": "subprocess_without_shell_equals_true" }, { - "code": "3076 try:\n3077 root = etree.parse(f'{nmap_dir}/{fname}')\n3078 except etree.ParseError:\n", + "code": "3438 try:\n3439 root = etree.parse(f'{nmap_dir}/{fname}')\n3440 except etree.ParseError:\n", "col_offset": 19, "end_col_offset": 53, "filename": "./spoonmap.py", @@ -605,16 +605,16 @@ }, "issue_severity": "MEDIUM", "issue_text": "Using xml.etree.ElementTree.parse to parse untrusted XML data is known to be vulnerable to XML attacks. Replace xml.etree.ElementTree.parse with its defusedxml equivalent function or make sure defusedxml.defuse_stdlib() is called", - "line_number": 3077, + "line_number": 3439, "line_range": [ - 3077 + 3439 ], "more_info": "https://bandit.readthedocs.io/en/1.9.4/blacklists/blacklist_calls.html#b313-b320-xml-bad-elementtree", "test_id": "B314", "test_name": "blacklist" }, { - "code": "3226 try:\n3227 root = etree.parse(fpath)\n3228 except Exception:\n", + "code": "3588 try:\n3589 root = etree.parse(fpath)\n3590 except Exception:\n", "col_offset": 19, "end_col_offset": 37, "filename": "./spoonmap.py", @@ -625,16 +625,16 @@ }, "issue_severity": "MEDIUM", "issue_text": "Using xml.etree.ElementTree.parse to parse untrusted XML data is known to be vulnerable to XML attacks. Replace xml.etree.ElementTree.parse with its defusedxml equivalent function or make sure defusedxml.defuse_stdlib() is called", - "line_number": 3227, + "line_number": 3589, "line_range": [ - 3227 + 3589 ], "more_info": "https://bandit.readthedocs.io/en/1.9.4/blacklists/blacklist_calls.html#b313-b320-xml-bad-elementtree", "test_id": "B314", "test_name": "blacklist" }, { - "code": "3227 root = etree.parse(fpath)\n3228 except Exception:\n3229 continue\n3230 \n", + "code": "3589 root = etree.parse(fpath)\n3590 except Exception:\n3591 continue\n3592 \n", "col_offset": 8, "end_col_offset": 20, "filename": "./spoonmap.py", @@ -645,17 +645,17 @@ }, "issue_severity": "LOW", "issue_text": "Try, Except, Continue detected.", - "line_number": 3228, + "line_number": 3590, "line_range": [ - 3228, - 3229 + 3590, + 3591 ], "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b112_try_except_continue.html", "test_id": "B112", "test_name": "try_except_continue" }, { - "code": "4571 try:\n4572 return etree.parse(path)\n4573 except etree.ParseError as e:\n", + "code": "4933 try:\n4934 return etree.parse(path)\n4935 except etree.ParseError as e:\n", "col_offset": 15, "end_col_offset": 32, "filename": "./spoonmap.py", @@ -666,16 +666,16 @@ }, "issue_severity": "MEDIUM", "issue_text": "Using xml.etree.ElementTree.parse to parse untrusted XML data is known to be vulnerable to XML attacks. Replace xml.etree.ElementTree.parse with its defusedxml equivalent function or make sure defusedxml.defuse_stdlib() is called", - "line_number": 4572, + "line_number": 4934, "line_range": [ - 4572 + 4934 ], "more_info": "https://bandit.readthedocs.io/en/1.9.4/blacklists/blacklist_calls.html#b313-b320-xml-bad-elementtree", "test_id": "B314", "test_name": "blacklist" }, { - "code": "4876 readline.set_completer(None) # type: ignore[name-defined]\n4877 except Exception:\n4878 pass\n4879 \n", + "code": "5238 readline.set_completer(None) # type: ignore[name-defined]\n5239 except Exception:\n5240 pass\n5241 \n", "col_offset": 8, "end_col_offset": 16, "filename": "./spoonmap.py", @@ -686,17 +686,17 @@ }, "issue_severity": "LOW", "issue_text": "Try, Except, Pass detected.", - "line_number": 4877, + "line_number": 5239, "line_range": [ - 4877, - 4878 + 5239, + 5240 ], "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b110_try_except_pass.html", "test_id": "B110", "test_name": "try_except_pass" }, { - "code": "4914 try:\n4915 tree = etree.parse(nmap_xml)\n4916 root_elem = tree.getroot()\n", + "code": "5276 try:\n5277 tree = etree.parse(nmap_xml)\n5278 root_elem = tree.getroot()\n", "col_offset": 19, "end_col_offset": 40, "filename": "./spoonmap.py", @@ -707,13 +707,33 @@ }, "issue_severity": "MEDIUM", "issue_text": "Using xml.etree.ElementTree.parse to parse untrusted XML data is known to be vulnerable to XML attacks. Replace xml.etree.ElementTree.parse with its defusedxml equivalent function or make sure defusedxml.defuse_stdlib() is called", - "line_number": 4915, + "line_number": 5277, "line_range": [ - 4915 + 5277 ], "more_info": "https://bandit.readthedocs.io/en/1.9.4/blacklists/blacklist_calls.html#b313-b320-xml-bad-elementtree", "test_id": "B314", "test_name": "blacklist" + }, + { + "code": "5898 try:\n5899 with urllib.request.urlopen(_RELEASE_API_URL, timeout=timeout) as resp:\n5900 payload = json.loads(resp.read().decode('utf-8', 'replace'))\n", + "col_offset": 13, + "end_col_offset": 70, + "filename": "./spoonmap.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 22, + "link": "https://cwe.mitre.org/data/definitions/22.html" + }, + "issue_severity": "MEDIUM", + "issue_text": "Audit url open for permitted schemes. Allowing use of file:/ or custom schemes is often unexpected.", + "line_number": 5899, + "line_range": [ + 5899 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/blacklists/blacklist_calls.html#b310-urllib-urlopen", + "test_id": "B310", + "test_name": "blacklist" } ] } \ No newline at end of file diff --git a/config.json.sample b/config.json.sample index 4835f84..cc941a8 100644 --- a/config.json.sample +++ b/config.json.sample @@ -16,6 +16,8 @@ "host_discovery" : true, "__resume_choices__" : "true, false (JSON booleans; legacy quoted \"True\"/\"False\" still accepted)", "resume" : false, + "__check_for_updates_note__": "Optional. When true, SpooNMAP contacts api.github.com at startup to see whether a newer release exists. Default false, and absent means false: the tool makes no network connection other than the scan itself unless you turn this on. Use --check-update for a one-off check without enabling it here.", + "check_for_updates": false, "__target_scan_choices__" : "External, Internal (case-insensitive; any other value stops the run rather than scanning with the Internal-only checks silently skipped)", "target_scan" : "Internal", "__max_rate_external_recommendation__" : "Default = 20000 (full port scan capped at 10000)", diff --git a/spoonmap.py b/spoonmap.py index 3132b02..366be00 100755 --- a/spoonmap.py +++ b/spoonmap.py @@ -24,6 +24,8 @@ import time import queue from queue import Queue +import urllib.error +import urllib.request import xml.etree.ElementTree as etree from importlib import metadata @@ -5393,6 +5395,13 @@ def _filter_udp_live_hosts(output_path): 'true, false (JSON booleans; legacy quoted "True"/"False" ' 'still accepted)'), ], + 'check_for_updates': [ + ('__check_for_updates_note__', + 'Optional. When true, SpooNMAP contacts api.github.com at startup to see ' + 'whether a newer release exists. Default false, and absent means false: the ' + 'tool makes no network connection other than the scan itself unless you turn ' + 'this on. Use --check-update for a one-off check without enabling it here.'), + ], 'target_scan': [ ('__target_scan_choices__', 'External, Internal (case-insensitive; any other value stops the run ' @@ -5419,7 +5428,7 @@ def _filter_udp_live_hosts(output_path): # Canonical key order for a written config.json, matching config.json.sample. _CONFIG_FIELD_ORDER = ( 'scan_categories', 'dest_ports', 'masscan_batch_size', 'banner_scan', - 'script_scan', 'host_discovery', 'resume', 'target_scan', 'max_rate', + 'script_scan', 'host_discovery', 'resume', 'check_for_updates', 'target_scan', 'max_rate', 'nmap_threads', 'nmap_threshold', 'target_file', 'output_path', 'exclusions_file', ) @@ -5776,6 +5785,10 @@ def _load_config(config_parser, dir_path, resume=False): 'host_discovery', config_parser.get('host_discovery'), True) # ORed, never overwritten: --resume can't be turned back off by the file. resume = resume or _config_bool('resume', config_parser.get('resume'), False) + # Absent means off, and absent is the normal case. This is the only way to + # enable a launch-time network call; see _check_for_updates(). + check_for_updates = _config_bool( + 'check_for_updates', config_parser.get('check_for_updates', False), False) config_generated = bool(config_parser.get(_CONFIG_GENERATED_KEY)) # Resolve relative paths in config relative to the operator directory @@ -5803,6 +5816,7 @@ def _load_config(config_parser, dir_path, resume=False): 'script_scan': script_scan, 'host_discovery': host_discovery, 'resume': resume, + 'check_for_updates': check_for_updates, 'config_generated': config_generated, } @@ -5845,6 +5859,87 @@ def _tool_version(): return _UNKNOWN_VERSION +# Latest *release* specifically: GitHub's /releases/latest excludes +# pre-releases, so the vX.Y.ZrcN candidates cut on `nightly` are never +# advertised to an operator as an available update. +_RELEASE_API_URL = ( + 'https://api.github.com/repos/trustedsec/spoonmap/releases/latest' +) +_RELEASES_URL = 'https://github.com/trustedsec/spoonmap/releases' +# Short: this runs before a scan, and a hung TCP connection to a network the +# jumpbox cannot reach must not become a stalled engagement. +_UPDATE_CHECK_TIMEOUT = 5 + +_RELEASE_TAG_RE = re.compile(r'^v?(\d+)\.(\d+)\.(\d+)$') + + +def _parse_release_tag(text): + """(major, minor, patch) for a plain release, else None. + + Deliberately strict. A candidate (0.1.0rc1) or a dev build + (0.0.1.post1.dev1) is not comparable against a release without PEP 440 + semantics, and spoonmap.py is stdlib-only by design -- there is no + `packaging` here to do it properly, so anything that is not an unambiguous + X.Y.Z is declined rather than guessed at. + """ + match = _RELEASE_TAG_RE.match((text or '').strip()) + if not match: + return None + return (int(match.group(1)), int(match.group(2)), int(match.group(3))) + + +def _check_for_updates(timeout=_UPDATE_CHECK_TIMEOUT): + """Report whether a newer release exists. Never raises, never blocks long. + + Every failure mode -- no route, DNS, TLS, rate limiting, an HTML error page + where JSON was expected, a release with no tag_name -- is swallowed. An + update check is a courtesy; a scan must never fail or stall because one did. + """ + try: + with urllib.request.urlopen(_RELEASE_API_URL, timeout=timeout) as resp: + payload = json.loads(resp.read().decode('utf-8', 'replace')) + latest_text = payload.get('tag_name', '') + except Exception: + # Intentionally broad: see the docstring. There is no failure here + # worth interrupting an operator for, and the set of exceptions urllib + # and json can raise between them is not worth enumerating wrongly. + return + + latest = _parse_release_tag(latest_text) + if latest is None: + return + + current_text = _tool_version() + current = _parse_release_tag(current_text) + if current is None: + # Running from a checkout, or on a dev build. There is nothing to + # compare, so report the fact and make no claim about it -- telling + # every operator running from a clone that they are out of date would + # be wrong far more often than right. + print(f'Latest release: {latest_text} (local version unknown). ' + f'See {_RELEASES_URL}') + return + + if latest > current: + print(_COLOR_ERROR + + f'Update available: {latest_text} (current: {current_text}). ' + f'See {_RELEASES_URL}' + + _COLOR_RESET) + else: + print(f'SpooNMAP {current_text} is up to date.') + + +def _maybe_check_for_updates(enabled): + """Run the update check only if the operator turned it on. + + Separate from _check_for_updates() so the gate itself is testable: main() + is under `pragma: no cover`, and "does a default config reach the network" + is exactly the question that must not go untested. + """ + if enabled: + _check_for_updates() + + # The Main Guts def main(): # pragma: no cover -- interactive CLI entry point; orchestrates # already-independently-tested functions behind input()-driven prompts, @@ -5857,6 +5952,11 @@ def main(): # pragma: no cover -- interactive CLI entry point; orchestrates if '--version' in sys.argv: print(_tool_version()) sys.exit(0) + # On-demand, regardless of config: asking whether an update exists should + # not require leaving the launch-time check switched on. + if '--check-update' in sys.argv: + _check_for_updates() + sys.exit(0) # Save initial terminal state initial_term_state = save_terminal_state() @@ -5916,6 +6016,8 @@ def main(): # pragma: no cover -- interactive CLI entry point; orchestrates resume = cfg['resume'] config_generated = cfg['config_generated'] + _maybe_check_for_updates(cfg['check_for_updates']) + # A config this tool wrote is a saved answer sheet, not a hand-authored # one, so ask about pre-existing output *before* the prompts: [d]elete and # [a]ppend then re-open every question with the saved values as defaults, diff --git a/tests/test_spoonmap.py b/tests/test_spoonmap.py index b267905..4eca580 100644 --- a/tests/test_spoonmap.py +++ b/tests/test_spoonmap.py @@ -173,6 +173,10 @@ def test_running_from_a_checkout_is_not_a_version(self): def test_the_unknown_sentinel_is_not_mistakable_for_a_version(self): assert not spoonmap._UNKNOWN_VERSION[0].isdigit() + # The property that actually matters for update checking: an unknown + # version cannot be parsed as a comparable release, so running from + # a checkout never claims an update is available. + assert spoonmap._parse_release_tag(spoonmap._UNKNOWN_VERSION) is None class TestRaiseFdLimit: @@ -12495,3 +12499,134 @@ def test_cleanup_removes_the_record(self, tmp_path): assert (disc / 'portFull.xml.coverage').exists() spoonmap._delete_previous_results(str(tmp_path)) assert not (tmp_path / 'discovery').exists() + + +class TestUpdateCheckIsOptIn: + """The launch-time update check is off unless explicitly enabled. + + SpooNMAP runs from jumpboxes inside client networks, where an unprompted + call to api.github.com is an outbound beacon from an engagement host that + nobody authorised. hate_crack defaults this to True; SpooNMAP inverts it, + and these tests are what keep it inverted. + """ + + def test_a_config_that_never_mentions_the_key_makes_no_network_call(self): + def explode(*args, **kwargs): + raise AssertionError( + 'a default config performed a network call at launch' + ) + + with patch('spoonmap.urllib.request.urlopen', side_effect=explode): + spoonmap._maybe_check_for_updates(False) + + def test_enabling_it_performs_the_check(self): + with patch('spoonmap._check_for_updates') as checked: + spoonmap._maybe_check_for_updates(True) + checked.assert_called_once() + + def test_load_config_defaults_the_key_to_false(self): + cfg = _config_dict() + assert 'check_for_updates' not in cfg + assert _load_config(cfg, '/t')['check_for_updates'] is False + + def test_load_config_honours_an_explicit_true(self): + cfg = _config_dict(check_for_updates=True) + assert _load_config(cfg, '/t')['check_for_updates'] is True + + def test_load_config_accepts_the_legacy_quoted_spelling(self): + """_config_bool() accepts "True"/"False" indefinitely for hand-edited + configs; this key is no exception.""" + cfg = _config_dict(check_for_updates='True') + assert _load_config(cfg, '/t')['check_for_updates'] is True + + +class TestCheckForUpdates: + """The check itself: comparison, output, and total failure tolerance.""" + + def _response(self, tag): + body = json.dumps({'tag_name': tag}).encode() + resp = MagicMock() + resp.read.return_value = body + resp.__enter__.return_value = resp + return resp + + def test_a_newer_release_is_reported(self, capsys): + with patch('spoonmap._tool_version', return_value='0.0.1'), \ + patch('spoonmap.urllib.request.urlopen', + return_value=self._response('v0.1.0')): + spoonmap._check_for_updates() + out = capsys.readouterr().out + assert '0.1.0' in out + + def test_being_up_to_date_says_so_without_claiming_an_update(self, capsys): + with patch('spoonmap._tool_version', return_value='0.1.0'), \ + patch('spoonmap.urllib.request.urlopen', + return_value=self._response('v0.1.0')): + spoonmap._check_for_updates() + assert 'Update available' not in capsys.readouterr().out + + def test_an_older_release_is_not_an_update(self, capsys): + with patch('spoonmap._tool_version', return_value='0.2.0'), \ + patch('spoonmap.urllib.request.urlopen', + return_value=self._response('v0.1.0')): + spoonmap._check_for_updates() + assert 'Update available' not in capsys.readouterr().out + + def test_an_unknown_local_version_never_claims_an_update(self, capsys): + """Running from a checkout has no version to compare. Reporting the + latest release is fine; asserting the operator is behind is not -- + it would nag everyone running from a clone, which is most of them.""" + with patch('spoonmap._tool_version', + return_value=spoonmap._UNKNOWN_VERSION), \ + patch('spoonmap.urllib.request.urlopen', + return_value=self._response('v0.1.0')): + spoonmap._check_for_updates() + out = capsys.readouterr().out + assert 'Update available' not in out + assert '0.1.0' in out + + def test_a_network_failure_is_swallowed(self, capsys): + """A failed update check must never delay, prompt, or abort a scan.""" + with patch('spoonmap._tool_version', return_value='0.0.1'), \ + patch('spoonmap.urllib.request.urlopen', + side_effect=OSError('no route to host')): + spoonmap._check_for_updates() # must not raise + assert 'Update available' not in capsys.readouterr().out + + def test_unparseable_json_is_swallowed(self, capsys): + resp = MagicMock() + resp.read.return_value = b'404' + resp.__enter__.return_value = resp + with patch('spoonmap._tool_version', return_value='0.0.1'), \ + patch('spoonmap.urllib.request.urlopen', return_value=resp): + spoonmap._check_for_updates() # must not raise + + def test_a_release_with_no_tag_name_is_swallowed(self, capsys): + resp = MagicMock() + resp.read.return_value = b'{}' + resp.__enter__.return_value = resp + with patch('spoonmap._tool_version', return_value='0.0.1'), \ + patch('spoonmap.urllib.request.urlopen', return_value=resp): + spoonmap._check_for_updates() # must not raise + + +class TestParseReleaseTag: + """Version comparison, without a packaging dependency.""" + + @pytest.mark.parametrize('text,expected', [ + ('v0.1.0', (0, 1, 0)), + ('0.1.0', (0, 1, 0)), + ('v10.4.7', (10, 4, 7)), + # Not a comparable release: a candidate, a dev build, junk, and the + # running-from-source sentinel. + ('v0.1.0rc1', None), + ('0.0.1.post1.dev1', None), + ('nightly', None), + ('', None), + ]) + def test_only_plain_releases_compare(self, text, expected): + assert spoonmap._parse_release_tag(text) == expected + + def test_comparison_is_numeric_not_lexical(self): + assert (spoonmap._parse_release_tag('v0.10.0') + > spoonmap._parse_release_tag('v0.9.0')) From b6b7543e8846a06718c899085ee4cc368fc8e4e4 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Wed, 26 Aug 2026 14:39:39 -0400 Subject: [PATCH 12/22] fix: round 1 corrections for Task 5 update checking 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 --- spoonmap.py | 15 ++++++++++----- tests/test_spoonmap.py | 33 ++++++++++++++++++++++++++------- 2 files changed, 36 insertions(+), 12 deletions(-) diff --git a/spoonmap.py b/spoonmap.py index 366be00..50a1684 100755 --- a/spoonmap.py +++ b/spoonmap.py @@ -24,7 +24,6 @@ import time import queue from queue import Queue -import urllib.error import urllib.request import xml.etree.ElementTree as etree from importlib import metadata @@ -5889,22 +5888,28 @@ def _parse_release_tag(text): def _check_for_updates(timeout=_UPDATE_CHECK_TIMEOUT): - """Report whether a newer release exists. Never raises, never blocks long. + """Report whether a newer release exists. Never raises; times out quickly. Every failure mode -- no route, DNS, TLS, rate limiting, an HTML error page - where JSON was expected, a release with no tag_name -- is swallowed. An - update check is a courtesy; a scan must never fail or stall because one did. + where JSON was expected, a release with no tag_name -- is swallowed. The + timeout bounds socket operations (connect, read) but not getaddrinfo, so + a blackholed resolver can still block past the timeout. An update check is + a courtesy; a scan must never fail or stall because one did. """ + payload = None try: with urllib.request.urlopen(_RELEASE_API_URL, timeout=timeout) as resp: payload = json.loads(resp.read().decode('utf-8', 'replace')) - latest_text = payload.get('tag_name', '') except Exception: # Intentionally broad: see the docstring. There is no failure here # worth interrupting an operator for, and the set of exceptions urllib # and json can raise between them is not worth enumerating wrongly. return + if not isinstance(payload, dict): + return + latest_text = str(payload.get('tag_name', '')) + latest = _parse_release_tag(latest_text) if latest is None: return diff --git a/tests/test_spoonmap.py b/tests/test_spoonmap.py index 4eca580..a255f36 100644 --- a/tests/test_spoonmap.py +++ b/tests/test_spoonmap.py @@ -2978,6 +2978,12 @@ def test_every_sample_doc_key_is_in_the_constant(self): def test_sample_has_no_generated_marker(self): assert _CONFIG_GENERATED_KEY not in self._sample() + def test_check_for_updates_defaults_to_false_in_sample(self): + """The security model requires this key to default false. Flipping it + in the sample is the most likely route to it being enabled by accident. + This test pins the value permanently.""" + assert self._sample()['check_for_updates'] is False + def test_scan_categories_choices_track_service_categories(self): choices = dict(_CONFIG_DOCS['scan_categories'])['__scan_categories_choices__'] assert choices == 'All, Full, ' + ', '.join(SERVICE_CATEGORIES) @@ -12511,13 +12517,9 @@ class TestUpdateCheckIsOptIn: """ def test_a_config_that_never_mentions_the_key_makes_no_network_call(self): - def explode(*args, **kwargs): - raise AssertionError( - 'a default config performed a network call at launch' - ) - - with patch('spoonmap.urllib.request.urlopen', side_effect=explode): + with patch('spoonmap._check_for_updates') as checked: spoonmap._maybe_check_for_updates(False) + assert not checked.called, 'a default config performed a network call at launch' def test_enabling_it_performs_the_check(self): with patch('spoonmap._check_for_updates') as checked: @@ -12556,6 +12558,7 @@ def test_a_newer_release_is_reported(self, capsys): return_value=self._response('v0.1.0')): spoonmap._check_for_updates() out = capsys.readouterr().out + assert 'Update available' in out assert '0.1.0' in out def test_being_up_to_date_says_so_without_claiming_an_update(self, capsys): @@ -12563,7 +12566,9 @@ def test_being_up_to_date_says_so_without_claiming_an_update(self, capsys): patch('spoonmap.urllib.request.urlopen', return_value=self._response('v0.1.0')): spoonmap._check_for_updates() - assert 'Update available' not in capsys.readouterr().out + out = capsys.readouterr().out + assert 'Update available' not in out + assert 'is up to date' in out def test_an_older_release_is_not_an_update(self, capsys): with patch('spoonmap._tool_version', return_value='0.2.0'), \ @@ -12600,6 +12605,7 @@ def test_unparseable_json_is_swallowed(self, capsys): with patch('spoonmap._tool_version', return_value='0.0.1'), \ patch('spoonmap.urllib.request.urlopen', return_value=resp): spoonmap._check_for_updates() # must not raise + assert 'Update available' not in capsys.readouterr().out def test_a_release_with_no_tag_name_is_swallowed(self, capsys): resp = MagicMock() @@ -12609,6 +12615,19 @@ def test_a_release_with_no_tag_name_is_swallowed(self, capsys): patch('spoonmap.urllib.request.urlopen', return_value=resp): spoonmap._check_for_updates() # must not raise + def test_non_string_tag_name_is_coerced(self, capsys): + """A proxy or captive portal might return well-formed JSON with + unexpected field types. Non-string tag_name must not crash.""" + for tag_value in (123, {'nested': 'dict'}): + body = json.dumps({'tag_name': tag_value}).encode() + resp = MagicMock() + resp.read.return_value = body + resp.__enter__.return_value = resp + with patch('spoonmap._tool_version', return_value='0.0.1'), \ + patch('spoonmap.urllib.request.urlopen', return_value=resp): + spoonmap._check_for_updates() # must not raise + assert 'Update available' not in capsys.readouterr().out + class TestParseReleaseTag: """Version comparison, without a packaging dependency.""" From 69bd9fa603cb90fb2143164c7247e86404ce9fe6 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Wed, 26 Aug 2026 14:43:16 -0400 Subject: [PATCH 13/22] test: cover non-dict JSON payloads in update check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- tests/test_spoonmap.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/test_spoonmap.py b/tests/test_spoonmap.py index a255f36..63e5a89 100644 --- a/tests/test_spoonmap.py +++ b/tests/test_spoonmap.py @@ -12628,6 +12628,19 @@ def test_non_string_tag_name_is_coerced(self, capsys): spoonmap._check_for_updates() # must not raise assert 'Update available' not in capsys.readouterr().out + def test_json_body_that_is_not_a_dict_is_swallowed(self, capsys): + """A proxy or captive portal might return valid JSON that is not an + object — a list, or a bare string. These are common error page + responses and must not crash.""" + for body_bytes in (b'[]', b'"nope"'): + resp = MagicMock() + resp.read.return_value = body_bytes + resp.__enter__.return_value = resp + with patch('spoonmap._tool_version', return_value='0.0.1'), \ + patch('spoonmap.urllib.request.urlopen', return_value=resp): + spoonmap._check_for_updates() # must not raise + assert 'Update available' not in capsys.readouterr().out + class TestParseReleaseTag: """Version comparison, without a packaging dependency.""" From bca4addd7c4e7cf10047807df6172a8fab7948ff Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Wed, 26 Aug 2026 14:53:09 -0400 Subject: [PATCH 14/22] test: guard the release-versioning wiring 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 --- .github/workflows/ci.yml | 4 +- pyproject.toml | 3 + tests/test_release_versioning.py | 279 +++++++++++++++++++++++++++++++ uv.lock | 2 + 4 files changed, 286 insertions(+), 2 deletions(-) create mode 100644 tests/test_release_versioning.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 33f53cd..6322619 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -122,7 +122,7 @@ jobs: - name: Run tests run: > uv run --isolated --no-project --python ${{ matrix.python-version }} - --with pytest --with pytest-cov + --with pytest --with pytest-cov --with pyyaml --with packaging pytest tests/ -v -rs # Separate job, not a step on `test`: a lint failure and a test failure are @@ -151,7 +151,7 @@ jobs: run: uv lock --check - name: Ruff - run: uv run --frozen ruff check spoonmap.py tests/ + run: uv run --frozen ruff check spoonmap.py tests/ tools/ # SAST against a committed baseline: spoonmap shells out to masscan/nmap and # parses their XML, so a bare run reports 32 reviewed findings (subprocess diff --git a/pyproject.toml b/pyproject.toml index 41fca59..34d7dee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,9 @@ dev = [ # different pre-release schemes have been got wrong before. Not a runtime # dependency: spoonmap.py is stdlib-only. "packaging>=24.0", + # Test-only. tests/test_release_versioning.py parses the workflow YAML to + # assert the CI triggers and tagging steps still wire together. + "pyyaml>=6.0", ] [tool.uv] diff --git a/tests/test_release_versioning.py b/tests/test_release_versioning.py new file mode 100644 index 0000000..652ebfb --- /dev/null +++ b/tests/test_release_versioning.py @@ -0,0 +1,279 @@ +"""Guards on the release-versioning wiring. + +The policy itself -- which component moves, and to what -- lives in +tools/next_version.py and is tested in tests/test_next_version.py. Nothing here +re-implements it. + +What this file guards is everything around the policy, all of which fails +*silently*: + +* The tag job ceasing to depend on the jobs that validate the commit, which + would let a tag land on a commit that failed its tests. +* The policy module ceasing to be the only thing that produces a version, + asserted as a positive invariant (exactly one next_version.py call, and the + pushed tag read back from its output) with a denylist of shell version + arithmetic as a second line of defence. +* A `fetch-depth` reverted to the default, which does not fail anything -- it + silently computes versions from a baseline of no tags at all. +* The behaviour of the shell that remains -- tag idempotency and the + empty-batch path -- asserted by extracting the step script from the YAML and + running it against a real git repository and a real bare remote. + +Substring assertions on YAML are sensitive to formatting and blind to +behaviour, which is backwards. Do not convert these back into them. +""" + +import os +import re +import subprocess + +import pytest +import yaml + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +WORKFLOWS = os.path.join(REPO_ROOT, '.github', 'workflows') + + +def _load(name): + with open(os.path.join(WORKFLOWS, name)) as handle: + return yaml.safe_load(handle) + + +# `on` is the YAML 1.1 boolean True, so a parsed workflow keys the trigger +# block under True rather than 'on'. This bites everyone once. +def _triggers(workflow): + return workflow.get('on', workflow.get(True)) + + +def _job(name, job_id): + return _load(name)['jobs'][job_id] + + +def _step_script(job, step_name): + for step in job['steps']: + if step.get('name') == step_name: + return step['run'] + raise AssertionError(f'no step named {step_name!r}') + + +def _checkout(job): + for step in job['steps']: + if 'actions/checkout' in str(step.get('uses', '')): + return step + raise AssertionError('no checkout step') + + +# --- triggers and gating ----------------------------------------------------- + + +def test_ci_runs_on_pushes_to_both_release_branches(): + """A push to `nightly` that runs no CI would never reach the tag job, and + no candidate would ever be cut. Nothing errors; tags just stop appearing.""" + branches = _triggers(_load('ci.yml'))['push']['branches'] + assert 'nightly' in branches + assert 'main' in branches + + +def test_the_tag_job_waits_for_every_validating_job(): + """A tag must never appear on a commit that failed anything. `needs` treats + a failed or skipped dependency as not-success, so the job simply does not + run -- but only for jobs actually listed here.""" + ci = _load('ci.yml') + needs = set(ci['jobs']['tag']['needs']) + validating = {j for j in ci['jobs'] if j != 'tag'} + missing = validating - needs + assert not missing, f'tag job does not depend on: {sorted(missing)}' + + +def test_the_tag_job_never_runs_on_pull_requests(): + """ci.yml also runs on pull_request, where tagging would be actively + wrong.""" + condition = _job('ci.yml', 'tag')['if'] + assert "github.event_name == 'push'" in condition + assert "refs/heads/main" in condition + assert "refs/heads/nightly" in condition + + +def test_only_the_tag_job_can_write(): + """The workflow is read-only; exactly one job escalates, and only to what + pushing a tag and cutting a release requires.""" + ci = _load('ci.yml') + assert ci['permissions'] == {'contents': 'read'} + assert ci['jobs']['tag']['permissions'] == {'contents': 'write'} + for job_id, job in ci['jobs'].items(): + if job_id != 'tag': + assert 'permissions' not in job, job_id + + +def test_the_tag_job_does_not_cancel_itself(): + """Two pushes landing together would compute the same tag; the second push + would fail. Serialize per branch rather than cancel, so none is skipped.""" + concurrency = _job('ci.yml', 'tag')['concurrency'] + assert concurrency['cancel-in-progress'] is False + + +# --- checkout depth ---------------------------------------------------------- + + +@pytest.mark.parametrize('job_id', ['tag', 'build']) +def test_version_deriving_jobs_fetch_all_history(job_id): + """Both jobs derive a version from git describe. A shallow clone does not + fail either of them -- it silently computes from a baseline of no tags, + which is how a wrong version ships without anything going red.""" + assert _checkout(_job('ci.yml', job_id))['with']['fetch-depth'] == 0 + + +def test_the_tag_job_keeps_its_credentials(): + """Deliberate exception to this repo's persist-credentials: false rule: + this job pushes a tag and needs the token. Pinned so a well-meaning + convention sweep cannot silently break tagging.""" + assert _checkout(_job('ci.yml', 'tag'))['with']['persist-credentials'] is True + + +def test_every_other_checkout_drops_its_credentials(): + ci = _load('ci.yml') + for job_id, job in ci['jobs'].items(): + if job_id == 'tag': + continue + assert _checkout(job)['with']['persist-credentials'] is False, job_id + + +# --- the policy module is the only thing that produces a version ------------- + + +def test_exactly_one_call_to_the_policy_module(): + with open(os.path.join(WORKFLOWS, 'ci.yml')) as handle: + body = handle.read() + calls = re.findall(r'tools/next_version\.py --channel', body) + assert len(calls) == 1, 'the tag must come from exactly one call' + + +def test_both_channels_are_reachable(): + """main cuts the final release, nightly cuts a candidate for the same + target. A job that only ever computed one channel would silently tag + nightly builds as releases, or never cut a release at all.""" + script = _step_script(_job('ci.yml', 'tag'), 'Compute tag') + assert 'channel=stable' in script + assert 'channel=nightly' in script + + +def test_the_pushed_tag_is_the_one_the_policy_computed(): + job = _job('ci.yml', 'tag') + compute = [s for s in job['steps'] if 'next_version.py' in s.get('run', '')] + assert len(compute) == 1 + step_id = compute[0]['id'] + create = [s for s in job['steps'] if s.get('name') == 'Create tag'][0] + assert create['env']['NEW_TAG'] == '${{ steps.%s.outputs.new_tag }}' % step_id + + +def test_no_shell_version_arithmetic(): + """Second line of defence. Version math in YAML cannot be unit-tested, + which is the entire reason tools/next_version.py exists.""" + with open(os.path.join(WORKFLOWS, 'ci.yml')) as handle: + body = handle.read() + for banned in ('cut -d.', '$((', 'awk -F.'): + assert banned not in body, f'version arithmetic in YAML: {banned}' + + +def test_only_stable_publishes_a_release(): + """Nightly candidates exist to make builds addressable, not to be releases. + Publishing them would make anything ranking releases see a candidate as + latest.""" + release_step = [ + s for s in _job('ci.yml', 'tag')['steps'] + if s.get('name') == 'Create GitHub release' + ][0] + assert "== 'stable'" in release_step['if'] + + +# --- the behaviour of the shell that remains --------------------------------- + + +def _git(repo, *args): + return subprocess.run( + ['git', *args], cwd=repo, capture_output=True, text=True, check=True + ).stdout + + +@pytest.fixture +def repo_with_remote(tmp_path): + """A real repository with a real bare origin, so `git push` is exercised.""" + remote = tmp_path / 'remote.git' + subprocess.run(['git', 'init', '-q', '--bare', str(remote)], check=True) + repo = tmp_path / 'repo' + subprocess.run(['git', 'init', '-q', str(repo)], check=True) + _git(repo, 'config', 'user.email', 'test@example.com') + _git(repo, 'config', 'user.name', 'test') + (repo / 'f.txt').write_text('x') + _git(repo, 'add', '-A') + _git(repo, 'commit', '-qm', 'fix: initial') + _git(repo, 'remote', 'add', 'origin', str(remote)) + return repo, remote + + +def _run_create_tag(repo, script, new_tag): + env = dict(os.environ, NEW_TAG=new_tag) + return subprocess.run( + ['bash', '-c', script], cwd=repo, env=env, + capture_output=True, text=True, + ) + + +def test_create_tag_pushes_the_tag(repo_with_remote): + repo, remote = repo_with_remote + script = _step_script(_job('ci.yml', 'tag'), 'Create tag') + result = _run_create_tag(repo, script, 'v0.1.0') + assert result.returncode == 0, result.stderr + assert 'v0.1.0' in _git(remote, 'tag') + + +def test_create_tag_is_idempotent(repo_with_remote): + """A re-run of the workflow must not fail the job.""" + repo, _ = repo_with_remote + script = _step_script(_job('ci.yml', 'tag'), 'Create tag') + assert _run_create_tag(repo, script, 'v0.1.0').returncode == 0 + second = _run_create_tag(repo, script, 'v0.1.0') + assert second.returncode == 0, second.stderr + + +def test_an_empty_batch_tags_nothing_and_is_not_an_error(repo_with_remote): + """No commits since the last release is a re-run, not a failure. Tagging + "" would fail with a message about nothing in particular.""" + repo, remote = repo_with_remote + script = _step_script(_job('ci.yml', 'tag'), 'Create tag') + result = _run_create_tag(repo, script, '') + assert result.returncode == 0, result.stderr + assert _git(remote, 'tag').strip() == '' + + +def test_the_policy_module_agrees_with_this_repository(): + """End to end against the real repo: the CLI runs and prints a usable tag + or nothing at all. Catches an import error or a bad shebang that no unit + test would see.""" + result = subprocess.run( + ['python3', os.path.join(REPO_ROOT, 'tools', 'next_version.py'), + '--channel', 'nightly', '--repo-dir', REPO_ROOT], + capture_output=True, text=True, + ) + assert result.returncode == 0, result.stderr + output = result.stdout.strip() + assert output == '' or re.match(r'^v\d+\.\d+\.\d+rc\d+$', output), output + + +# --- the hand-pushed release path ------------------------------------------- + + +def test_release_workflow_still_exists_for_hand_pushed_tags(): + """The policy never bumps a major automatically, so a major release is + `git tag v1.0.0 && git push`. This is what turns that into a release.""" + assert _triggers(_load('release.yml'))['push']['tags'] == ['v*'] + + +def test_the_release_workflow_uses_no_third_party_action(): + """The runner already ships gh, and the tag job publishes the same way. + zizmor flags the third-party action as superfluous, and two release paths + doing the same thing differently is one too many.""" + (job,) = _load('release.yml')['jobs'].values() + for step in job['steps']: + uses = str(step.get('uses', '')) + assert 'action-gh-release' not in uses diff --git a/uv.lock b/uv.lock index f8f1f64..b681fca 100644 --- a/uv.lock +++ b/uv.lock @@ -371,6 +371,7 @@ dev = [ { name = "packaging", marker = "python_full_version >= '3.10'" }, { name = "pytest", marker = "python_full_version >= '3.10'" }, { name = "pytest-cov", marker = "python_full_version >= '3.10'" }, + { name = "pyyaml", marker = "python_full_version >= '3.10'" }, { name = "ruff", marker = "python_full_version >= '3.10'" }, ] @@ -382,6 +383,7 @@ dev = [ { name = "packaging", specifier = ">=24.0" }, { name = "pytest", specifier = ">=9.0.3" }, { name = "pytest-cov", specifier = ">=5.0.0" }, + { name = "pyyaml", specifier = ">=6.0" }, { name = "ruff", specifier = "==0.16.4" }, ] From c8aadf1e4c053167209bc9bbb9bc72826618526a Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Wed, 26 Aug 2026 15:06:15 -0400 Subject: [PATCH 15/22] test: fix release-versioning guards to catch all critical mutations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- tests/test_release_versioning.py | 200 +++++++++++++++++++++++++------ 1 file changed, 163 insertions(+), 37 deletions(-) diff --git a/tests/test_release_versioning.py b/tests/test_release_versioning.py index 652ebfb..eca4db3 100644 --- a/tests/test_release_versioning.py +++ b/tests/test_release_versioning.py @@ -10,9 +10,8 @@ * The tag job ceasing to depend on the jobs that validate the commit, which would let a tag land on a commit that failed its tests. * The policy module ceasing to be the only thing that produces a version, - asserted as a positive invariant (exactly one next_version.py call, and the - pushed tag read back from its output) with a denylist of shell version - arithmetic as a second line of defence. + asserted by running the actual Compute tag step and reading back the outputs + it writes to $GITHUB_OUTPUT, rather than grepping for function calls. * A `fetch-depth` reverted to the default, which does not fail anything -- it silently computes versions from a baseline of no tags at all. * The behaviour of the shell that remains -- tag idempotency and the @@ -63,6 +62,11 @@ def _checkout(job): raise AssertionError('no checkout step') +def _normalize_yaml_expr(expr): + """Normalize YAML expression for comparison: collapse whitespace.""" + return re.sub(r'\s+', ' ', expr.strip()) + + # --- triggers and gating ----------------------------------------------------- @@ -77,41 +81,78 @@ def test_ci_runs_on_pushes_to_both_release_branches(): def test_the_tag_job_waits_for_every_validating_job(): """A tag must never appear on a commit that failed anything. `needs` treats a failed or skipped dependency as not-success, so the job simply does not - run -- but only for jobs actually listed here.""" + run -- but only for jobs actually listed here. + + Exclude any job whose own `needs` contains 'tag' (e.g., a future publish job). + """ ci = _load('ci.yml') needs = set(ci['jobs']['tag']['needs']) - validating = {j for j in ci['jobs'] if j != 'tag'} + # Exclude jobs that depend on tag (self-referencing jobs like publish) + validating = { + j for j in ci['jobs'] + if j != 'tag' and not (ci['jobs'][j].get('needs') and 'tag' in ci['jobs'][j].get('needs', [])) + } missing = validating - needs assert not missing, f'tag job does not depend on: {sorted(missing)}' def test_the_tag_job_never_runs_on_pull_requests(): """ci.yml also runs on pull_request, where tagging would be actively - wrong.""" + wrong. Assert the entire normalized if expression, not fragments.""" condition = _job('ci.yml', 'tag')['if'] - assert "github.event_name == 'push'" in condition - assert "refs/heads/main" in condition - assert "refs/heads/nightly" in condition + normalized = _normalize_yaml_expr(condition) + # Must assert the full expression to catch && → || mutations + expected = _normalize_yaml_expr( + "github.event_name == 'push' && " + "(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/nightly')" + ) + assert normalized == expected, f'Expected: {expected}\nGot: {normalized}' def test_only_the_tag_job_can_write(): """The workflow is read-only; exactly one job escalates, and only to what - pushing a tag and cutting a release requires.""" + pushing a tag and cutting a release requires. Exclude publish-like jobs + that legitimately need write access.""" ci = _load('ci.yml') assert ci['permissions'] == {'contents': 'read'} assert ci['jobs']['tag']['permissions'] == {'contents': 'write'} for job_id, job in ci['jobs'].items(): if job_id != 'tag': + # Allow publish-like jobs to have their own permissions + if 'needs' in job and 'tag' in job['needs']: + continue assert 'permissions' not in job, job_id def test_the_tag_job_does_not_cancel_itself(): - """Two pushes landing together would compute the same tag; the second push - would fail. Serialize per branch rather than cancel, so none is skipped.""" + """Two pushes landing together would otherwise both compute the same tag. + Serialize per branch instead of cancelling, so no push is skipped.""" concurrency = _job('ci.yml', 'tag')['concurrency'] assert concurrency['cancel-in-progress'] is False +def test_tag_job_concurrency_group_is_branch_specific(): + """The tag job's concurrency group must not collide with the workflow-level + group, which would deadlock the job against itself. It must also interpolate + github.ref to serialize by branch.""" + tag_job = _job('ci.yml', 'tag') + tag_concurrency_group = tag_job['concurrency']['group'] + workflow_concurrency_group = _load('ci.yml')['concurrency']['group'] + + # Groups must be different to avoid deadlock + assert tag_concurrency_group != workflow_concurrency_group + # Tag group must reference github.ref to serialize by branch + assert 'github.ref' in tag_concurrency_group + + +def test_all_jobs_declare_timeout_minutes(): + """Every job must declare timeout-minutes to bound execution. A hung job + holding contents: write is the worst one to lose that bound.""" + ci = _load('ci.yml') + for job_id, job in ci['jobs'].items(): + assert 'timeout-minutes' in job, f'job {job_id} missing timeout-minutes' + + # --- checkout depth ---------------------------------------------------------- @@ -119,33 +160,42 @@ def test_the_tag_job_does_not_cancel_itself(): def test_version_deriving_jobs_fetch_all_history(job_id): """Both jobs derive a version from git describe. A shallow clone does not fail either of them -- it silently computes from a baseline of no tags, - which is how a wrong version ships without anything going red.""" - assert _checkout(_job('ci.yml', job_id))['with']['fetch-depth'] == 0 + which is how a wrong version ships without anything going red. GitHub + coerces fetch-depth to a string, so compare as string.""" + depth = _checkout(_job('ci.yml', job_id))['with']['fetch-depth'] + assert str(depth).lower() == '0', f'job {job_id} must have fetch-depth: 0, got {depth}' def test_the_tag_job_keeps_its_credentials(): """Deliberate exception to this repo's persist-credentials: false rule: - this job pushes a tag and needs the token. Pinned so a well-meaning - convention sweep cannot silently break tagging.""" - assert _checkout(_job('ci.yml', 'tag'))['with']['persist-credentials'] is True + this job pushes a tag and needs the token. GitHub coerces to string.""" + persist = _checkout(_job('ci.yml', 'tag'))['with']['persist-credentials'] + assert str(persist).lower() == 'true' def test_every_other_checkout_drops_its_credentials(): + """All other jobs must drop credentials. GitHub coerces to string.""" ci = _load('ci.yml') for job_id, job in ci['jobs'].items(): if job_id == 'tag': continue - assert _checkout(job)['with']['persist-credentials'] is False, job_id + persist = _checkout(job)['with']['persist-credentials'] + assert str(persist).lower() == 'false', job_id # --- the policy module is the only thing that produces a version ------------- def test_exactly_one_call_to_the_policy_module(): - with open(os.path.join(WORKFLOWS, 'ci.yml')) as handle: - body = handle.read() - calls = re.findall(r'tools/next_version\.py --channel', body) - assert len(calls) == 1, 'the tag must come from exactly one call' + """The policy module must be called exactly once to produce the version. + Count against the parsed run bodies of the tag job's steps, not the whole + file (which may contain --repo-dir in comments).""" + job = _job('ci.yml', 'tag') + calls = sum( + len(re.findall(r'tools/next_version\.py\s+--channel', step.get('run', ''))) + for step in job['steps'] + ) + assert calls == 1, f'expected exactly one call to tools/next_version.py, found {calls}' def test_both_channels_are_reachable(): @@ -157,7 +207,58 @@ def test_both_channels_are_reachable(): assert 'channel=nightly' in script +@pytest.mark.parametrize('branch,expected_channel', [ + ('refs/heads/main', 'stable'), + ('refs/heads/nightly', 'nightly'), +]) +def test_compute_tag_step_assigns_correct_channel(branch, expected_channel, tmp_path): + """The Compute tag step must actually WRITE the channel and new_tag outputs + to $GITHUB_OUTPUT. Runs the step in isolation with a real bash subprocess.""" + script = _step_script(_job('ci.yml', 'tag'), 'Compute tag') + + # Minimal environment: only PATH, HOME, and the variables the step needs + env = { + 'PATH': os.environ.get('PATH', '/usr/bin:/bin'), + 'HOME': os.environ.get('HOME', '/tmp'), + 'GITHUB_REF': branch, + 'GITHUB_OUTPUT': str(tmp_path / 'github_output'), + } + + result = subprocess.run( + ['bash', '-c', script], + cwd=REPO_ROOT, + env=env, + capture_output=True, + text=True, + ) + assert result.returncode == 0, f'Compute tag step failed: {result.stderr}' + + # Parse the $GITHUB_OUTPUT file to read back the values the step wrote + output_file = tmp_path / 'github_output' + assert output_file.exists(), 'Compute tag step did not write $GITHUB_OUTPUT' + + output_contents = output_file.read_text() + parsed = {} + for line in output_contents.splitlines(): + if '=' in line: + key, value = line.split('=', 1) + parsed[key] = value + + # Assert the channel is assigned correctly + assert 'channel' in parsed, f'channel key not in GITHUB_OUTPUT: {parsed}' + assert parsed['channel'] == expected_channel, \ + f'Expected channel={expected_channel}, got channel={parsed["channel"]}' + + # Assert new_tag exists and has a plausible shape (or is empty for no new commits) + assert 'new_tag' in parsed, f'new_tag key not in GITHUB_OUTPUT: {parsed}' + new_tag = parsed['new_tag'] + if new_tag: + assert re.match(r'^v\d+\.\d+\.\d+', new_tag), \ + f'new_tag has unexpected format: {new_tag}' + + def test_the_pushed_tag_is_the_one_the_policy_computed(): + """The Create tag step must use the tag computed by Compute tag step.""" job = _job('ci.yml', 'tag') compute = [s for s in job['steps'] if 'next_version.py' in s.get('run', '')] assert len(compute) == 1 @@ -168,22 +269,30 @@ def test_the_pushed_tag_is_the_one_the_policy_computed(): def test_no_shell_version_arithmetic(): """Second line of defence. Version math in YAML cannot be unit-tested, - which is the entire reason tools/next_version.py exists.""" - with open(os.path.join(WORKFLOWS, 'ci.yml')) as handle: - body = handle.read() + which is the entire reason tools/next_version.py exists. Scope to tag job + steps only, not the whole file.""" + job = _job('ci.yml', 'tag') + script = '\n'.join(step.get('run', '') for step in job['steps']) for banned in ('cut -d.', '$((', 'awk -F.'): - assert banned not in body, f'version arithmetic in YAML: {banned}' + assert banned not in script, f'version arithmetic in tag job: {banned}' def test_only_stable_publishes_a_release(): """Nightly candidates exist to make builds addressable, not to be releases. Publishing them would make anything ranking releases see a candidate as - latest.""" - release_step = [ + latest. Assert the full if condition, not fragments.""" + release_steps = [ s for s in _job('ci.yml', 'tag')['steps'] if s.get('name') == 'Create GitHub release' - ][0] - assert "== 'stable'" in release_step['if'] + ] + assert len(release_steps) == 1 + release_step = release_steps[0] + + # Assert the full if condition to catch || true appending + if_condition = release_step.get('if', '') + normalized = _normalize_yaml_expr(if_condition) + assert normalized == _normalize_yaml_expr("steps.bump.outputs.channel == 'stable'"), \ + f'release step if condition must be exactly "steps.bump.outputs.channel == \'stable\'", got: {normalized}' # --- the behaviour of the shell that remains --------------------------------- @@ -212,7 +321,12 @@ def repo_with_remote(tmp_path): def _run_create_tag(repo, script, new_tag): - env = dict(os.environ, NEW_TAG=new_tag) + """Run the Create tag step with minimal environment (no inherited vars).""" + env = { + 'PATH': os.environ.get('PATH', '/usr/bin:/bin'), + 'HOME': os.environ.get('HOME', '/tmp'), + 'NEW_TAG': new_tag, + } return subprocess.run( ['bash', '-c', script], cwd=repo, env=env, capture_output=True, text=True, @@ -269,11 +383,23 @@ def test_release_workflow_still_exists_for_hand_pushed_tags(): assert _triggers(_load('release.yml'))['push']['tags'] == ['v*'] -def test_the_release_workflow_uses_no_third_party_action(): +def test_the_release_workflow_uses_only_built_in_actions(): """The runner already ships gh, and the tag job publishes the same way. - zizmor flags the third-party action as superfluous, and two release paths - doing the same thing differently is one too many.""" - (job,) = _load('release.yml')['jobs'].values() + Assert positive: the release step must call `gh release create`, and no + step may use a third-party action.""" + job = list(_load('release.yml')['jobs'].values())[0] + + # Positive assertion: at least one step must run gh release create + gh_found = False + for step in job['steps']: + if 'gh release create' in step.get('run', ''): + gh_found = True + break + assert gh_found, 'no step runs `gh release create`' + + # Every step that uses an action must use built-in actions/ for step in job['steps']: - uses = str(step.get('uses', '')) - assert 'action-gh-release' not in uses + uses = step.get('uses', '') + if uses: + assert uses.startswith('actions/'), \ + f'release workflow uses non-built-in action: {uses}' From c8c161efaf6e2cc6f0ad4ec807e274ab0815dc22 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Wed, 26 Aug 2026 15:16:40 -0400 Subject: [PATCH 16/22] test: fix three remaining release-versioning test issues 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 --- tests/test_release_versioning.py | 42 +++++++++++++++++++++++--------- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/tests/test_release_versioning.py b/tests/test_release_versioning.py index eca4db3..9a08757 100644 --- a/tests/test_release_versioning.py +++ b/tests/test_release_versioning.py @@ -87,10 +87,22 @@ def test_the_tag_job_waits_for_every_validating_job(): """ ci = _load('ci.yml') needs = set(ci['jobs']['tag']['needs']) + + # Helper to normalize job's needs list, handling both list and string forms + def get_needs_set(job_def): + job_needs = job_def.get('needs') + if isinstance(job_needs, str): + # Normalize string form to list to avoid substring matching + return {job_needs} + elif isinstance(job_needs, list): + return set(job_needs) + else: + return set() + # Exclude jobs that depend on tag (self-referencing jobs like publish) validating = { j for j in ci['jobs'] - if j != 'tag' and not (ci['jobs'][j].get('needs') and 'tag' in ci['jobs'][j].get('needs', [])) + if j != 'tag' and 'tag' not in get_needs_set(ci['jobs'][j]) } missing = validating - needs assert not missing, f'tag job does not depend on: {sorted(missing)}' @@ -111,16 +123,20 @@ def test_the_tag_job_never_runs_on_pull_requests(): def test_only_the_tag_job_can_write(): """The workflow is read-only; exactly one job escalates, and only to what - pushing a tag and cutting a release requires. Exclude publish-like jobs - that legitimately need write access.""" + pushing a tag and cutting a release requires.""" + # Jobs permitted to declare their own `permissions:`. Adding a name here is + # a deliberate decision to let another job escalate, and it should come with + # a reason -- `tag` needs contents: write to push a tag and cut a release. + # A derived rule was tried here and removed: exempting anything that depends + # on `tag` let a future job grant itself contents: write with nothing + # tripping, which is the invariant this test exists to hold. + MAY_DECLARE_PERMISSIONS = {'tag'} + ci = _load('ci.yml') assert ci['permissions'] == {'contents': 'read'} assert ci['jobs']['tag']['permissions'] == {'contents': 'write'} for job_id, job in ci['jobs'].items(): - if job_id != 'tag': - # Allow publish-like jobs to have their own permissions - if 'needs' in job and 'tag' in job['needs']: - continue + if job_id not in MAY_DECLARE_PERMISSIONS: assert 'permissions' not in job, job_id @@ -249,12 +265,16 @@ def test_compute_tag_step_assigns_correct_channel(branch, expected_channel, tmp_ assert parsed['channel'] == expected_channel, \ f'Expected channel={expected_channel}, got channel={parsed["channel"]}' - # Assert new_tag exists and has a plausible shape (or is empty for no new commits) + # Assert new_tag key exists and is non-empty in this environment. + # Empty is only legitimate when HEAD sits exactly on a tag (no new commits), + # which is not the case in this test repo. The key must exist and have a value + # to catch mutations that write then blank it. Do NOT use `if new_tag:` guards + # that would skip validation of a blanked value. assert 'new_tag' in parsed, f'new_tag key not in GITHUB_OUTPUT: {parsed}' new_tag = parsed['new_tag'] - if new_tag: - assert re.match(r'^v\d+\.\d+\.\d+', new_tag), \ - f'new_tag has unexpected format: {new_tag}' + assert new_tag, f'new_tag must not be empty in this environment, got: {new_tag!r}' + assert re.match(r'^v\d+\.\d+\.\d+', new_tag), \ + f'new_tag has unexpected format: {new_tag}' def test_the_pushed_tag_is_the_one_the_policy_computed(): From a202256ea43a1db699761c3c4b2a97fcfe500441 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Wed, 26 Aug 2026 15:19:55 -0400 Subject: [PATCH 17/22] test: fix item 3 validation to accept legitimate empty new_tag 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 --- tests/test_release_versioning.py | 36 +++++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/tests/test_release_versioning.py b/tests/test_release_versioning.py index 9a08757..78ea502 100644 --- a/tests/test_release_versioning.py +++ b/tests/test_release_versioning.py @@ -265,16 +265,36 @@ def test_compute_tag_step_assigns_correct_channel(branch, expected_channel, tmp_ assert parsed['channel'] == expected_channel, \ f'Expected channel={expected_channel}, got channel={parsed["channel"]}' - # Assert new_tag key exists and is non-empty in this environment. - # Empty is only legitimate when HEAD sits exactly on a tag (no new commits), - # which is not the case in this test repo. The key must exist and have a value - # to catch mutations that write then blank it. Do NOT use `if new_tag:` guards - # that would skip validation of a blanked value. + # Assert both keys are present unconditionally. This catches key renames + # (e.g., echo "new_tag=$new_tag" renamed to echo "tagname=$new_tag"). assert 'new_tag' in parsed, f'new_tag key not in GITHUB_OUTPUT: {parsed}' + assert 'channel' in parsed, f'channel key not in GITHUB_OUTPUT (checked twice): {parsed}' + + # Validate new_tag value shape without guarding. Empty is legitimate when HEAD + # sits exactly on a tag (no new commits). Non-empty must match the channel's + # tag format: stable uses v0.1.0, nightly uses v0.1.0rc1. new_tag = parsed['new_tag'] - assert new_tag, f'new_tag must not be empty in this environment, got: {new_tag!r}' - assert re.match(r'^v\d+\.\d+\.\d+', new_tag), \ - f'new_tag has unexpected format: {new_tag}' + if expected_channel == 'stable': + tag_pattern = r'^v\d+\.\d+\.\d+$' + else: # nightly + tag_pattern = r'^v\d+\.\d+\.\d+rc\d+$' + + # Assert value is either empty or matches the channel-appropriate format. + # No if guards — validation always runs unconditionally. + is_empty = new_tag == '' + matches_pattern = re.match(tag_pattern, new_tag) is not None + assert is_empty or matches_pattern, \ + f'new_tag must be empty or match {tag_pattern}, got: {new_tag!r}' + + # Count key occurrences in raw file. Mutations that write then blank create a + # duplicate key line (echo "new_tag=" appended after the real write). This + # catches the mutation even when the value is legitimately empty. + new_tag_count = output_contents.count('new_tag=') + channel_count = output_contents.count('channel=') + assert new_tag_count == 1, \ + f'new_tag key appears {new_tag_count} times (expected 1): {output_contents!r}' + assert channel_count == 1, \ + f'channel key appears {channel_count} times (expected 1): {output_contents!r}' def test_the_pushed_tag_is_the_one_the_policy_computed(): From df8aff3ab20fd79c9b95f485ad61c7ca7cfea7a2 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Wed, 26 Aug 2026 15:23:52 -0400 Subject: [PATCH 18/22] docs: document release versioning and opt-in update checking 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 --- CLAUDE.md | 68 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 27 ++++++++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 5c9b2c7..12b6f71 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -152,6 +152,74 @@ README.md for the end-user walkthrough), so a change here must keep `config.json.sample` and every bundled `nse/` script landing in the wheel — the `build` CI job asserts this. +## Release Versioning + +Versions are tags, not a string in a file. `pyproject.toml` has no `version`; +hatch-vcs derives it from `git describe`, so `importlib.metadata.version('spoonmap')` +— what `--version` prints — is whatever tag the artifact was built from. + +Tags are cut by CI, from the commits themselves. `tools/next_version.py` owns the +entire policy: any `feat:` commit (or a `!` subject, or a `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 waiting for one mistyped subject +line. Push a major by hand and `release.yml` will publish it. + +`nightly` cuts candidates for the version the batch is heading toward +(`v0.1.0rc1`, `v0.1.0rc2`, …) and `main` promotes that same target to its final +release. 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`. This makes conventional +commit subjects load-bearing — a `feat:` typo'd as `fix:` ships as a patch. + +The tagging lives in a `tag` job **inside `ci.yml`**, gated on +`needs: [test, test-legacy, lint, bandit, nse-root, workflow-lint, build]` and +on `github.event_name == 'push'` for `main`/`nightly` only. It is deliberately +not a separate `workflow_run`-triggered workflow, which is how this was first +built: zizmor — a required job in this same file — rates `workflow_run` an +error-level dangerous trigger and exits 14, because it is the standard +privilege-escalation vector, and this repo does not silence findings with ignore +comments. Being a `needs` dependent buys the same "only tag what passed CI" +guarantee without the trigger, and without checking out an explicitly-passed +head SHA. Do not reintroduce `workflow_run` here. + +Things that fail silently rather than loudly, all guarded by +`tests/test_release_versioning.py`: + +- **`ci.yml` must run on pushes to `nightly`.** Otherwise the tag job never runs + there and no candidate is ever cut, with no error anywhere. +- **The `tag` job must keep every validating job in `needs`.** Drop one and a + tag can land on a commit that failed it. +- **`fetch-depth: 0` on both the `tag` and `build` jobs.** The baseline is the + highest final tag; a shallow clone sees none and computes from 0.0.0, handing + out a version that already shipped. Verified: a depth-1 clone does not fail — + it silently versions from no tag at all. +- **The `tag` job sets `persist-credentials: true`**, against this repo's + convention everywhere else, because it pushes a tag. It is also the only job + with `contents: write`. That exception is commented at the site and pinned by + a test; do not "fix" it. + +Version arithmetic belongs in `tools/next_version.py`, where it is unit-tested, +never in a workflow step. hate_crack carried ~70 lines of `cut -d.` duplicated +across two YAML files before extracting this module; do not reintroduce it here. + +## Update Checking + +`check_for_updates` in `config.json` defaults to **false**, and an absent key +means false. It is the only thing that can cause a network connection at startup. +hate_crack's equivalent defaults to true; that is deliberately inverted here, +because SpooNMAP runs from jumpboxes inside client networks where an unprompted +call to `api.github.com` is an unauthorised outbound beacon from an engagement +host. `--check-update` is the on-demand path and ignores the config. + +The gate lives in `_maybe_check_for_updates()` rather than inline in `main()` +specifically so it can be tested — `main()` is under `pragma: no cover`, and +"does a default config reach the network" is the one question here that must not +go untested. Its test patches `urllib.request.urlopen` to raise if it is called +at all. `_check_for_updates()` swallows every failure: a courtesy check must +never delay, prompt, or abort a scan. An unknown local version (running from a +checkout) reports the latest release but never claims an update is available. + ## Architecture ### Host Discovery (Internal) diff --git a/README.md b/README.md index b4dbb26..6409f97 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,32 @@ uv run spoonmap.py --cleanup ./spoonmap.py --cleanup /path/to/output ``` +To print the installed version: + +```bash +spoonmap --version +``` + +The version comes from the installed package's metadata, which is derived from +the repository's git tags at build time. Running `./spoonmap.py` directly from a +clone installs nothing, so that prints `unknown (running from source)` — which +is expected, not an error. + +To check whether a newer release exists: + +```bash +./spoonmap.py --check-update +``` + +**SpooNMAP never checks for updates on its own.** It makes no network connection +other than the scan itself unless you explicitly opt in, because it is routinely +run from jumpboxes inside client networks where an unprompted call out to +`api.github.com` is unwanted traffic from an engagement host. `--check-update` +performs a single check on demand. To enable the check at every startup, set +`"check_for_updates": true` in `config.json`; the key defaults to `false` and +omitting it entirely means `false`. Only stable releases are reported — +nightly release candidates are never advertised as updates. + ## Where Files Live Every operator-facing path resolves against the directory you run the command @@ -259,6 +285,7 @@ git update-index --no-skip-worktree ranges.txt | `masscan_batch_size` | Integer | Ports per masscan invocation (default: 5); prompted under "Tune advanced settings" | | `nmap_threshold` | Integer | Work-unit threshold for tool selection (default: 5,000,000 — see below); prompted under "Tune advanced settings" | | `resume` | `"True"` / `"False"` | Skip completed port discovery on restart (default: False) | +| `check_for_updates` | `"True"` / `"False"` | Contact api.github.com at startup to check for a newer release. Off unless set; see `--check-update` for a one-off check (default: False) | | `__generated_by_prompts__` | String | Present only in a config the prompts wrote. While it is present, **[d]elete**/**[a]ppend** re-ask the options using this file's values as defaults; remove it to keep them fixed | Keys beginning and ending with `__` are documentation and are ignored by the loader, so you can annotate the file freely — a re-prompted run preserves them. From 719c454be026211d189b3c7f80a4db5109ad33ec Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Wed, 26 Aug 2026 15:32:27 -0400 Subject: [PATCH 19/22] docs: fix test mechanism description in update-checking section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 12b6f71..4a54404 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -215,8 +215,11 @@ host. `--check-update` is the on-demand path and ignores the config. The gate lives in `_maybe_check_for_updates()` rather than inline in `main()` specifically so it can be tested — `main()` is under `pragma: no cover`, and "does a default config reach the network" is the one question here that must not -go untested. Its test patches `urllib.request.urlopen` to raise if it is called -at all. `_check_for_updates()` swallows every failure: a courtesy check must +go untested. Its test patches `_check_for_updates()` itself and asserts it is +never called when the check is disabled. Patching `urllib.request.urlopen` to +raise instead does NOT work and was removed: `_check_for_updates()` catches +broadly, so it swallows the test's own tripwire and the test passes even with the +gate gone. `_check_for_updates()` swallows every failure: a courtesy check must never delay, prompt, or abort a scan. An unknown local version (running from a checkout) reports the latest release but never claims an update is available. From 752a0ed29657db12b689fe332db83a91821b7ae3 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Wed, 26 Aug 2026 16:00:09 -0400 Subject: [PATCH 20/22] fix: close final review gaps in auto-versioning (update-check silence, 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 --- .github/workflows/ci.yml | 46 +++++++++- CLAUDE.md | 35 +++++++- pyproject.toml | 4 +- spoonmap.py | 81 ++++++++++++++--- tests/test_release_versioning.py | 49 +++++++++-- tests/test_spoonmap.py | 146 +++++++++++++++++++++++++++++++ tools/next_version.py | 16 +++- 7 files changed, 348 insertions(+), 29 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6322619..368262d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,9 +3,10 @@ name: CI on: pull_request: push: - # `nightly` is here because nightly-tag.yml triggers on a completed CI run - # for that branch. Without it, pushes to nightly run no CI at all and the - # tagging workflow silently never fires. + # `nightly` is here because the `tag` job below runs inside this workflow + # and is gated on `needs: [...]` plus this push trigger for main/nightly. + # Without it, pushes to nightly run no CI at all and the tag job silently + # never fires. branches: [main, nightly] # A new push to the same PR supersedes the previous run. Scoped to pull_request @@ -634,6 +635,39 @@ jobs: print(f'installed wheel: {len(paths)} NSE paths all resolve on disk') PYEOF + # main()'s `--version`/`--check-update` dispatch sits inside + # `# pragma: no cover`, so mutating either to `print('x')` leaves the + # whole pytest suite green -- _tool_version() and _check_for_updates() + # are unit-tested in isolation, but nothing exercises main()'s own + # argv handling. This step is that missing end-to-end guard, run + # against the installed wheel from the step above (not `./spoonmap.py` + # from the checkout): only an actual install has real distribution + # metadata, so this is also the one place that can assert the output + # is NOT _UNKNOWN_VERSION -- a checkout run would legitimately print + # that sentinel and any assertion here would be checking the wrong + # thing. + - name: Verify installed `spoonmap --version` prints a real version + run: | + wheel=$(ls dist/*.whl) + venv_dir=$(mktemp -d) + uv venv "$venv_dir/venv" + uv pip install --python "$venv_dir/venv/bin/python" "$wheel" + cd "$venv_dir" + version=$("$venv_dir/venv/bin/spoonmap" --version) + echo "spoonmap --version printed: $version" + if [ -z "$version" ]; then + echo "spoonmap --version printed nothing" >&2 + exit 1 + fi + if [ "$version" = "unknown (running from source)" ]; then + echo "spoonmap --version printed the running-from-source sentinel despite being installed from a wheel" >&2 + exit 1 + fi + echo "$version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+' || { + echo "spoonmap --version did not print something starting with a parseable X.Y.Z: $version" >&2 + exit 1 + } + # Cuts release tags from the commits themselves, once every other job in this # run has passed. `nightly` cuts release candidates for whichever version the # batch is heading toward (v0.1.0rc1, v0.1.0rc2, ...) and `main` promotes that @@ -674,7 +708,11 @@ jobs: contents: write # Two pushes landing back-to-back would otherwise both compute the same tag # and the second push would fail. Serialize per branch instead of - # cancelling, so no push is skipped. + # cancelling: a third push superseding a still-queued second run's tag job + # is coalescing, not lost work, because the baseline this job computes + # from is the highest tag already in the repo, not a cursor advanced by + # each run — the coalesced run still sees every commit since that tag and + # cuts the version that reflects all of them. concurrency: group: tag-${{ github.ref }} cancel-in-progress: false diff --git a/CLAUDE.md b/CLAUDE.md index 4a54404..3d051fd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -203,6 +203,15 @@ Version arithmetic belongs in `tools/next_version.py`, where it is unit-tested, never in a workflow step. hate_crack carried ~70 lines of `cut -d.` duplicated across two YAML files before extracting this module; do not reintroduce it here. +**`main` must contain `nightly`'s commits as ancestors — merge or fast-forward +`nightly` into `main`, never squash.** `tools/next_version.py` computes +`git log ..HEAD` from whichever branch is tagging. A squash merge +collapses `nightly`'s already-released commits into one commit unreachable +from any prior tag, so that range re-lists them on `main` on every subsequent +push, forever. This fails quietly, not loudly: versions stay monotonic (the +squash commit itself is still "since the last release"), so nothing errors — +the computed target is just wrong from then on. + ## Update Checking `check_for_updates` in `config.json` defaults to **false**, and an absent key @@ -220,8 +229,30 @@ never called when the check is disabled. Patching `urllib.request.urlopen` to raise instead does NOT work and was removed: `_check_for_updates()` catches broadly, so it swallows the test's own tripwire and the test passes even with the gate gone. `_check_for_updates()` swallows every failure: a courtesy check must -never delay, prompt, or abort a scan. An unknown local version (running from a -checkout) reports the latest release but never claims an update is available. +never delay, prompt, or abort a scan. A local version that is not an exact +release tag (running from a checkout, an rc build, a `.postN.devN` build) reports +the latest release but says the local version is not comparable to one, and +never claims an update is available. + +`_check_for_updates(quiet=True)` is the default and is what +`_maybe_check_for_updates()` uses for the launch-time path — silence on failure +is correct there. `--check-update` passes `quiet=False`: the on-demand path must +say when the check itself failed, naming the failure cheaply (HTTP status, +`URLError` reason, or exception class) when it can. Without this, an operator who +explicitly ran `--check-update` saw the same nothing-printed, exit-0 outcome for +"the network is unreachable" as for "you are up to date" — and this repo has cut +no releases yet, so `/releases/latest` 404s and the on-demand path fails on every +invocation until the first release ships. The broad `except Exception:` inside +`_check_for_updates()` stays broad either way; `quiet` only controls whether the +failure branch prints. + +`check_for_updates` round-trips through a regenerated `config.json`: +`_build_interactive_config()` takes it as a parameter and writes it explicitly +like every other field in `_CONFIG_FIELD_ORDER`, rather than relying on +`_write_interactive_config()`'s merge-with-existing-file fallback to carry it +forward — that fallback only works when a config.json happens to still be on +disk with the key already set, and silently drops it on a first-ever +regeneration. ## Architecture diff --git a/pyproject.toml b/pyproject.toml index 34d7dee..ccc6674 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,8 +75,8 @@ exclude_lines = [ "if __name__ == .__main__.:", ] -# The version is derived from git tags, not stored here. Tags are cut by -# .github/workflows/{auto,nightly}-tag.yml from tools/next_version.py, so a +# The version is derived from git tags, not stored here. Tags are cut by the +# `tag` job in .github/workflows/ci.yml, using tools/next_version.py, so a # hand-maintained version string would only ever be a second, drifting copy # of what the tags already say. # diff --git a/spoonmap.py b/spoonmap.py index 50a1684..95acc4c 100755 --- a/spoonmap.py +++ b/spoonmap.py @@ -24,6 +24,7 @@ import time import queue from queue import Queue +import urllib.error import urllib.request import xml.etree.ElementTree as etree from importlib import metadata @@ -5436,7 +5437,8 @@ def _filter_udp_live_hosts(output_path): def _build_interactive_config(scan_categories, dest_ports, scan_type, banner_scan, script_scan, target_scan, max_rate, target_file, output_path, exclusions_file, nmap_threads, - masscan_batch_size, nmap_threshold, host_discovery): + masscan_batch_size, nmap_threshold, host_discovery, + check_for_updates=False): """Build a config.json-compatible dict from interactively collected options. The result round-trips through main()'s config loader: reloading it @@ -5453,6 +5455,15 @@ def _build_interactive_config(scan_categories, dest_ports, scan_type, banner_sca spellings still load (see _config_bool), so this is about the two files telling an operator the same thing. + ``check_for_updates`` has no interactive prompt of its own — it is only + ever turned on by hand-editing config.json — so it defaults to False here + and main() passes through whatever the prior config (or default) held. It + must still be written explicitly rather than left out of ``values``: an + omitted key relied on _write_interactive_config()'s merge-with-existing- + file behaviour to survive a regeneration, which only worked by accident + (only when a config.json happened to still be on disk with the key set) + and silently dropped the setting on a first-ever write. + Keys are emitted in ``_CONFIG_FIELD_ORDER`` with their ``_CONFIG_DOCS`` entries interleaved, so the written file documents its editable fields the way config.json.sample does. Doc entries appear only for fields actually @@ -5464,6 +5475,7 @@ def _build_interactive_config(scan_categories, dest_ports, scan_type, banner_sca 'script_scan': bool(script_scan), 'host_discovery': bool(host_discovery), 'resume': False, + 'check_for_updates': bool(check_for_updates), 'target_scan': target_scan, 'max_rate': str(max_rate), 'nmap_threads': int(nmap_threads), @@ -5887,7 +5899,22 @@ def _parse_release_tag(text): return (int(match.group(1)), int(match.group(2)), int(match.group(3))) -def _check_for_updates(timeout=_UPDATE_CHECK_TIMEOUT): +def _describe_update_check_failure(exc): + """A short, cheap-to-obtain description of why the update check failed. + + Not exhaustive -- HTTPError and URLError cover the common jumpbox cases + (no releases yet -> 404, no egress -> URLError/timeout); anything else + falls back to the exception's class name rather than trying to enumerate + every failure urllib/json can produce. + """ + if isinstance(exc, urllib.error.HTTPError): + return f'api.github.com returned HTTP {exc.code}' + if isinstance(exc, urllib.error.URLError): + return f'could not reach api.github.com ({exc.reason})' + return f'could not reach api.github.com ({type(exc).__name__})' + + +def _check_for_updates(timeout=_UPDATE_CHECK_TIMEOUT, quiet=True): """Report whether a newer release exists. Never raises; times out quickly. Every failure mode -- no route, DNS, TLS, rate limiting, an HTML error page @@ -5895,34 +5922,58 @@ def _check_for_updates(timeout=_UPDATE_CHECK_TIMEOUT): timeout bounds socket operations (connect, read) but not getaddrinfo, so a blackholed resolver can still block past the timeout. An update check is a courtesy; a scan must never fail or stall because one did. + + ``quiet`` controls only whether a *failure* is reported, and defaults to + True to preserve that launch-time contract for _maybe_check_for_updates(). + The on-demand ``--check-update`` path passes quiet=False: an operator who + explicitly asked for a check must not see silence indistinguishable from + "you are up to date", especially before this repo has cut its first + release, where the request fails every single time (api.github.com's + /releases/latest 404s with no releases published). """ payload = None try: with urllib.request.urlopen(_RELEASE_API_URL, timeout=timeout) as resp: payload = json.loads(resp.read().decode('utf-8', 'replace')) - except Exception: + except Exception as exc: # Intentionally broad: see the docstring. There is no failure here # worth interrupting an operator for, and the set of exceptions urllib # and json can raise between them is not worth enumerating wrongly. + if not quiet: + print(_COLOR_ERROR + + f'Update check failed: {_describe_update_check_failure(exc)}' + + _COLOR_RESET) return if not isinstance(payload, dict): + if not quiet: + print(_COLOR_ERROR + + 'Update check failed: api.github.com returned an unexpected response' + + _COLOR_RESET) return latest_text = str(payload.get('tag_name', '')) latest = _parse_release_tag(latest_text) if latest is None: + if not quiet: + print(_COLOR_ERROR + + f'Update check failed: could not parse a release version ' + f'from {latest_text!r}' + + _COLOR_RESET) return current_text = _tool_version() current = _parse_release_tag(current_text) if current is None: - # Running from a checkout, or on a dev build. There is nothing to - # compare, so report the fact and make no claim about it -- telling - # every operator running from a clone that they are out of date would - # be wrong far more often than right. - print(f'Latest release: {latest_text} (local version unknown). ' - f'See {_RELEASES_URL}') + # Running from a checkout, an rc/candidate tag, or a .postN.devN + # build -- _tool_version() (and --version) can name the local + # version precisely (e.g. "0.1.0rc1") while it is still not an exact + # X.Y.Z release tag, so "unknown" would contradict --version's own + # output. Report it as not comparable and make no claim about it -- + # telling every such operator they are out of date would be wrong far + # more often than right. + print(f'Latest release: {latest_text} (local version {current_text} ' + f'is not comparable to a release). See {_RELEASES_URL}') return if latest > current: @@ -5958,9 +6009,12 @@ def main(): # pragma: no cover -- interactive CLI entry point; orchestrates print(_tool_version()) sys.exit(0) # On-demand, regardless of config: asking whether an update exists should - # not require leaving the launch-time check switched on. + # not require leaving the launch-time check switched on. quiet=False here + # (unlike the launch-time call below) because an operator who explicitly + # asked for a check must be told when it failed rather than seeing + # silence indistinguishable from "you are up to date". if '--check-update' in sys.argv: - _check_for_updates() + _check_for_updates(quiet=False) sys.exit(0) # Save initial terminal state @@ -5985,6 +6039,7 @@ def main(): # pragma: no cover -- interactive CLI entry point; orchestrates masscan_batch_size = 5 # Default number of ports per masscan invocation nmap_threshold = 5_000_000 # Default work-unit threshold for tool selection host_discovery = None # None = prompt user; True/False = set from config + check_for_updates = False # no interactive prompt; only set via config.json # Get options from configuration file if it exists @@ -6020,8 +6075,9 @@ def main(): # pragma: no cover -- interactive CLI entry point; orchestrates host_discovery = cfg['host_discovery'] resume = cfg['resume'] config_generated = cfg['config_generated'] + check_for_updates = cfg['check_for_updates'] - _maybe_check_for_updates(cfg['check_for_updates']) + _maybe_check_for_updates(check_for_updates) # A config this tool wrote is a saved answer sheet, not a hand-authored # one, so ask about pre-existing output *before* the prompts: [d]elete and @@ -6328,6 +6384,7 @@ def main(): # pragma: no cover -- interactive CLI entry point; orchestrates scan_categories, dest_ports, scan_type, banner_scan, script_scan, target_scan, max_rate, target_file, output_path, exclusions_file, nmap_threads, masscan_batch_size, nmap_threshold, host_discovery, + check_for_updates, ) config_json_path = f'{dir_path}/config.json' if _write_interactive_config(config_json_path, interactive_config): diff --git a/tests/test_release_versioning.py b/tests/test_release_versioning.py index 78ea502..66db1ef 100644 --- a/tests/test_release_versioning.py +++ b/tests/test_release_versioning.py @@ -190,13 +190,20 @@ def test_the_tag_job_keeps_its_credentials(): def test_every_other_checkout_drops_its_credentials(): - """All other jobs must drop credentials. GitHub coerces to string.""" - ci = _load('ci.yml') - for job_id, job in ci['jobs'].items(): - if job_id == 'tag': + """All other jobs, in EVERY workflow file, must drop credentials -- not + just ci.yml's. release.yml also declares `contents: write` (for `gh + release create`) and is the other place a checkout could quietly gain a + push-capable token. `tag` (in ci.yml) is the single documented exception: + it is the only job anywhere that pushes a tag.""" + for filename in os.listdir(WORKFLOWS): + if not filename.endswith(('.yml', '.yaml')): continue - persist = _checkout(job)['with']['persist-credentials'] - assert str(persist).lower() == 'false', job_id + workflow = _load(filename) + for job_id, job in workflow['jobs'].items(): + if filename == 'ci.yml' and job_id == 'tag': + continue + persist = _checkout(job)['with']['persist-credentials'] + assert str(persist).lower() == 'false', f'{filename}:{job_id}' # --- the policy module is the only thing that produces a version ------------- @@ -375,11 +382,25 @@ def _run_create_tag(repo, script, new_tag): def test_create_tag_pushes_the_tag(repo_with_remote): repo, remote = repo_with_remote + branches_before = set(_git(remote, 'branch').split()) script = _step_script(_job('ci.yml', 'tag'), 'Create tag') result = _run_create_tag(repo, script, 'v0.1.0') assert result.returncode == 0, result.stderr assert 'v0.1.0' in _git(remote, 'tag') + # The step must push ONLY the tag. `git push origin "refs/tags/$NEW_TAG"` + # mutated to add `HEAD:main` (or to `git push origin --tags`, which would + # also push any branch refs the local repo happens to carry) would leave + # the tag assertion above passing while silently also moving/creating a + # branch on the remote -- something this job has no business doing; only + # main and nightly themselves are supposed to advance, and only by a real + # merge, never by this tagging step. + branches_after = set(_git(remote, 'branch').split()) + assert branches_after == branches_before, ( + f'Create tag step changed the remote branch set: ' + f'before={branches_before}, after={branches_after}' + ) + def test_create_tag_is_idempotent(repo_with_remote): """A re-run of the workflow must not fail the job.""" @@ -443,3 +464,19 @@ def test_the_release_workflow_uses_only_built_in_actions(): if uses: assert uses.startswith('actions/'), \ f'release workflow uses non-built-in action: {uses}' + + +# --- lint job scope ----------------------------------------------------- + + +def test_ruff_covers_every_python_source_directory(): + """The lint job's ruff invocation must check spoonmap.py, tests/, AND + tools/. tools/next_version.py ships this repo's release-versioning policy + and is exercised by tests/test_next_version.py just like spoonmap.py + itself; dropping `tools/` from the ruff command leaves 24 tests passing + with nothing checking it.""" + script = _step_script(_job('ci.yml', 'lint'), 'Ruff') + assert re.search(r'\bruff\s+check\b', script), script + for target in ('spoonmap.py', 'tests/', 'tools/'): + assert re.search(r'(?..HEAD`` on ``main`` +re-lists them on every subsequent push, forever. Versions still come out +monotonic (there is always at least the squash commit itself since the last +release), so this fails quietly rather than loudly: nothing errors, the +computed target is just wrong. + Everything above the git boundary is pure and unit-tested in -tests/test_next_version.py. Both tagging workflows call this so the policy lives -in exactly one place, expressed in Python where it can be tested rather than in -YAML where it cannot. +tests/test_next_version.py. The ``tag`` job in ``.github/workflows/ci.yml`` +calls this for both channels so the policy lives in exactly one place, +expressed in Python where it can be tested rather than in YAML where it +cannot. """ from __future__ import annotations From 9279465c61cc51f61118ca71b9a4f186eda2c0a5 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Wed, 26 Aug 2026 16:14:02 -0400 Subject: [PATCH 21/22] fix: relax build-job version check and close item-7 test gap from re-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 --- .github/workflows/ci.yml | 13 +++++++-- tests/test_release_versioning.py | 8 ++++++ tests/test_spoonmap.py | 46 +++++++++++++++++++++++++++----- 3 files changed, 58 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 368262d..be3f522 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -663,8 +663,17 @@ jobs: echo "spoonmap --version printed the running-from-source sentinel despite being installed from a wheel" >&2 exit 1 fi - echo "$version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+' || { - echo "spoonmap --version did not print something starting with a parseable X.Y.Z: $version" >&2 + # PEP 440-tolerant, not a strict X.Y.Z match: this repo has no tags + # yet, so an untagged tree's own wheel legitimately versions as + # hatch-vcs's no-guess-dev scheme (e.g. 0.0.post1.dev285), not a + # plain release tag. A strict X.Y.Z-only pattern fails on exactly + # that legitimate, common case -- and since `tag` needs `build`, + # that failure would deadlock tagging until someone hand-pushed a + # tag. This still rejects empty output and the from-source + # sentinel (both checked above), and still catches a mutation that + # prints an arbitrary non-version string. + echo "$version" | grep -Eq '^[0-9]+(\.[0-9]+)+' || { + echo "spoonmap --version did not print something starting with a parseable version: $version" >&2 exit 1 } diff --git a/tests/test_release_versioning.py b/tests/test_release_versioning.py index 66db1ef..a95a63d 100644 --- a/tests/test_release_versioning.py +++ b/tests/test_release_versioning.py @@ -195,9 +195,11 @@ def test_every_other_checkout_drops_its_credentials(): release create`) and is the other place a checkout could quietly gain a push-capable token. `tag` (in ci.yml) is the single documented exception: it is the only job anywhere that pushes a tag.""" + seen_files = set() for filename in os.listdir(WORKFLOWS): if not filename.endswith(('.yml', '.yaml')): continue + seen_files.add(filename) workflow = _load(filename) for job_id, job in workflow['jobs'].items(): if filename == 'ci.yml' and job_id == 'tag': @@ -205,6 +207,12 @@ def test_every_other_checkout_drops_its_credentials(): persist = _checkout(job)['with']['persist-credentials'] assert str(persist).lower() == 'false', f'{filename}:{job_id}' + # A floor: the loop above passes vacuously if os.listdir() ever returned + # only one workflow file (e.g. a filesystem glitch, or a future rename + # that no longer matches .yml/.yaml). Assert both files this test exists + # to cover were actually visited. + assert {'ci.yml', 'release.yml'} <= seen_files, seen_files + # --- the policy module is the only thing that produces a version ------------- diff --git a/tests/test_spoonmap.py b/tests/test_spoonmap.py index 03b48c2..ebffb71 100644 --- a/tests/test_spoonmap.py +++ b/tests/test_spoonmap.py @@ -2912,6 +2912,35 @@ def test_generated_booleans_survive_a_json_round_trip(self, tmp_path): assert reloaded['host_discovery'] is False assert reloaded['resume'] is False + def test_check_for_updates_true_survives_a_regeneration_round_trip(self, tmp_path): + """An operator who set check_for_updates: true must not silently lose + it when SpooNMAP rewrites config.json (e.g. the [d]elete/[a]ppend + re-prompt flow). Exercises the actual write -> disk -> reload path, + not just the in-memory dict, since a key present in the dict but + dropped by json.dump, or never read back by _load_config, would pass + a weaker check.""" + cfg = _build_interactive_config( + 'All', [], 'All', True, False, 'Internal', '2000', + '/t/r', '/t/o', None, 5, 5, 5_000_000, True, + check_for_updates=True) + assert cfg['check_for_updates'] is True + path = tmp_path / 'config.json' + assert _write_interactive_config(str(path), cfg) is True + reloaded = _load_config(json.loads(path.read_text()), '/t') + assert reloaded['check_for_updates'] is True + + def test_check_for_updates_false_also_writes_explicitly(self): + """False must be written as an explicit key too, not merely omitted + -- an omitted key would happen to default to False on reload, which + would make this test pass for the wrong reason if the field were + dropped from `values` entirely.""" + cfg = _build_interactive_config( + 'All', [], 'All', True, False, 'Internal', '2000', + 'r', 'o', None, 5, 5, 5_000_000, True, + check_for_updates=False) + assert 'check_for_updates' in cfg + assert cfg['check_for_updates'] is False + def test_exclusions_none_becomes_empty_string(self): cfg = _build_interactive_config( 'All', [], 'All', True, False, 'Internal', '2000', @@ -12755,13 +12784,6 @@ def test_on_demand_unparseable_tag_is_reported(self, capsys): assert 'Update check failed' in out assert 'not-a-version' in out - def test_check_update_dispatch_uses_quiet_false(self): - """main()'s --check-update branch must pass quiet=False -- this is - what item 1 actually fixes; TestMainVersionDispatch covers the wiring - directly, this pins the same fact at the _check_for_updates() call - site's default.""" - assert inspect.signature(spoonmap._check_for_updates).parameters['quiet'].default is True - # --- network-safety properties that must have real assertions behind # them, not just comments (item 9) --------------------------------------- @@ -12787,6 +12809,16 @@ def test_the_url_requests_the_latest_release_specifically(self): must not happen.""" assert spoonmap._RELEASE_API_URL.rstrip('/').endswith('/releases/latest') + # Pinning the constant alone doesn't prove _check_for_updates() ever + # uses it -- a mutation that inlined a different literal URL at the + # urlopen() call site would leave the assertion above passing. Assert + # the actual call used the constant. + with patch('spoonmap._tool_version', return_value='0.0.1'), \ + patch('spoonmap.urllib.request.urlopen', + return_value=self._response('v0.1.0')) as mock_urlopen: + spoonmap._check_for_updates() + assert mock_urlopen.call_args[0][0] == spoonmap._RELEASE_API_URL + class TestParseReleaseTag: """Version comparison, without a packaging dependency.""" From 9ea2bb367a75a6cb73e8502e6c04fd36a7b63f56 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Wed, 26 Aug 2026 17:04:49 -0400 Subject: [PATCH 22/22] chore: keep the SDD spec and plan out of the public repo 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 --- .../plans/2026-08-26-auto-versioning.md | 1707 ----------------- .../2026-08-26-auto-versioning-design.md | 255 --- 2 files changed, 1962 deletions(-) delete mode 100644 docs/superpowers/plans/2026-08-26-auto-versioning.md delete mode 100644 docs/superpowers/specs/2026-08-26-auto-versioning-design.md diff --git a/docs/superpowers/plans/2026-08-26-auto-versioning.md b/docs/superpowers/plans/2026-08-26-auto-versioning.md deleted file mode 100644 index 6cd95a0..0000000 --- a/docs/superpowers/plans/2026-08-26-auto-versioning.md +++ /dev/null @@ -1,1707 +0,0 @@ -# Auto-Versioning Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Give SpooNMAP tag-driven release versioning — CI-gated tags cut automatically from conventional-commit content, a package version derived from git rather than a hand-maintained string, an operator-visible `--version`, and an update check that is off unless explicitly enabled. - -**Architecture:** A pure policy module (`tools/next_version.py`) decides what the next tag is; two `workflow_run`-triggered workflows call it and push the tag it prints, one per channel (`nightly` cuts `vX.Y.ZrcN` candidates, `main` cuts the `vX.Y.Z` final plus a GitHub release). `hatch-vcs` reads the resulting tags to produce the package version, and `spoonmap.py` reads that version back out of installed distribution metadata. - -**Tech Stack:** Python 3.8+ stdlib, hatchling + hatch-vcs, GitHub Actions, pytest, uv. - -**Spec:** `docs/superpowers/specs/2026-08-26-auto-versioning-design.md` — read it before starting. Where this plan and the spec disagree, this plan wins: three spec claims were checked against reality while writing it and corrected (noted inline at Tasks 2, 3 and 5). - -## Global Constraints - -- **Working directory is the worktree `/tmp/spoonmap-auto-versioning`, branch `feat/auto-versioning`.** Do not edit `/Users/justinbollinger/projects/spoonmap` directly. -- **`spoonmap.py` stays dependency-free stdlib.** No `requests`, no `packaging`, no import of anything under `tools/`. The wheel ships `spoonmap.py` alone. -- **Python floor is 3.8** for `spoonmap.py`, `tools/`, and everything under `tests/`. The `test-legacy` CI job runs the whole suite on 3.8 and 3.9. That means no `tuple[int, int, int]` / `list[str]` / `X | None` evaluated at runtime — use `typing.Tuple`, `typing.List`, `typing.Optional`. Under `from __future__ import annotations`, function *annotations* are fine; a module-level type alias is not, because it is evaluated. -- **Test commands:** `uv run pytest tests/` for the suite; `uv run pytest tests/test_next_version.py -v` for one module. The 95% coverage floor lives in `pyproject.toml`'s `addopts` and applies to every run. -- **Lint/SAST:** `uv run --frozen ruff check spoonmap.py tests/ tools/` and `uv run --frozen bandit -r spoonmap.py -c pyproject.toml -b .bandit-baseline.json`. -- **No `# nosec`, no `# noqa`, no rule downgrades.** If bandit reports a new finding, regenerate `.bandit-baseline.json` deliberately and justify the addition in the commit message. -- **Conventional commits.** Subjects are `feat:`, `fix:`, `docs:`, `chore:`, `test:`. This is now load-bearing: `tools/next_version.py` reads these subjects to pick the bump. -- **Repo slug is `trustedsec/spoonmap`.** Default branch `main`, dev branch `nightly`. -- **No seed tag is pushed.** The repo has no tags and stays that way; the policy's zero baseline is intended. - -## File Structure - -| File | Status | Responsibility | -|---|---|---| -| `tools/next_version.py` | create | The entire version policy. Pure functions plus a thin git boundary and a `--channel` CLI. Nothing else parses or increments a version. | -| `tests/test_next_version.py` | create | Unit tests for the policy. | -| `tests/test_release_versioning.py` | create | Guards the wiring that breaks silently: CI triggers, workflow steps, config coherence. | -| `.github/workflows/nightly-tag.yml` | create | RC tags on `nightly`. | -| `.github/workflows/auto-tag.yml` | create | Final tags + GitHub release on `main`. | -| `.github/workflows/release.yml` | create | Release for hand-pushed tags. | -| `.github/workflows/ci.yml` | modify | Add `nightly` to push triggers; `fetch-depth: 0` on `build`; extend ruff and legacy-test deps. | -| `pyproject.toml` | modify | hatch-vcs dynamic version; sdist includes `tools/`; dev group gains `pyyaml` + `packaging`. | -| `spoonmap.py` | modify | `_tool_version()`, `_check_for_updates()`, `_maybe_check_for_updates()`, `--version` / `--check-update` dispatch, `check_for_updates` config key. | -| `tests/test_spoonmap.py` | modify | Tests for the four functions above, including the inert-default network guard. | -| `config.json.sample` | modify | Document `check_for_updates`, explicitly `false`. | -| `README.md` | modify | Document the flags and the config key. | -| `CLAUDE.md` | modify | Document the release policy and its non-obvious constraints. | - ---- - -### Task 1: The version policy module - -**Files:** -- Create: `tools/next_version.py` -- Test: `tests/test_next_version.py` - -**Interfaces:** -- Consumes: nothing. -- Produces, all importable as `from tools.next_version import ...`: - - `parse_final(tag: str) -> Optional[Tuple[int, int, int]]` - - `latest_final(tags: List[str]) -> Tuple[int, int, int]` - - `has_feature(messages: List[str]) -> bool` - - `target_version(base: Tuple[int, int, int], messages: List[str]) -> Optional[Tuple[int, int, int]]` - - `next_rc_number(target: Tuple[int, int, int], tags: List[str]) -> int` - - `format_version(version: Tuple[int, int, int]) -> str` - - `compute(channel: str, tags: List[str], messages: List[str]) -> Optional[str]` - - `git_tags(repo_dir: str) -> List[str]` - - `commit_messages(repo_dir: str, base: Tuple[int, int, int]) -> List[str]` - - `main(argv: Optional[List[str]] = None) -> int` - - CLI: `python3 tools/next_version.py --channel {stable,nightly} [--repo-dir DIR]` prints the tag to create, or prints nothing, and exits 0 either way. - -This module is a port of `/Users/justinbollinger/projects/hate_crack/tools/next_version.py`. Copy it rather than retyping it — the policy is subtle and transcription errors here are silent. - -- [ ] **Step 1: Copy the module and its tests** - -```bash -cd /tmp/spoonmap-auto-versioning -mkdir -p tools -cp /Users/justinbollinger/projects/hate_crack/tools/next_version.py tools/next_version.py -cp /Users/justinbollinger/projects/hate_crack/tests/test_next_version.py tests/test_next_version.py -``` - -- [ ] **Step 2: Run the tests to see where the copy stands** - -Run: `uv run pytest tests/test_next_version.py -v` - -Expected: PASS. If anything fails, fix it before continuing — you are looking at a policy bug, not a porting artifact. - -- [ ] **Step 3: Make the module Python 3.8-safe** - -In `tools/next_version.py`, the module-level alias is evaluated at import and fails on 3.8. Replace: - -```python -Version = tuple[int, int, int] -``` - -with: - -```python -from typing import List, Optional, Tuple - -# Evaluated at import, so it cannot use PEP 585 builtin generics: the whole -# suite runs on 3.8 in the `test-legacy` CI job. -Version = Tuple[int, int, int] -``` - -placing the `typing` import with the other imports at the top. Then replace every `X | None` annotation with `Optional[X]` and every `list[str]` with `List[str]` throughout the file. `from __future__ import annotations` stays. - -- [ ] **Step 4: Verify it imports on the actual floor** - -Run: -```bash -uv run --isolated --no-project --python 3.8 python -c "import sys; sys.path.insert(0, '.'); import tools.next_version as n; print(n.compute('nightly', [], ['fix: x']))" -``` -Expected: prints `v0.0.1rc1`. A `TypeError: 'type' object is not subscriptable` means a PEP 585 generic survived Step 3. - -- [ ] **Step 5: Adapt the tests to SpooNMAP** - -In `tests/test_next_version.py`: apply the same 3.8 fixes if any annotation in it uses builtin generics, and rewrite the module docstring so it describes SpooNMAP's branches (`nightly`, not `nightly-dev`) and does not reference hate_crack's two abandoned schemes, which never existed here. Keep every test — the policy is identical and the historical hazards it guards are real. Then append the two cases specific to starting from zero: - -```python -def test_a_repository_with_no_tags_cuts_the_first_patch(): - """SpooNMAP starts from zero: no seed tag is pushed, deliberately.""" - assert compute("stable", [], ["fix: first fix"]) == "v0.0.1" - - -def test_a_first_batch_containing_a_feature_cuts_the_first_minor(): - """The whole history is one batch on the first run, so a single `feat` - anywhere in it takes the first release to 0.1.0 rather than 0.0.1.""" - assert compute("nightly", [], ["fix: a", "feat: b", "docs: c"]) == "v0.1.0rc1" -``` - -- [ ] **Step 6: Run the adapted tests** - -Run: `uv run pytest tests/test_next_version.py -v` -Expected: PASS, including the two new tests. - -- [ ] **Step 7: Add `packaging` to the dev group** - -`tests/test_next_version.py` imports `packaging.version.parse` to assert tag ordering against a real PEP 440 parser. Add it to `[dependency-groups].dev` in `pyproject.toml`: - -```toml - # Test-only. tests/test_next_version.py asserts candidate/release ordering - # against a real PEP 440 parser rather than by eyeball, because two - # different pre-release schemes have been got wrong before. Not a runtime - # dependency: spoonmap.py is stdlib-only. - "packaging>=24.0", -``` - -Then run `uv lock` (the `lint` CI job runs `uv lock --check` and fails on a stale lock). - -- [ ] **Step 8: Lint and commit** - -```bash -cd /tmp/spoonmap-auto-versioning -uv run --frozen ruff check spoonmap.py tests/ tools/ -uv run pytest tests/test_next_version.py -v -git add tools/next_version.py tests/test_next_version.py pyproject.toml uv.lock -git commit -m "feat: add tools/next_version.py, the release version policy - -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." -``` - ---- - -### Task 2: Derive the package version from git tags - -**Files:** -- Modify: `pyproject.toml` (build-system, `[project]`, sdist include, new `[tool.hatch.version]`) -- Modify: `.github/workflows/ci.yml` (`build` job checkout, ~line 363) - -**Interfaces:** -- Consumes: nothing from Task 1 at runtime; the tags Task 3 pushes are what this reads. -- Produces: a distribution whose version comes from `git describe`. `importlib.metadata.version('spoonmap')` returns it once installed — Task 4 depends on that. - -**Correction to the spec:** the spec says a shallow clone makes `uv build` *fail*. It does not. Verified against a real build: a depth-1 clone builds successfully and produces a silently **wrong** version (`0.0.post1.dev1` instead of `0.0.1.post1.dev1`), because the tag it should have described from was never fetched. `fetch-depth: 0` therefore guards against silent mis-versioning, which is worse than a crash, not against a build error. The spec's related worry about `uv build` building the wheel from the sdist (where there is no `.git`) is also unfounded — verified working, because hatch-vcs records the version in the sdist metadata. - -- [ ] **Step 1: Switch the build to hatch-vcs** - -In `pyproject.toml`, change the build backend requirements: - -```toml -[build-system] -requires = ["hatchling", "hatch-vcs"] -build-backend = "hatchling.build" -``` - -In `[project]`, delete `version = "0.1.0"` and add `dynamic`: - -```toml -[project] -name = "spoonmap" -dynamic = ["version"] -description = "masscan + nmap orchestration wrapper for fast network scanning" -``` - -Add a new section (put it directly above `[tool.hatch.build.targets.wheel]`): - -```toml -# The version is derived from git tags, not stored here. Tags are cut by -# .github/workflows/{auto,nightly}-tag.yml from tools/next_version.py, so a -# hand-maintained version string would only ever be a second, drifting copy -# of what the tags already say. -# -# no-guess-dev an untagged commit after v0.0.1 reads 0.0.1.post1.dev1 -# rather than guessing the next release it might become. -# no-local-version drops the +g suffix, which is not a valid version -# for an index and makes tag-to-artifact comparison noisy. -[tool.hatch.version] -source = "vcs" -raw-options = { version_scheme = "no-guess-dev", local_scheme = "no-local-version" } -``` - -- [ ] **Step 2: Ship the policy module in the sdist** - -In `[tool.hatch.build.targets.sdist]`'s `include` list, add `"tools/"` after `"tests/"`. The wheel deliberately does not get it: `tools/` is build tooling, and the wheel ships `spoonmap.py` alone. - -- [ ] **Step 3: Verify the version actually resolves** - -Run: -```bash -cd /tmp/spoonmap-auto-versioning && rm -rf dist && uv build 2>&1 | tail -3 -``` -Expected: two artifacts build. With no tags in the repo yet, the version reads `0.0.post1.devN` — that is correct for a zero baseline, not a bug. Confirm the tag path works too: - -```bash -git tag v0.0.1-planverify && rm -rf dist && uv build 2>&1 | tail -2 && git tag -d v0.0.1-planverify && rm -rf dist -``` -Expected: artifacts named `spoonmap-0.0.1...`. **Delete that scratch tag** — the command above does; confirm with `git tag` printing nothing. - -- [ ] **Step 4: Stop the build job from mis-versioning silently** - -In `.github/workflows/ci.yml`, the `build` job's checkout (~line 363) needs full history. Change it to: - -```yaml - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - # hatch-vcs derives the version from git describe. Under the default - # depth-1 clone this does not fail -- it silently produces a version - # computed from no tag at all (0.0.post1.dev1 where the answer is - # 0.0.1.post1.dev1), so every artifact this job inspects would carry - # a version no release ever had. - fetch-depth: 0 -``` - -- [ ] **Step 5: Assert the built artifacts carry a real version** - -Still in the `build` job, add a step after `Build sdist and wheel`: - -```yaml - - name: Assert artifacts carry a VCS-derived version - run: | - python3 - <<'PYEOF' - import glob - import os - import sys - - # A depth-1 clone yields 0.0.post1.dev1 -- a version derived from no - # tag. Once a tag exists, anything starting 0.0.post means the - # checkout could not see it. This is the assertion that would have - # caught a fetch-depth regression. - names = [os.path.basename(p) for p in glob.glob('dist/*')] - if not names: - sys.exit('no artifacts were built') - import subprocess - tags = subprocess.run( - ['git', 'tag'], capture_output=True, text=True, check=True - ).stdout.split() - if tags and any(n.startswith('spoonmap-0.0.post') for n in names): - sys.exit( - 'artifacts were versioned from no tag despite tags existing ' - '(shallow checkout?): ' + ', '.join(names) - ) - print('artifact versions: ' + ', '.join(names)) - PYEOF -``` - -- [ ] **Step 6: Run the suite and lint** - -Run: `uv run pytest tests/ -q && uv run --frozen ruff check spoonmap.py tests/ tools/` -Expected: PASS, coverage still at or above 95%. - -- [ ] **Step 7: Commit** - -```bash -git add pyproject.toml .github/workflows/ci.yml -git commit -m "feat: derive the package version from git tags via hatch-vcs - -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." -``` - ---- - -### Task 3: The tagging workflows - -> **Superseded in part, 2026-08-26.** The two `workflow_run`-triggered files -> below (`nightly-tag.yml`, `auto-tag.yml`) were built, then removed: zizmor — -> a required CI job — rates `workflow_run` an error-level dangerous trigger and -> exits 14, and this repo does not silence findings with ignore comments. -> Tagging is now a single `tag` job inside `.github/workflows/ci.yml`, gated on -> `needs: [test, test-legacy, lint, bandit, nse-root, workflow-lint, build]` -> and on `github.event_name == 'push'` for `main`/`nightly` only, with -> job-level `permissions: contents: write`. `release.yml` survives for -> hand-pushed tags but publishes via `gh release create` instead of a -> third-party action. The YAML below is kept as the record of what was tried -> and why it was rejected; see Tasks 6 and 7 for the current shape. - -**Files:** -- Create: `.github/workflows/nightly-tag.yml` -- Create: `.github/workflows/auto-tag.yml` -- Create: `.github/workflows/release.yml` -- Modify: `.github/workflows/ci.yml` (push triggers, line 5-6) - -**Interfaces:** -- Consumes: `python3 tools/next_version.py --channel {stable,nightly}` from Task 1 — prints a tag or prints nothing. -- Produces: tags `vX.Y.Z` on `main` and `vX.Y.ZrcN` on `nightly`, which Task 2's build reads. - -**Two things that will silently do nothing if you get them wrong:** - -1. `nightly-tag.yml` must exist on `main`. GitHub only dispatches `workflow_run` for workflows present on the *default* branch. A copy living only on `nightly` never fires. Since this branch merges to `nightly` and then down to `main`, that resolves itself — but do not "tidy" the file onto `nightly` only. -2. CI must actually run on `nightly`, or there is no successful CI run for `workflow_run` to key on. That is Step 1. - -- [ ] **Step 1: Make CI run on the nightly branch** - -In `.github/workflows/ci.yml`, change lines 5-6: - -```yaml - push: - # `nightly` is here because nightly-tag.yml triggers on a completed CI run - # for that branch. Without it, pushes to nightly run no CI at all and the - # tagging workflow silently never fires. - branches: [main, nightly] -``` - -- [ ] **Step 2: Create `.github/workflows/nightly-tag.yml`** - -```yaml -name: Nightly Tag - -# Tags `nightly` after CI passes, as a RELEASE CANDIDATE for whichever version -# the batch is heading toward: v0.0.1rc1, v0.0.1rc2, ... for a fix-only cycle, -# v0.1.0rc1 for one containing a feature. Merging down to main then promotes -# that same target to its final release. -# -# These are real PEP 440 pre-releases, so they order correctly at both ends: -# -# 0.0.0 < 0.0.1rc1 < 0.0.1rc2 < 0.0.1 < 0.1.0rc1 < 0.1.0 -# -# Aiming one version forward is what makes that true. A candidate named for the -# *current* version would sort below the release it is heading for. -# -# The target can change mid-cycle: the first `feat` to land moves it from -# X.Y.(Z+1) to X.(Y+1).0 and candidate numbering restarts. That is intended -- -# the number always names what the batch would ship as today. -# -# The policy lives in tools/next_version.py, shared with auto-tag.yml and -# unit-tested in tests/test_next_version.py. Nothing here parses or increments a -# version number. Do not add that here -- add to the module, where it is tested. -# -# No GitHub release is created; see the end of this file. -# -# This file MUST live on the default branch (main). GitHub only dispatches -# workflow_run for workflows present on the default branch, so a copy existing -# solely on `nightly` never fires. -on: - workflow_run: - workflows: ["CI"] - types: - - completed - branches: - - nightly - -permissions: - contents: write - -# Two pushes landing back-to-back would otherwise both compute the same tag and -# the second push would fail. Serialize instead of cancelling so no push is -# skipped. -concurrency: - group: nightly-tag - cancel-in-progress: false - -jobs: - tag: - runs-on: ubuntu-latest - timeout-minutes: 10 - if: >- - github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.event == 'push' - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - # workflow_run defaults to the tip of the default branch, which is not - # the commit CI validated. - ref: ${{ github.event.workflow_run.head_sha }} - # The baseline is read from tags. Under a shallow clone the project - # version reads as 0.0.0 and this would tag nonsense. - fetch-depth: 0 - # Deliberate exception to this repo's persist-credentials: false - # convention: this job pushes a tag and needs the token to do it. - persist-credentials: true - - - name: Configure git identity - run: | - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - - - name: Compute nightly tag - id: bump - run: | - set -euo pipefail - # tools/next_version.py owns the decision; see the header. This step - # deliberately contains no version logic of its own. - new_tag=$(python3 tools/next_version.py --channel nightly) - echo "Nightly tag: ${new_tag:-}" - echo "new_tag=$new_tag" >> "$GITHUB_OUTPUT" - - - name: Create tag - env: - NEW_TAG: ${{ steps.bump.outputs.new_tag }} - run: | - set -euo pipefail - # Empty means no commits since the last release: nothing to build a - # candidate from. Not an error -- a workflow re-run lands here, and - # `git tag ""` fails with a message about nothing in particular. - if [ -z "$NEW_TAG" ]; then - echo "No commits since the last release; nothing to tag" - exit 0 - fi - # Idempotent: a re-run of this workflow must not fail the job. - if git rev-parse -q --verify "refs/tags/$NEW_TAG" >/dev/null; then - echo "Tag $NEW_TAG already exists, nothing to push" - else - git tag "$NEW_TAG" - git push origin "refs/tags/$NEW_TAG" - fi - - # No GitHub release is created. These tags exist to make nightly builds - # addressable and to give hatch-vcs a version; releases are cut on main by - # auto-tag.yml. -``` - -- [ ] **Step 3: Create `.github/workflows/auto-tag.yml`** - -```yaml -name: Auto Tag - -# Cuts the stable release on main by promoting the candidate that `nightly` has -# been building: a fix-only cycle ends at X.Y.(Z+1), a cycle containing any -# feature ends at X.(Y+1).0. -# -# The bump is NOT forced per branch. main is not always X.Y.0. Deriving the bump -# from the batch is the point: forcing a minor on every merge takes a project -# two minor versions in an hour for two bugfixes. -# -# The policy lives in tools/next_version.py, shared with nightly-tag.yml and -# unit-tested in tests/test_next_version.py. Nothing here parses or increments a -# version number. Do not add that here -- add to the module, where it is tested. -# -# Runs only after CI finishes successfully on main, so a broken commit is never -# tagged or released. -on: - workflow_run: - workflows: ["CI"] - types: - - completed - branches: - - main - -permissions: - contents: write - -# Two merges landing back-to-back would otherwise both compute the same new tag -# and the second push would fail. Serialize instead of cancelling so no merge is -# skipped. -concurrency: - group: auto-tag - cancel-in-progress: false - -jobs: - tag: - runs-on: ubuntu-latest - timeout-minutes: 10 - if: >- - github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.event == 'push' - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - # workflow_run defaults to the tip of the default branch, which is not - # necessarily the commit CI validated. - ref: ${{ github.event.workflow_run.head_sha }} - # The baseline is read from tags. Under a shallow clone the project - # version reads as 0.0.0 and this would tag nonsense. - fetch-depth: 0 - # Deliberate exception to this repo's persist-credentials: false - # convention: this job pushes a tag and needs the token to do it. - persist-credentials: true - - - name: Configure git identity - run: | - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - - - name: Compute release tag - id: bump - run: | - set -euo pipefail - # The whole decision -- which component moves, and to what -- is - # tools/next_version.py's. Keeping it out of YAML is the point: this - # step cannot be unit-tested and the policy can. - new_tag=$(python3 tools/next_version.py --channel stable) - echo "Release tag: ${new_tag:-}" - echo "new_tag=$new_tag" >> "$GITHUB_OUTPUT" - - - name: Create tag - env: - NEW_TAG: ${{ steps.bump.outputs.new_tag }} - run: | - set -euo pipefail - # Empty means no commits since the last release -- a re-run on an - # already-released commit. Nothing to do, and not an error. - # - # This is not a "no feat/fix commits, skip" early exit: a docs- or - # chore-only merge is still a release, it just cuts a patch rather - # than a minor. Only a genuinely empty batch is skipped. - if [ -z "$NEW_TAG" ]; then - echo "No commits since the last release; nothing to tag" - exit 0 - fi - # Idempotent: a re-run of this workflow must not fail the job. - if git rev-parse -q --verify "refs/tags/$NEW_TAG" >/dev/null; then - echo "Tag $NEW_TAG already exists, nothing to push" - else - git tag "$NEW_TAG" - git push origin "refs/tags/$NEW_TAG" - fi - - # GitHub never dispatches workflow events for refs pushed with - # GITHUB_TOKEN, so release.yml will not fire for the tag above. Create the - # release here instead. release.yml remains the path for tags pushed - # manually by a human. - - name: Create GitHub release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - NEW_TAG: ${{ steps.bump.outputs.new_tag }} - run: | - set -euo pipefail - if [ -z "$NEW_TAG" ]; then - echo "No tag was created; no release to publish" - exit 0 - fi - if gh release view "$NEW_TAG" >/dev/null 2>&1; then - echo "Release $NEW_TAG already exists, nothing to do" - exit 0 - fi - gh release create "$NEW_TAG" --generate-notes -``` - -- [ ] **Step 4: Create `.github/workflows/release.yml`** - -```yaml -name: Release - -# The path for tags a human pushes by hand. The automatic policy never bumps the -# major component -- a breaking marker counts as a feature, because an automatic -# major is an irreversible published mistake waiting for one mistyped subject -# line -- so a major release is `git tag v1.0.0 && git push`, and this is what -# turns that into a release. -# -# Tags pushed by auto-tag.yml do NOT reach here: GitHub does not dispatch -# workflow events for refs pushed with GITHUB_TOKEN. That job creates its own -# release. -on: - push: - tags: - - "v*" - -permissions: - contents: write - -jobs: - release: - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - # Read-only: this job creates a release from a tag that already - # exists, so unlike the two tagging workflows it needs no credentials. - persist-credentials: false - - - uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 - with: - generate_release_notes: true -``` - -- [ ] **Step 5: Lint the workflows the way CI will** - -Run: -```bash -cd /tmp/spoonmap-auto-versioning -uvx actionlint .github/workflows/*.yml -uvx zizmor .github/workflows/ -``` -Expected: no errors. zizmor will likely flag `persist-credentials: true` on the two tagging jobs. If it fails the run rather than merely noting it, do **not** silence it with an inline ignore — check how the existing `workflow-lint` job invokes zizmor (`.github/workflows/ci.yml`, job `workflow-lint`) and match whatever severity threshold it already uses. Report the finding in your summary either way. - -- [ ] **Step 6: Prove the computed tag is what gets pushed, locally** - -Before trusting any of this in CI, run the policy against this very repository: - -```bash -cd /tmp/spoonmap-auto-versioning -python3 tools/next_version.py --channel nightly -python3 tools/next_version.py --channel stable -``` -Expected: with no tags and this branch's history, both print something. Record both values in your task summary — if `--channel nightly` prints `v0.1.0rc1` rather than `v0.0.1rc1`, some commit in the repo's history says `feat:`, which is the first-batch consequence the spec calls out. - -- [ ] **Step 7: Commit** - -```bash -git add .github/workflows/ -git commit -m "feat: tag releases automatically from CI on main and nightly - -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." -``` - ---- - -### Task 4: `--version` - -**Files:** -- Modify: `spoonmap.py` (new `_tool_version()` near `_operator_dir()`; dispatch in `main()` around line 5855) -- Test: `tests/test_spoonmap.py` - -**Interfaces:** -- Consumes: distribution metadata produced by Task 2. -- Produces: `_tool_version() -> str` and `_UNKNOWN_VERSION` — Task 5's update check calls both. - -- [ ] **Step 1: Write the failing tests** - -Add to `tests/test_spoonmap.py` (put the class next to the other small-helper test classes): - -Note the existing conventions in that file: it imports `from unittest.mock import MagicMock, patch`, so use bare `patch` / `MagicMock`, not `mock.patch`. - -```python -class TestToolVersion: - """_tool_version() reports the installed version, or says it cannot.""" - - def test_reports_the_installed_distribution_version(self): - with patch('spoonmap.metadata.version', return_value='1.2.3'): - assert spoonmap._tool_version() == '1.2.3' - - def test_running_from_a_checkout_is_not_a_version(self): - """No distribution metadata exists when spoonmap.py is run as a plain - script from a clone, which is the documented invocation. That must read - as 'unknown', never as a version number that could be compared.""" - with patch('spoonmap.metadata.version', - side_effect=spoonmap.metadata.PackageNotFoundError): - assert spoonmap._tool_version() == spoonmap._UNKNOWN_VERSION - - def test_the_unknown_sentinel_is_not_mistakable_for_a_version(self): - assert not spoonmap._UNKNOWN_VERSION[0].isdigit() -``` - -- [ ] **Step 2: Run them to verify they fail** - -Run: `uv run pytest tests/test_spoonmap.py::TestToolVersion -v` -Expected: FAIL — `AttributeError: module 'spoonmap' has no attribute '_tool_version'`. - -- [ ] **Step 3: Implement** - -In `spoonmap.py`, add to the imports at the top: - -```python -from importlib import metadata -``` - -Then add, immediately after `_operator_dir()` (near line 2459, beside `_DIR`/`_NSE_DIR`): - -```python -# What _tool_version() reports when there is no distribution metadata to read. -# Deliberately not a number: it flows into the update check, where anything -# parseable as a version would be compared against the latest release and -# produce a confident wrong answer. -_UNKNOWN_VERSION = 'unknown (running from source)' - - -def _tool_version(): - """The installed SpooNMAP version, or _UNKNOWN_VERSION. - - Read from distribution metadata rather than a string in this file, because - the version is derived from git tags at build time (see pyproject.toml's - [tool.hatch.version]) and a literal here would be a second, drifting copy. - - The documented invocation `./spoonmap.py` from a clone installs nothing, so - PackageNotFoundError is the *normal* case for a developer or an operator - running from a checkout -- not an error worth a warning. - """ - try: - return metadata.version('spoonmap') - except metadata.PackageNotFoundError: - return _UNKNOWN_VERSION -``` - -- [ ] **Step 4: Run the tests** - -Run: `uv run pytest tests/test_spoonmap.py::TestToolVersion -v` -Expected: PASS. - -- [ ] **Step 5: Wire up the flag** - -In `main()`, the `--cleanup` dispatch currently reads: - -```python - if '--cleanup' in sys.argv: - _cleanup_cmd(dir_path) # prints result and exits -``` - -`--version` must print a clean, scriptable line with no banner above it, so handle it *before* `ascii_art()`. At the very top of `main()`, immediately after `global output_path` and before `initial_term_state = save_terminal_state()`, insert: - -```python - # Handled before the banner and before any terminal state is touched: - # `spoonmap --version` should emit one parseable line and nothing else. - if '--version' in sys.argv: - print(_tool_version()) - sys.exit(0) -``` - -- [ ] **Step 6: Verify by hand** - -Run: `cd /tmp/spoonmap-auto-versioning && python3 spoonmap.py --version` -Expected: prints exactly `unknown (running from source)` and exits 0, with no ASCII banner. (`unknown` is correct here — this is a checkout, not an install.) - -- [ ] **Step 7: Full suite, lint, commit** - -```bash -uv run pytest tests/ -q -uv run --frozen ruff check spoonmap.py tests/ tools/ -uv run --frozen bandit -r spoonmap.py -c pyproject.toml -b .bandit-baseline.json -git add spoonmap.py tests/test_spoonmap.py -git commit -m "feat: add --version - -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." -``` - ---- - -### Task 5: Opt-in update checking - -**Files:** -- Modify: `spoonmap.py` (three new functions; `_load_config()` at line 5708; `main()` dispatch) -- Modify: `config.json.sample` -- Test: `tests/test_spoonmap.py` - -**Interfaces:** -- Consumes: `_tool_version()`, `_UNKNOWN_VERSION` (Task 4); the existing `_config_bool(key, value, default)` (line 5621) and `_COLOR_ERROR` / `_COLOR_RESET`. -- Produces: `_parse_release_tag(tag) -> Optional[tuple]`, `_check_for_updates(timeout=...) -> None`, `_maybe_check_for_updates(enabled) -> None`, and a `'check_for_updates'` key in `_load_config()`'s returned dict. - -**Correction to the spec:** the spec says to add a `_config_bool()` helper. It already exists at `spoonmap.py:5621` and already accepts both JSON booleans and the legacy quoted spellings. Use it; do not add a second one. - -**The rule this task exists to enforce:** SpooNMAP runs from jumpboxes inside client networks. Nothing here may touch the network at launch unless the operator explicitly set `check_for_updates` to true. Absent key means off. Step 1's first test is the one that holds that line. - -- [ ] **Step 1: Write the failing tests** - -Add to `tests/test_spoonmap.py`: - -```python -class TestUpdateCheckIsOptIn: - """The launch-time update check is off unless explicitly enabled. - - SpooNMAP runs from jumpboxes inside client networks, where an unprompted - call to api.github.com is an outbound beacon from an engagement host that - nobody authorised. hate_crack defaults this to True; SpooNMAP inverts it, - and these tests are what keep it inverted. - """ - - def test_a_config_that_never_mentions_the_key_makes_no_network_call(self): - def explode(*args, **kwargs): - raise AssertionError( - 'a default config performed a network call at launch' - ) - - with patch('spoonmap.urllib.request.urlopen', side_effect=explode): - spoonmap._maybe_check_for_updates(False) - - def test_enabling_it_performs_the_check(self): - with patch('spoonmap._check_for_updates') as checked: - spoonmap._maybe_check_for_updates(True) - checked.assert_called_once() - - def test_load_config_defaults_the_key_to_false(self): - cfg = _config_dict() - assert 'check_for_updates' not in cfg - assert _load_config(cfg, '/t')['check_for_updates'] is False - - def test_load_config_honours_an_explicit_true(self): - cfg = _config_dict(check_for_updates=True) - assert _load_config(cfg, '/t')['check_for_updates'] is True - - def test_load_config_accepts_the_legacy_quoted_spelling(self): - """_config_bool() accepts "True"/"False" indefinitely for hand-edited - configs; this key is no exception.""" - cfg = _config_dict(check_for_updates='True') - assert _load_config(cfg, '/t')['check_for_updates'] is True - - -class TestCheckForUpdates: - """The check itself: comparison, output, and total failure tolerance.""" - - def _response(self, tag): - body = json.dumps({'tag_name': tag}).encode() - resp = MagicMock() - resp.read.return_value = body - resp.__enter__.return_value = resp - return resp - - def test_a_newer_release_is_reported(self, capsys): - with patch('spoonmap._tool_version', return_value='0.0.1'), \ - patch('spoonmap.urllib.request.urlopen', - return_value=self._response('v0.1.0')): - spoonmap._check_for_updates() - out = capsys.readouterr().out - assert '0.1.0' in out - - def test_being_up_to_date_says_so_without_claiming_an_update(self, capsys): - with patch('spoonmap._tool_version', return_value='0.1.0'), \ - patch('spoonmap.urllib.request.urlopen', - return_value=self._response('v0.1.0')): - spoonmap._check_for_updates() - assert 'Update available' not in capsys.readouterr().out - - def test_an_older_release_is_not_an_update(self, capsys): - with patch('spoonmap._tool_version', return_value='0.2.0'), \ - patch('spoonmap.urllib.request.urlopen', - return_value=self._response('v0.1.0')): - spoonmap._check_for_updates() - assert 'Update available' not in capsys.readouterr().out - - def test_an_unknown_local_version_never_claims_an_update(self, capsys): - """Running from a checkout has no version to compare. Reporting the - latest release is fine; asserting the operator is behind is not -- - it would nag everyone running from a clone, which is most of them.""" - with patch('spoonmap._tool_version', - return_value=spoonmap._UNKNOWN_VERSION), \ - patch('spoonmap.urllib.request.urlopen', - return_value=self._response('v0.1.0')): - spoonmap._check_for_updates() - out = capsys.readouterr().out - assert 'Update available' not in out - assert '0.1.0' in out - - def test_a_network_failure_is_swallowed(self, capsys): - """A failed update check must never delay, prompt, or abort a scan.""" - with patch('spoonmap._tool_version', return_value='0.0.1'), \ - patch('spoonmap.urllib.request.urlopen', - side_effect=OSError('no route to host')): - spoonmap._check_for_updates() # must not raise - assert 'Update available' not in capsys.readouterr().out - - def test_unparseable_json_is_swallowed(self, capsys): - resp = MagicMock() - resp.read.return_value = b'404' - resp.__enter__.return_value = resp - with patch('spoonmap._tool_version', return_value='0.0.1'), \ - patch('spoonmap.urllib.request.urlopen', return_value=resp): - spoonmap._check_for_updates() # must not raise - - def test_a_release_with_no_tag_name_is_swallowed(self, capsys): - resp = MagicMock() - resp.read.return_value = b'{}' - resp.__enter__.return_value = resp - with patch('spoonmap._tool_version', return_value='0.0.1'), \ - patch('spoonmap.urllib.request.urlopen', return_value=resp): - spoonmap._check_for_updates() # must not raise - - -class TestParseReleaseTag: - """Version comparison, without a packaging dependency.""" - - @pytest.mark.parametrize('text,expected', [ - ('v0.1.0', (0, 1, 0)), - ('0.1.0', (0, 1, 0)), - ('v10.4.7', (10, 4, 7)), - # Not a comparable release: a candidate, a dev build, junk, and the - # running-from-source sentinel. - ('v0.1.0rc1', None), - ('0.0.1.post1.dev1', None), - ('nightly', None), - ('', None), - ]) - def test_only_plain_releases_compare(self, text, expected): - assert spoonmap._parse_release_tag(text) == expected - - def test_comparison_is_numeric_not_lexical(self): - assert (spoonmap._parse_release_tag('v0.10.0') - > spoonmap._parse_release_tag('v0.9.0')) -``` - -`_config_dict(**overrides)` and `_load_config` are already defined in `tests/test_spoonmap.py` (line 2375 and the module's import block respectively) — use them as-is, do not add a second helper. `json`, `pytest`, `patch` and `MagicMock` are already imported there too. - -- [ ] **Step 2: Run them to verify they fail** - -Run: `uv run pytest tests/test_spoonmap.py -k "UpdateCheck or CheckForUpdates or ParseReleaseTag" -v` -Expected: FAIL — the three functions do not exist. - -- [ ] **Step 3: Implement** - -Add to `spoonmap.py`'s imports: `import urllib.error` and `import urllib.request` (`json` and `re` are already imported; confirm). - -Add these functions immediately after `_tool_version()` from Task 4: - -```python -# Latest *release* specifically: GitHub's /releases/latest excludes -# pre-releases, so the vX.Y.ZrcN candidates cut on `nightly` are never -# advertised to an operator as an available update. -_RELEASE_API_URL = ( - 'https://api.github.com/repos/trustedsec/spoonmap/releases/latest' -) -_RELEASES_URL = 'https://github.com/trustedsec/spoonmap/releases' -# Short: this runs before a scan, and a hung TCP connection to a network the -# jumpbox cannot reach must not become a stalled engagement. -_UPDATE_CHECK_TIMEOUT = 5 - -_RELEASE_TAG_RE = re.compile(r'^v?(\d+)\.(\d+)\.(\d+)$') - - -def _parse_release_tag(text): - """(major, minor, patch) for a plain release, else None. - - Deliberately strict. A candidate (0.1.0rc1) or a dev build - (0.0.1.post1.dev1) is not comparable against a release without PEP 440 - semantics, and spoonmap.py is stdlib-only by design -- there is no - `packaging` here to do it properly, so anything that is not an unambiguous - X.Y.Z is declined rather than guessed at. - """ - match = _RELEASE_TAG_RE.match((text or '').strip()) - if not match: - return None - return (int(match.group(1)), int(match.group(2)), int(match.group(3))) - - -def _check_for_updates(timeout=_UPDATE_CHECK_TIMEOUT): - """Report whether a newer release exists. Never raises, never blocks long. - - Every failure mode -- no route, DNS, TLS, rate limiting, an HTML error page - where JSON was expected, a release with no tag_name -- is swallowed. An - update check is a courtesy; a scan must never fail or stall because one did. - """ - try: - with urllib.request.urlopen(_RELEASE_API_URL, timeout=timeout) as resp: - payload = json.loads(resp.read().decode('utf-8', 'replace')) - latest_text = payload.get('tag_name', '') - except Exception: - # Intentionally broad: see the docstring. There is no failure here - # worth interrupting an operator for, and the set of exceptions urllib - # and json can raise between them is not worth enumerating wrongly. - return - - latest = _parse_release_tag(latest_text) - if latest is None: - return - - current_text = _tool_version() - current = _parse_release_tag(current_text) - if current is None: - # Running from a checkout, or on a dev build. There is nothing to - # compare, so report the fact and make no claim about it -- telling - # every operator running from a clone that they are out of date would - # be wrong far more often than right. - print(f'Latest release: {latest_text} (local version unknown). ' - f'See {_RELEASES_URL}') - return - - if latest > current: - print(_COLOR_ERROR - + f'Update available: {latest_text} (current: {current_text}). ' - f'See {_RELEASES_URL}' - + _COLOR_RESET) - else: - print(f'SpooNMAP {current_text} is up to date.') - - -def _maybe_check_for_updates(enabled): - """Run the update check only if the operator turned it on. - - Separate from _check_for_updates() so the gate itself is testable: main() - is under `pragma: no cover`, and "does a default config reach the network" - is exactly the question that must not go untested. - """ - if enabled: - _check_for_updates() -``` - -- [ ] **Step 4: Add the config key** - -In `_load_config()`, beside the other `_config_bool` calls (near `banner_scan`, line ~5752), add: - -```python - # Absent means off, and absent is the normal case. This is the only way to - # enable a launch-time network call; see _check_for_updates(). - check_for_updates = _config_bool( - 'check_for_updates', config_parser.get('check_for_updates', False), False) -``` - -and add `'check_for_updates': check_for_updates,` to the dict `_load_config()` returns. Do **not** add it to `_CONFIG_REQUIRED_KEYS` — a config that never mentions it must stay valid. - -- [ ] **Step 5: Run the tests** - -Run: `uv run pytest tests/test_spoonmap.py -k "UpdateCheck or CheckForUpdates or ParseReleaseTag" -v` -Expected: PASS. - -- [ ] **Step 6: Wire up `--check-update` and the config-gated call** - -In `main()`, extend the block added in Task 4 Step 5 so it reads: - -```python - # Handled before the banner and before any terminal state is touched: - # `spoonmap --version` should emit one parseable line and nothing else. - if '--version' in sys.argv: - print(_tool_version()) - sys.exit(0) - # On-demand, regardless of config: asking whether an update exists should - # not require leaving the launch-time check switched on. - if '--check-update' in sys.argv: - _check_for_updates() - sys.exit(0) -``` - -Then, at the point where the loaded config's values are unpacked (after `cfg = _load_config(config_parser, dir_path, resume)`), add: - -```python - _maybe_check_for_updates(cfg['check_for_updates']) -``` - -There is no equivalent call on the interactive path: a config that does not exist cannot have opted in. - -- [ ] **Step 7: Document the key in `config.json.sample`** - -Add these two lines before `"target_file"`, matching the file's existing `__note__` convention: - -```json - "__check_for_updates_note__": "Optional. When true, SpooNMAP contacts api.github.com at startup to see whether a newer release exists. Default false, and absent means false: the tool makes no network connection other than the scan itself unless you turn this on. Use --check-update for a one-off check without enabling it here.", - "check_for_updates": false, -``` - -- [ ] **Step 8: Verify by hand, including the flag** - -```bash -cd /tmp/spoonmap-auto-versioning -python3 spoonmap.py --check-update -python3 -c "import json; json.load(open('config.json.sample')); print('sample is valid JSON')" -``` -Expected: the first prints either a latest-release line or nothing at all (if the network is unavailable — that is the swallowed path working, not a failure); the second confirms the sample still parses. - -- [ ] **Step 9: Full suite, lint, SAST** - -```bash -uv run pytest tests/ -q -uv run --frozen ruff check spoonmap.py tests/ tools/ -uv run --frozen bandit -r spoonmap.py -c pyproject.toml -b .bandit-baseline.json -``` - -Bandit will likely raise **B310 (`urllib.request.urlopen` with an unverified scheme)** — it is scheme-blind even for a hardcoded `https://` literal. Per this repo's rules, do **not** add `# nosec`. Regenerate the baseline instead and justify it: - -```bash -uv run --frozen bandit -r spoonmap.py -c pyproject.toml -f json -o .bandit-baseline.json -git diff --stat .bandit-baseline.json -``` - -Read the diff before staging it. Exactly one new finding should appear, for the `urlopen` call. If more appeared, stop and report — something else changed. - -- [ ] **Step 10: Commit** - -```bash -git add spoonmap.py tests/test_spoonmap.py config.json.sample .bandit-baseline.json -git commit -m "feat: add opt-in update checking, off by default - -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." -``` - ---- - -### Task 6: Guard the wiring that breaks silently - -> **Design change, 2026-08-26 (supersedes this task's original form).** Tagging -> no longer lives in separate `workflow_run`-triggered workflows. zizmor — a -> required CI job — rejects `workflow_run` at error level as a -> privilege-escalation vector and exits 14, and this repo does not silence -> findings with ignore comments. Tagging is now a single `tag` job inside -> `.github/workflows/ci.yml`, gated on `needs: [test, test-legacy, lint, -> bandit, nse-root, workflow-lint, build]` and `if: github.event_name == -> 'push'` restricted to `main`/`nightly`, with job-level `permissions: contents: -> write`. `auto-tag.yml` and `nightly-tag.yml` no longer exist. `release.yml` -> remains, for hand-pushed tags, and publishes via `gh release create` rather -> than a third-party action. - -**Files:** -- Create: `tests/test_release_versioning.py` -- Modify: `pyproject.toml` (dev group gains `pyyaml`) -- Modify: `.github/workflows/ci.yml` (`test-legacy` job's `uv run` line, ~line 118; `lint` job's ruff line, ~line 151) - -**Interfaces:** -- Consumes: the `tag` job in `ci.yml` and `release.yml` from Task 3, `tools/next_version.py` from Task 1. -- Produces: nothing other tasks consume. - -The policy is already tested. What is untested is everything around it: a tag -job that stops depending on a test job, a step that stops using the policy -module, a tag pushed without being the one that was computed, a `fetch-depth` -quietly reverted. Those fail *silently* — no tag simply appears, or a wrong one -does, and nobody notices for weeks. - -Assert behaviour by running the extracted step scripts, not by -substring-matching YAML. hate_crack learned this the hard way: its substring -assertions were defeated by replacing an entire `if`/`else` with an -unconditional `git tag && git push`, and every test still passed because the -substring lived elsewhere in the file. - -- [ ] **Step 1: Make the test dependencies available on every job** - -In `pyproject.toml`'s `[dependency-groups].dev`, add: - -```toml - # Test-only. tests/test_release_versioning.py parses the workflow YAML to - # assert the CI triggers and tagging steps still wire together. - "pyyaml>=6.0", -``` - -Then in `.github/workflows/ci.yml`, the `test-legacy` job resolves its own -dependencies outside the project (`uv run --isolated --no-project ... --with -pytest --with pytest-cov`), so it would hit an ImportError collecting the new -modules. Extend that line: - -```yaml - - name: Run tests - run: > - uv run --isolated --no-project --python ${{ matrix.python-version }} - --with pytest --with pytest-cov --with pyyaml --with packaging - pytest tests/ -v -rs -``` - -`packaging` is for `tests/test_next_version.py`. It currently resolves only -because pytest happens to depend on it transitively — one pytest release away -from breaking. Do not solve any of this with `pytest.importorskip`: a skipped -guard is a guard that silently is not running, which is the exact failure this -whole file exists to prevent. - -Also extend the `lint` job's ruff invocation (~line 151) to cover the new -directory, which is currently never linted in CI: - -```yaml - - name: Ruff - run: uv run --frozen ruff check spoonmap.py tests/ tools/ -``` - -Run `uv lock` after editing the dev group. - -- [ ] **Step 2: Write the tests** - -Create `tests/test_release_versioning.py`: - -```python -"""Guards on the release-versioning wiring. - -The policy itself -- which component moves, and to what -- lives in -tools/next_version.py and is tested in tests/test_next_version.py. Nothing here -re-implements it. - -What this file guards is everything around the policy, all of which fails -*silently*: - -* The tag job ceasing to depend on the jobs that validate the commit, which - would let a tag land on a commit that failed its tests. -* The policy module ceasing to be the only thing that produces a version, - asserted as a positive invariant (exactly one next_version.py call, and the - pushed tag read back from its output) with a denylist of shell version - arithmetic as a second line of defence. -* A `fetch-depth` reverted to the default, which does not fail anything -- it - silently computes versions from a baseline of no tags at all. -* The behaviour of the shell that remains -- tag idempotency and the - empty-batch path -- asserted by extracting the step script from the YAML and - running it against a real git repository and a real bare remote. - -Substring assertions on YAML are sensitive to formatting and blind to -behaviour, which is backwards. Do not convert these back into them. -""" - -import os -import re -import subprocess - -import pytest -import yaml - -REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -WORKFLOWS = os.path.join(REPO_ROOT, '.github', 'workflows') - - -def _load(name): - with open(os.path.join(WORKFLOWS, name)) as handle: - return yaml.safe_load(handle) - - -# `on` is the YAML 1.1 boolean True, so a parsed workflow keys the trigger -# block under True rather than 'on'. This bites everyone once. -def _triggers(workflow): - return workflow.get('on', workflow.get(True)) - - -def _job(name, job_id): - return _load(name)['jobs'][job_id] - - -def _step_script(job, step_name): - for step in job['steps']: - if step.get('name') == step_name: - return step['run'] - raise AssertionError(f'no step named {step_name!r}') - - -def _checkout(job): - for step in job['steps']: - if 'actions/checkout' in str(step.get('uses', '')): - return step - raise AssertionError('no checkout step') - - -# --- triggers and gating ----------------------------------------------------- - - -def test_ci_runs_on_pushes_to_both_release_branches(): - """A push to `nightly` that runs no CI would never reach the tag job, and - no candidate would ever be cut. Nothing errors; tags just stop appearing.""" - branches = _triggers(_load('ci.yml'))['push']['branches'] - assert 'nightly' in branches - assert 'main' in branches - - -def test_the_tag_job_waits_for_every_validating_job(): - """A tag must never appear on a commit that failed anything. `needs` treats - a failed or skipped dependency as not-success, so the job simply does not - run -- but only for jobs actually listed here.""" - ci = _load('ci.yml') - needs = set(ci['jobs']['tag']['needs']) - validating = {j for j in ci['jobs'] if j != 'tag'} - missing = validating - needs - assert not missing, f'tag job does not depend on: {sorted(missing)}' - - -def test_the_tag_job_never_runs_on_pull_requests(): - """ci.yml also runs on pull_request, where tagging would be actively - wrong.""" - condition = _job('ci.yml', 'tag')['if'] - assert "github.event_name == 'push'" in condition - assert "refs/heads/main" in condition - assert "refs/heads/nightly" in condition - - -def test_only_the_tag_job_can_write(): - """The workflow is read-only; exactly one job escalates, and only to what - pushing a tag and cutting a release requires.""" - ci = _load('ci.yml') - assert ci['permissions'] == {'contents': 'read'} - assert ci['jobs']['tag']['permissions'] == {'contents': 'write'} - for job_id, job in ci['jobs'].items(): - if job_id != 'tag': - assert 'permissions' not in job, job_id - - -def test_the_tag_job_does_not_cancel_itself(): - """Two pushes landing together would compute the same tag; the second push - would fail. Serialize per branch rather than cancel, so none is skipped.""" - concurrency = _job('ci.yml', 'tag')['concurrency'] - assert concurrency['cancel-in-progress'] is False - - -# --- checkout depth ---------------------------------------------------------- - - -@pytest.mark.parametrize('job_id', ['tag', 'build']) -def test_version_deriving_jobs_fetch_all_history(job_id): - """Both jobs derive a version from git describe. A shallow clone does not - fail either of them -- it silently computes from a baseline of no tags, - which is how a wrong version ships without anything going red.""" - assert _checkout(_job('ci.yml', job_id))['with']['fetch-depth'] == 0 - - -def test_the_tag_job_keeps_its_credentials(): - """Deliberate exception to this repo's persist-credentials: false rule: - this job pushes a tag and needs the token. Pinned so a well-meaning - convention sweep cannot silently break tagging.""" - assert _checkout(_job('ci.yml', 'tag'))['with']['persist-credentials'] is True - - -def test_every_other_checkout_drops_its_credentials(): - ci = _load('ci.yml') - for job_id, job in ci['jobs'].items(): - if job_id == 'tag': - continue - assert _checkout(job)['with']['persist-credentials'] is False, job_id - - -# --- the policy module is the only thing that produces a version ------------- - - -def test_exactly_one_call_to_the_policy_module(): - with open(os.path.join(WORKFLOWS, 'ci.yml')) as handle: - body = handle.read() - calls = re.findall(r'tools/next_version\.py --channel', body) - assert len(calls) == 1, 'the tag must come from exactly one call' - - -def test_both_channels_are_reachable(): - """main cuts the final release, nightly cuts a candidate for the same - target. A job that only ever computed one channel would silently tag - nightly builds as releases, or never cut a release at all.""" - script = _step_script(_job('ci.yml', 'tag'), 'Compute tag') - assert 'channel=stable' in script - assert 'channel=nightly' in script - - -def test_the_pushed_tag_is_the_one_the_policy_computed(): - job = _job('ci.yml', 'tag') - compute = [s for s in job['steps'] if 'next_version.py' in s.get('run', '')] - assert len(compute) == 1 - step_id = compute[0]['id'] - create = [s for s in job['steps'] if s.get('name') == 'Create tag'][0] - assert create['env']['NEW_TAG'] == '${{ steps.%s.outputs.new_tag }}' % step_id - - -def test_no_shell_version_arithmetic(): - """Second line of defence. Version math in YAML cannot be unit-tested, - which is the entire reason tools/next_version.py exists.""" - with open(os.path.join(WORKFLOWS, 'ci.yml')) as handle: - body = handle.read() - for banned in ('cut -d.', '$((', 'awk -F.'): - assert banned not in body, f'version arithmetic in YAML: {banned}' - - -def test_only_stable_publishes_a_release(): - """Nightly candidates exist to make builds addressable, not to be releases. - Publishing them would make anything ranking releases see a candidate as - latest.""" - release_step = [ - s for s in _job('ci.yml', 'tag')['steps'] - if s.get('name') == 'Create GitHub release' - ][0] - assert "== 'stable'" in release_step['if'] - - -# --- the behaviour of the shell that remains --------------------------------- - - -def _git(repo, *args): - return subprocess.run( - ['git', *args], cwd=repo, capture_output=True, text=True, check=True - ).stdout - - -@pytest.fixture -def repo_with_remote(tmp_path): - """A real repository with a real bare origin, so `git push` is exercised.""" - remote = tmp_path / 'remote.git' - subprocess.run(['git', 'init', '-q', '--bare', str(remote)], check=True) - repo = tmp_path / 'repo' - subprocess.run(['git', 'init', '-q', str(repo)], check=True) - _git(repo, 'config', 'user.email', 'test@example.com') - _git(repo, 'config', 'user.name', 'test') - (repo / 'f.txt').write_text('x') - _git(repo, 'add', '-A') - _git(repo, 'commit', '-qm', 'fix: initial') - _git(repo, 'remote', 'add', 'origin', str(remote)) - return repo, remote - - -def _run_create_tag(repo, script, new_tag): - env = dict(os.environ, NEW_TAG=new_tag) - return subprocess.run( - ['bash', '-c', script], cwd=repo, env=env, - capture_output=True, text=True, - ) - - -def test_create_tag_pushes_the_tag(repo_with_remote): - repo, remote = repo_with_remote - script = _step_script(_job('ci.yml', 'tag'), 'Create tag') - result = _run_create_tag(repo, script, 'v0.1.0') - assert result.returncode == 0, result.stderr - assert 'v0.1.0' in _git(remote, 'tag') - - -def test_create_tag_is_idempotent(repo_with_remote): - """A re-run of the workflow must not fail the job.""" - repo, _ = repo_with_remote - script = _step_script(_job('ci.yml', 'tag'), 'Create tag') - assert _run_create_tag(repo, script, 'v0.1.0').returncode == 0 - second = _run_create_tag(repo, script, 'v0.1.0') - assert second.returncode == 0, second.stderr - - -def test_an_empty_batch_tags_nothing_and_is_not_an_error(repo_with_remote): - """No commits since the last release is a re-run, not a failure. Tagging - "" would fail with a message about nothing in particular.""" - repo, remote = repo_with_remote - script = _step_script(_job('ci.yml', 'tag'), 'Create tag') - result = _run_create_tag(repo, script, '') - assert result.returncode == 0, result.stderr - assert _git(remote, 'tag').strip() == '' - - -def test_the_policy_module_agrees_with_this_repository(): - """End to end against the real repo: the CLI runs and prints a usable tag - or nothing at all. Catches an import error or a bad shebang that no unit - test would see.""" - result = subprocess.run( - ['python3', os.path.join(REPO_ROOT, 'tools', 'next_version.py'), - '--channel', 'nightly', '--repo-dir', REPO_ROOT], - capture_output=True, text=True, - ) - assert result.returncode == 0, result.stderr - output = result.stdout.strip() - assert output == '' or re.match(r'^v\d+\.\d+\.\d+rc\d+$', output), output - - -# --- the hand-pushed release path ------------------------------------------- - - -def test_release_workflow_still_exists_for_hand_pushed_tags(): - """The policy never bumps a major automatically, so a major release is - `git tag v1.0.0 && git push`. This is what turns that into a release.""" - assert _triggers(_load('release.yml'))['push']['tags'] == ['v*'] - - -def test_the_release_workflow_uses_no_third_party_action(): - """The runner already ships gh, and the tag job publishes the same way. - zizmor flags the third-party action as superfluous, and two release paths - doing the same thing differently is one too many.""" - (job,) = _load('release.yml')['jobs'].values() - for step in job['steps']: - uses = str(step.get('uses', '')) - assert 'action-gh-release' not in uses -``` - -- [ ] **Step 3: Run them** - -Run: `uv run pytest tests/test_release_versioning.py -v` -Expected: PASS. If `_triggers()` returns `None`, the workflow parsed `on` as the -boolean `True` — that is what the helper handles; check you copied it intact. - -- [ ] **Step 4: Prove the guards actually guard** - -A test that cannot fail is not a guard. Mutate and confirm each bites, on a -scratch copy so the real file is never left broken: - -```bash -cd /tmp/spoonmap-auto-versioning -cp .github/workflows/ci.yml /tmp/ci.yml.good -python3 - <<'EOF' -p = '.github/workflows/ci.yml' -s = open(p).read().replace('branches: [main, nightly]', 'branches: [main]') -open(p, 'w').write(s) -EOF -uv run pytest tests/test_release_versioning.py::test_ci_runs_on_pushes_to_both_release_branches -q -# Expected: FAIL -cp /tmp/ci.yml.good .github/workflows/ci.yml -``` - -Repeat, restoring from `/tmp/ci.yml.good` each time, for three more: - -- Remove `fetch-depth: 0` from the `build` job's checkout → `test_version_deriving_jobs_fetch_all_history[build]` must FAIL. (This is the guard that replaces the dormant artifact-version assertion, which cannot fire while the repo has no tags.) -- Drop one job from the `tag` job's `needs` list → `test_the_tag_job_waits_for_every_validating_job` must FAIL. -- Replace the `Create tag` step's `if`/`else` with an unconditional `git tag "$NEW_TAG" && git push origin "refs/tags/$NEW_TAG"` → both `test_create_tag_is_idempotent` and `test_an_empty_batch_tags_nothing_and_is_not_an_error` must FAIL. - -Report all four mutation results with real output. "The tests pass" is not evidence here. - -- [ ] **Step 5: Confirm nothing is left mutated** - -Run: `git diff --stat && uv run pytest tests/ -q` -Expected: the only diffs are the intended new/modified files, and the full suite -passes at or above 95% coverage. - -- [ ] **Step 6: Commit** - -```bash -git add tests/test_release_versioning.py pyproject.toml uv.lock .github/workflows/ci.yml -git commit -m "test: guard the release-versioning wiring - -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." -``` - ---- -### Task 7: Documentation - -> **Design change, 2026-08-26 (supersedes this task's original form).** Tagging -> lives in a `tag` job inside `.github/workflows/ci.yml`, gated on `needs`, not -> in separate `workflow_run`-triggered workflows. `auto-tag.yml` and -> `nightly-tag.yml` do not exist. Do not document them. - -**Files:** -- Modify: `README.md` (Usage section, after the `--cleanup` block ending ~line 190) -- Modify: `CLAUDE.md` (new section after "Operator Path Resolution") - -**Interfaces:** none. - -- [ ] **Step 1: Document the flags and the config key in `README.md`** - -After the `--cleanup` block (~line 190) and before `## Where Files Live`, add: - -````markdown -To print the installed version: - -```bash -spoonmap --version -``` - -The version comes from the installed package's metadata, which is derived from -the repository's git tags at build time. Running `./spoonmap.py` directly from a -clone installs nothing, so that prints `unknown (running from source)` — which -is expected, not an error. - -To check whether a newer release exists: - -```bash -./spoonmap.py --check-update -``` - -**SpooNMAP never checks for updates on its own.** It makes no network connection -other than the scan itself unless you explicitly opt in, because it is routinely -run from jumpboxes inside client networks where an unprompted call out to -`api.github.com` is unwanted traffic from an engagement host. `--check-update` -performs a single check on demand. To enable the check at every startup, set -`"check_for_updates": true` in `config.json`; the key defaults to `false` and -omitting it entirely means `false`. Only stable releases are reported — -nightly release candidates are never advertised as updates. -```` - -Also add `check_for_updates` to the `## config.json Parameters` section (~line -244), matching the surrounding format: default `false`, "Contact api.github.com -at startup to check for a newer release. Off unless set; see `--check-update` -for a one-off check." - -- [ ] **Step 2: Document the release process in `CLAUDE.md`** - -Add a section after "Operator Path Resolution": - -```markdown -## Release Versioning - -Versions are tags, not a string in a file. `pyproject.toml` has no `version`; -hatch-vcs derives it from `git describe`, so `importlib.metadata.version('spoonmap')` -— what `--version` prints — is whatever tag the artifact was built from. - -Tags are cut by CI, from the commits themselves. `tools/next_version.py` owns the -entire policy: any `feat:` commit (or a `!` subject, or a `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 waiting for one mistyped subject -line. Push a major by hand and `release.yml` will publish it. - -`nightly` cuts candidates for the version the batch is heading toward -(`v0.1.0rc1`, `v0.1.0rc2`, …) and `main` promotes that same target to its final -release. 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`. This makes conventional -commit subjects load-bearing — a `feat:` typo'd as `fix:` ships as a patch. - -The tagging lives in a `tag` job **inside `ci.yml`**, gated on -`needs: [test, test-legacy, lint, bandit, nse-root, workflow-lint, build]` and -on `github.event_name == 'push'` for `main`/`nightly` only. It is deliberately -not a separate `workflow_run`-triggered workflow, which is how this was first -built: zizmor — a required job in this same file — rates `workflow_run` an -error-level dangerous trigger and exits 14, because it is the standard -privilege-escalation vector, and this repo does not silence findings with ignore -comments. Being a `needs` dependent buys the same "only tag what passed CI" -guarantee without the trigger, and without checking out an explicitly-passed -head SHA. Do not reintroduce `workflow_run` here. - -Things that fail silently rather than loudly, all guarded by -`tests/test_release_versioning.py`: - -- **`ci.yml` must run on pushes to `nightly`.** Otherwise the tag job never runs - there and no candidate is ever cut, with no error anywhere. -- **The `tag` job must keep every validating job in `needs`.** Drop one and a - tag can land on a commit that failed it. -- **`fetch-depth: 0` on both the `tag` and `build` jobs.** The baseline is the - highest final tag; a shallow clone sees none and computes from 0.0.0, handing - out a version that already shipped. Verified: a depth-1 clone does not fail — - it silently versions from no tag at all. -- **The `tag` job sets `persist-credentials: true`**, against this repo's - convention everywhere else, because it pushes a tag. It is also the only job - with `contents: write`. That exception is commented at the site and pinned by - a test; do not "fix" it. - -Version arithmetic belongs in `tools/next_version.py`, where it is unit-tested, -never in a workflow step. hate_crack carried ~70 lines of `cut -d.` duplicated -across two YAML files before extracting this module; do not reintroduce it here. - -## Update Checking - -`check_for_updates` in `config.json` defaults to **false**, and an absent key -means false. It is the only thing that can cause a network connection at startup. -hate_crack's equivalent defaults to true; that is deliberately inverted here, -because SpooNMAP runs from jumpboxes inside client networks where an unprompted -call to `api.github.com` is an unauthorised outbound beacon from an engagement -host. `--check-update` is the on-demand path and ignores the config. - -The gate lives in `_maybe_check_for_updates()` rather than inline in `main()` -specifically so it can be tested — `main()` is under `pragma: no cover`, and -"does a default config reach the network" is the one question here that must not -go untested. Its test patches `urllib.request.urlopen` to raise if it is called -at all. `_check_for_updates()` swallows every failure: a courtesy check must -never delay, prompt, or abort a scan. An unknown local version (running from a -checkout) reports the latest release but never claims an update is available. -``` - -- [ ] **Step 3: Verify the docs match reality** - -Re-read both edits against the code as it now stands. Every flag named must -exist, every default stated must be the actual default, every path referenced -must resolve, and no workflow file is named that does not exist. Check -specifically that `--version`, `--check-update`, and `check_for_updates` are -spelled exactly as implemented in Tasks 4 and 5, and that the `needs` list -quoted above matches `ci.yml` exactly. - -- [ ] **Step 4: Final full verification** - -```bash -cd /tmp/spoonmap-auto-versioning -uv run pytest tests/ -q -uv run --frozen ruff check spoonmap.py tests/ tools/ -uv run --frozen bandit -r spoonmap.py -c pyproject.toml -b .bandit-baseline.json -uv lock --check -uvx --from "actionlint-py==1.7.12.24" actionlint > /tmp/al.out 2>&1; echo "actionlint exit=$?" -uvx zizmor==1.29.0 --persona=regular .github/workflows/ > /tmp/zz.out 2>&1; echo "zizmor exit=$?" -git status --short -``` -Expected: suite green at or above 95% coverage, lint and SAST clean, lock -current, both workflow linters exiting 0, no unintended files. Capture each exit -code on its own line as shown — a pipeline would report the exit status of the -last command in the pipe, not the linter's. - -- [ ] **Step 5: Commit** - -```bash -git add README.md CLAUDE.md -git commit -m "docs: document release versioning and opt-in update checking - -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." -``` - ---- -## Post-Implementation Notes - -Two consequences to expect on the first real run, both intended and both already -recorded in the spec: - -1. **The first `nightly` push treats the entire history as one batch**, since - there is no baseline tag to bound it. If any commit in that history says - `feat:`, the first candidate is `v0.1.0rc1` rather than `v0.0.1rc1`. Task 3 - Step 6 tells you which it will be before you push. -2. **Early releases read as `v0.0.x`.** That was chosen deliberately; a human can - push `v1.0.0` by hand whenever that stops being the right description, and the - policy builds on it from there. diff --git a/docs/superpowers/specs/2026-08-26-auto-versioning-design.md b/docs/superpowers/specs/2026-08-26-auto-versioning-design.md deleted file mode 100644 index b470347..0000000 --- a/docs/superpowers/specs/2026-08-26-auto-versioning-design.md +++ /dev/null @@ -1,255 +0,0 @@ -# Auto-versioning for SpooNMAP - -Date: 2026-08-26 -Status: approved, not yet implemented - -## Goal - -Give SpooNMAP the tag-driven release versioning that hate_crack uses: CI-gated -tags cut automatically from conventional-commit content, a version that comes -from git rather than a hand-maintained string, and a way for an operator to see -which version they are running. Adapted for SpooNMAP's hatchling build and its -`main` + `nightly` branch pair. - -One deliberate divergence from hate_crack, decided up front: **SpooNMAP never -contacts the network at launch unless the operator explicitly turned that on.** -hate_crack's `check_for_updates` config key defaults to `True` and its startup -path calls `check_for_updates()` on every run. SpooNMAP runs from jumpboxes -inside client networks, where an unprompted call to `api.github.com` is an -outbound beacon from an engagement host that nobody authorised. The capability -is ported; the default is inverted and a test holds it there. - -## Versioning policy - -Ordinary semver, with the bump derived from what is in the batch since the last -final tag: - -- Any `feat:` commit (including `feat(scope):`, `feat!:`, or a - `BREAKING CHANGE:` footer on any type) means the batch targets `X.(Y+1).0`. -- A batch of only fixes, docs and chores targets `X.Y.(Z+1)`. -- The major component is never bumped automatically. A breaking marker counts - as a feature, not a major. An automatic major is an irreversible published - mistake waiting for one mistyped subject line, so a major stays an explicit - human act: tag and push it by hand, which `release.yml` then picks up. - -`nightly` cuts release candidates for whichever version the batch is heading -toward — `v0.0.1rc1`, `v0.0.1rc2`, … — and merging down to `main` promotes that -same target to its final release. These are real PEP 440 pre-releases, so they -sort correctly at both ends: - - 0.0.0 < 0.0.1rc1 < 0.0.1rc2 < 0.0.1 < 0.1.0rc1 < 0.1.0 - -The target can change mid-cycle: the first `feat` to land moves it from -`X.Y.(Z+1)` to `X.(Y+1).0`, and candidate numbering restarts for the new target. -That is intended — the number always names what the batch would ship as today. - -The baseline is the highest *final* tag in the repository, deliberately not -restricted to tags reachable from HEAD. `main`'s release tag can sit on a commit -the `nightly` tip does not contain, and a reachability-restricted lookup would -compute the next nightly from a stale baseline and hand out a version below the -release that already shipped. - -### Starting point - -The repository has no tags. The baseline is therefore `(0, 0, 0)`, and no seed -tag is pushed: the first fix-only batch cuts `v0.0.1` and the first batch -containing a feature cuts `v0.1.0`. `pyproject.toml`'s current static -`version = "0.1.0"` is discarded rather than seeded, since the version becomes -derived state (see below) and there is nothing to carry forward. - -Two consequences accepted at design time: - -1. Early releases read as `v0.0.x` even though the tool is in real engagement - use. Acceptable; a human can push a `v1.0.0` by hand whenever that stops - being true, and the policy will build on it from there. -2. The first push to `nightly` after this lands treats the entire history as one - batch, since there is no baseline tag to bound it. If any commit in that - history says `feat:`, the first candidate is `v0.1.0rc1` rather than - `v0.0.1rc1`. - -## Components - -### 1. `tools/next_version.py` - -A port of hate_crack's policy module. Pure functions — `parse_final`, -`latest_final`, `has_feature`, `target_version`, `next_rc_number`, `compute` — -plus a thin git boundary (`git_tags`, `commit_messages`) and a -`--channel stable|nightly` CLI that prints the tag to create, or prints nothing -and exits 0 when there is nothing to tag. - -An empty batch returning "no tag" is not an error: a workflow re-run on an -already-tagged commit lands there, and the right answer is silence rather than a -version nobody asked for. This is *not* a "no feat/fix commits, skip" early -exit — a docs-only or chore-only merge is still a release, it just cuts a patch. - -The whole point of this file is that the policy lives in Python, where it can be -unit-tested, instead of in YAML, where it cannot. Neither workflow parses or -increments a version number. Additions go here, not into a workflow step. - -**Adaptation required for SpooNMAP:** hate_crack's module uses -`Version = tuple[int, int, int]` at module level and `X | None` return -annotations. The alias is a runtime subscript of `tuple` and fails on Python -3.8; SpooNMAP's `test-legacy` CI job runs the whole suite on 3.8 and 3.9, which -collects this module's tests. Use `typing.Tuple` / `typing.Optional` instead, in -both the module and its tests. - -### 2. Tagging workflows - -Three files under `.github/workflows/`: - -- **`nightly-tag.yml`** — `workflow_run` on a successful `CI` run on `nightly`. - Calls `next_version.py --channel nightly`, pushes `vX.Y.ZrcN`. Creates no - GitHub release; these tags exist to make nightly builds addressable and to - give the build backend a version. -- **`auto-tag.yml`** — `workflow_run` on a successful `CI` run on `main`. Calls - `next_version.py --channel stable`, pushes `vX.Y.Z`, then creates the GitHub - release with `gh release create --generate-notes`. The release is created here - rather than left to `release.yml` because GitHub does not dispatch workflow - events for refs pushed with `GITHUB_TOKEN`, so a tag pushed by this job would - never trigger a `push: tags:` workflow. -- **`release.yml`** — `push: tags: v*`. The path for tags a human pushes by - hand, which is what the "no automatic major" rule depends on existing. - -Requirements common to both tagging workflows: - -- `ref: ${{ github.event.workflow_run.head_sha }}` — `workflow_run` defaults to - the tip of the default branch, which is not necessarily the commit CI - validated. -- `fetch-depth: 0` — the version baseline is read from tags. Under a shallow - clone the project version reads as nothing and the job tags nonsense. -- `concurrency` group with `cancel-in-progress: false` — two merges landing - back-to-back would otherwise compute the same tag and the second push would - fail. Serialize rather than cancel so no merge gets skipped. -- Idempotent tag creation: a re-run must not fail the job if the tag or release - already exists. -- Guard on `workflow_run.conclusion == 'success'` so a broken commit is never - tagged or released. - -**`nightly-tag.yml` must live on `main`.** GitHub only dispatches `workflow_run` -for workflows present on the default branch; a copy existing solely on `nightly` -never fires. - -**Credential exception.** SpooNMAP's convention is `persist-credentials: false` -on every checkout. The two tagging jobs push a tag and so must keep credentials -persisted. This is a deliberate, commented exception at each site, not drift. -`release.yml` keeps `persist-credentials: false`, since it only reads. - -### 3. `ci.yml` changes - -Two edits, both load-bearing: - -- **Add `nightly` to the `push` branches.** CI currently runs on `pull_request` - and `push` to `main` only, so a push to `nightly` runs no CI at all and - `nightly-tag.yml`'s `workflow_run` trigger would never fire. hate_crack's - workflow header records hitting exactly this. -- **`fetch-depth: 0` on the `build` job's checkout.** hatch-vcs cannot resolve a - version from a depth-1 clone with no tags, so `uv build` would fail there. - -The existing `workflow-lint` job already runs actionlint and zizmor against the -whole `.github/workflows/` directory, so the three new files are covered with no -edit to that job. - -### 4. `pyproject.toml` - -- `build-system.requires` gains `hatch-vcs`. -- `version = "0.1.0"` becomes `dynamic = ["version"]`, with - `[tool.hatch.version] source = "vcs"` and setuptools-scm's `no-guess-dev` - version scheme and `no-local-version` local scheme, matching hate_crack. -- `tools/` joins the sdist `include` allowlist. -- `pyyaml` joins the `dev` group, for the workflow guard tests. - -No commitizen. hate_crack pins it and configures `[tool.commitizen]`, but its -workflows call `next_version.py` and never actually run `cz bump`, so porting it -would add a pinned dependency that nothing executes. - -### 5. `--version` - -A module-level `_tool_version()` helper in `spoonmap.py` reading -`importlib.metadata.version('spoonmap')`, falling back to a "running from -source" string on `PackageNotFoundError` — the tool is frequently invoked as a -plain script from a checkout, where no distribution metadata exists. Wired into -`main()` beside the existing `--cleanup` dispatch. - -The helper lives outside `main()`'s `# pragma: no cover` region so it is -testable directly, the same reasoning that keeps `_operator_dir()` a module-level -function. - -### 6. Opt-in update checking - -`_check_for_updates()` in `spoonmap.py`: GET -`https://api.github.com/repos/trustedsec/spoonmap/releases/latest`, compare the -tag against `_tool_version()`, print a one-line notice if a newer release -exists. - -- **Off unless explicitly enabled.** `_load_config()` gains an optional - `check_for_updates` key, absent-means-false, and that key is the only way to - enable the launch-time check. It is not a required key; a config that never - mentions it is valid and inert. This needs a `_config_bool(key, value, - default)` helper alongside the existing `_config_int()`, warning and taking - the default on a non-boolean value. -- **`config.json.sample` ships it explicitly `false`,** so the safe state is - also the documented one. -- **`--check-update`** performs one check and exits, regardless of config, so an - operator can ask without leaving the startup check enabled. -- **Stdlib only.** `spoonmap.py` is deliberately dependency-free, so this uses - `urllib.request` with a short timeout — not `requests` — and a small local - version-tuple comparison rather than `packaging.parse`. `spoonmap.py` cannot - import `tools/next_version.py`; that module is build tooling, not a runtime - dependency, and the wheel ships `spoonmap.py` alone. -- **Every failure is swallowed** into at most a one-line warning. A failed or - slow update check must never delay, prompt, or abort a scan. -- **An unknown local version is not an update.** When `_tool_version()` returns - its running-from-source fallback there is nothing to compare against, so the - check reports the latest release as information and never claims an upgrade is - available. Guessing "newer" there would nag every operator running from a - checkout, which is most of them. -- **Nightly RCs stay invisible.** GitHub's `releases/latest` endpoint excludes - prereleases, so candidate tags are never advertised as updates. - -### 7. Documentation - -`CLAUDE.md` gains a section covering the release policy, the branch-to-channel -mapping, why `nightly-tag.yml` must live on `main`, the `persist-credentials` -exception, and the opt-in-off-by-default rule for update checking. `README.md` -documents `--version`, `--check-update`, and the `check_for_updates` config key -with its default. - -## Testing - -- **`tests/test_next_version.py`** — the pure policy: bump selection across - feat/fix/docs/breaking batches, subject anchoring (a `feat` mentioned - mid-sentence in a fix body must not promote the batch), RC numbering including - the target-changed-mid-cycle restart, baseline selection from a mixed tag - list, and the empty-batch `None`. -- **`tests/test_release_versioning.py`** — the wiring that breaks silently: - that `ci.yml` pushes on `nightly`; that each tagging workflow calls - `next_version.py` exactly once and pushes the tag it printed; that no shell - version arithmetic has crept back in. Following hate_crack's recorded lesson, - the load-bearing guards (computed tag value, tag idempotency, empty-batch - path) assert behaviour by extracting the step script from the YAML and running - it against a real git repository and a real bare remote — substring assertions - on YAML are sensitive to formatting and blind to behaviour, and a reviewer - defeated hate_crack's substring version without any test failing. -- **Inert-default guard** — with a config that does not mention - `check_for_updates`, `urllib.request.urlopen` is patched to raise if called at - all, so a future change that reintroduces a launch-time network call fails the - suite instead of shipping. This follows the precedent already recorded in - `CLAUDE.md` for defaulting a config to something inert and asserting it. -- **`_check_for_updates()` and `_tool_version()`** — the explicit-true path, the - swallowed-failure path, both branches of the metadata lookup, and the version - comparison. - -The repo's 95% coverage floor and `ruff`/`bandit` gates apply as usual. -`tools/next_version.py` is not under `--cov=spoonmap`, so its tests run without -contributing to that floor; coverage stays scoped to `spoonmap.py`. - -## Out of scope - -- Publishing to PyPI. hate_crack has a `pypi-placeholder.yml`; SpooNMAP has no - publish infrastructure and a prototyped hatch build hook was already dropped - once for that reason. -- Automatic major bumps. -- A `--update` self-update command. hate_crack has one; nothing here needs it, - and it is a separate decision from knowing an update exists. -- Rewriting `CHANGELOG.md`. Release notes come from - `gh release --generate-notes`.