Skip to content

Merge pull request #61 from trustedsec/feat/os-precision #71

Merge pull request #61 from trustedsec/feat/os-precision

Merge pull request #61 from trustedsec/feat/os-precision #71

Workflow file for this run

name: CI
on:
pull_request:
push:
# `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
# only: on `main`, cancelling the previous commit's in-progress run because a
# fast follow-up merge landed would erase that earlier commit's green check,
# and a push to `main` is not something a later push should be allowed to hide.
concurrency:
group: ci-${{ github.workflow }}-${{ github.head_ref || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
jobs:
test:
name: tests (${{ matrix.os }}, py${{ matrix.python-version }})
runs-on: ${{ matrix.os }}
# This suite exercises work_queue.join(), threading.Event polling and
# KeyboardInterrupt handling; a past bug in that area failed as a hang
# rather than a clean failure, so every job gets a bound well under the
# 6-hour default instead of relying on the runner to notice.
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest]
# uv.lock resolves for 3.10+ only, so pytest can stay on a version
# patched for CVE-2025-71176. 3.8 and 3.9 are covered by test-legacy.
python-version: ['3.10', '3.11', '3.12', '3.13']
include:
# The tool is developed on macOS, which has its own termios/stty paths.
- os: macos-latest
python-version: '3.12'
steps:
# Actions are pinned to a commit SHA, not a tag: a tag is mutable, so
# `@v4` is an unaudited third party with write access to this build.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# tests/test_nse_integration.py shells out to real nmap against local stub
# servers. Without nmap the whole module skips itself, so installing it is
# what makes the NSE assertions actually run — on every OS this job
# covers, not just Linux: the tool is developed on macOS, and until this
# job installed nmap there too, all 26 NSE integration tests silently
# skipped on the platform used for day-to-day development.
- name: Install nmap (Linux)
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends nmap
- name: Install nmap (macOS)
if: runner.os == 'macOS'
# HOMEBREW_NO_AUTO_UPDATE: without it, `brew install` first runs a
# full `brew update`, which can spend minutes refreshing tap metadata
# this job doesn't need — against the 10-minute timeout above.
run: brew install nmap
env:
HOMEBREW_NO_AUTO_UPDATE: '1'
# A broken nmap install would otherwise just turn 26 assertions into
# silent skips instead of failing the job.
- name: Verify nmap is on PATH
run: python3 -c "import shutil, sys; sys.exit(0 if shutil.which('nmap') else 1)"
- name: Install uv
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with:
python-version: ${{ matrix.python-version }}
enable-cache: true
# Coverage reporting and the 95% floor come from [tool.pytest.ini_options]
# in pyproject.toml, so a coverage regression fails this step too. -rs
# prints skip reasons, so a test that starts skipping here shows up in
# the log instead of vanishing into a bare count.
- name: Run tests
run: uv run --frozen pytest tests/ -v -rs
# spoonmap.py is dependency-free stdlib and requires-python floors at 3.8, so
# the oldest versions pyproject declares still need testing. They cannot use
# uv.lock (it resolves for 3.10+), so the test toolchain is resolved fresh
# here, outside the project. That means pytest 8.x on these two jobs — the
# CVE-2025-71176 tmpdir issue is a local-user attack on a throwaway runner,
# and nothing vulnerable enters uv.lock, which is what the repo ships.
test-legacy:
name: tests (ubuntu-latest, py${{ matrix.python-version }}, unlocked)
runs-on: ubuntu-latest
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
python-version: ['3.8', '3.9']
steps:
# Actions are pinned to a commit SHA, not a tag: a tag is mutable, so
# `@v4` is an unaudited third party with write access to this build.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install nmap
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends nmap
- name: Verify nmap is on PATH
run: python3 -c "import shutil, sys; sys.exit(0 if shutil.which('nmap') else 1)"
- name: Install uv
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with:
enable-cache: true
- 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
# Separate job, not a step on `test`: a lint failure and a test failure are
# different questions, and neither should hide the other behind a red X.
# Ruleset and pinned ruff version live in pyproject.toml.
lint:
name: ruff
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install uv
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with:
python-version: '3.12'
enable-cache: true
# Lives here rather than on every leg of the `test` matrix: the lock
# file's freshness doesn't vary per interpreter version, so checking it
# five times gave five copies of the same answer. This job runs once
# regardless of the test matrix's size, so it's the natural home.
- name: Check uv.lock is up to date
run: uv lock --check
- name: Ruff
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
# list-form calls, xml.etree on tool output we invoked ourselves) every time.
# Baselining them means only a NEW finding fails the build. bandit itself is
# exact-pinned in the `dev` group (same reasoning as ruff) so its transitive
# dependencies are locked too, rather than floating under a bare `uvx --from`
# pin on bandit alone. Regenerate the baseline with
# uv run --frozen bandit -r spoonmap.py -c pyproject.toml \
# -f json -o .bandit-baseline.json
# and say in the commit message why each added finding is acceptable.
bandit:
name: bandit
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install uv
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with:
python-version: '3.12'
enable-cache: true
- name: Bandit
run: >
uv run --frozen bandit
-r spoonmap.py -c pyproject.toml -b .bandit-baseline.json
# TestOpenvpnDetectNseUdp (tests/test_nse_integration.py) is gated on
# os.geteuid() != 0, so the -sU NSE path it exercises is skipped in every
# other job in this workflow. This job runs as root so it actually executes.
nse-root:
name: NSE integration tests (root, -sU)
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install nmap
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends nmap
- name: Verify nmap is on PATH
run: python3 -c "import shutil, sys; sys.exit(0 if shutil.which('nmap') else 1)"
- name: Install uv
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with:
python-version: '3.12'
# setup-uv's post-run cache prune runs as the `runner` user, but
# `sudo -E` below leaves `uv` writing cache entries as root. A
# runner-owned prune hitting root-owned entries is an EPERM footgun
# this job doesn't need: it never rebuilds the cache from a fresh
# run in a way that benefits from it, since the only thing it runs
# is one test module.
enable-cache: false
# Deterministic proof that the mechanism this job exists to test —
# sudo, PATH re-injection, and uv resolving under it — actually works,
# run under the *exact same* invocation the real test run uses below.
# If sudo, the PATH re-injection, or uv resolution silently broke, this
# fails here instead of the test run quietly reporting skips as if
# nothing were wrong.
- name: Verify sudo/PATH/uv actually resolve as root
run: >
sudo -E env "PATH=$PATH" uv run --frozen python3 -c
"import os, sys; sys.exit(0 if os.geteuid() == 0 else 1)"
# `sudo` resets PATH, dropping the `uv` that setup-uv just put on it —
# `sudo -E env "PATH=$PATH" ...` re-injects the invoking user's PATH
# into the root environment instead of relying on sudo's own (uv-less)
# secure_path. The 95% coverage floor in pyproject.toml's `addopts`
# applies to the whole suite, so a single-module run would fail it for
# a reason unrelated to this job's purpose — `--no-cov` disables it
# here without touching the floor itself. Only this one module is run:
# the rest of the suite is already covered, with coverage, by `test`
# and `test-legacy`. --junitxml records each test's skip message (if
# any) so the next step can tell a root-gate skip from a port-conflict
# skip, which a plain "N passed, M skipped" pytest summary cannot.
- name: Run NSE integration tests as root
run: >
sudo -E env "PATH=$PATH" uv run --frozen pytest
tests/test_nse_integration.py -v -rs --no-cov
--junitxml=nse-root-results.xml
# A *negative* check here ("no skip mentioned the root-gate reason")
# passes vacuously in at least three ways: the reason string in
# tests/test_nse_integration.py gets reworded, TestOpenvpnDetectNseUdp
# is renamed or deleted, or the whole module skips itself via the
# `nmap not installed` pytestmark — none of those produce a matching
# skip message, so a negative check would print success having
# verified nothing. This is a *positive* assertion instead: at least
# one testcase belonging to TestOpenvpnDetectNseUdp exists in the junit
# report, and none of that class's testcases were skipped, for any
# reason. It does not care what a skip message says, so rewording the
# reason string can't defeat it either.
#
# tests/conftest.py's port-conflict skip is still tolerated exactly as
# before — but only for *other* classes (e.g. the CUPS class on port
# 631). This job exists specifically to prove TestOpenvpnDetectNseUdp
# runs as root, so for that one class, a skip of any kind — including
# its own port conflict — means the thing this job exists to verify
# was not verified, and must fail.
- name: Verify TestOpenvpnDetectNseUdp actually ran (not skipped, for any reason)
run: |
python3 - <<'PYEOF'
import sys
import xml.etree.ElementTree as ET
TARGET_CLASS = 'TestOpenvpnDetectNseUdp'
tree = ET.parse('nse-root-results.xml')
matches = [
tc for tc in tree.getroot().iter('testcase')
if TARGET_CLASS in tc.attrib.get('classname', '')
]
if not matches:
sys.exit(
f'no testcase found for {TARGET_CLASS} — it was renamed, '
'deleted, or never collected at all (e.g. the whole module '
'skipped itself because nmap was not found), so the -sU '
'path this job exists to exercise was not verified'
)
skipped = [tc for tc in matches if tc.find('skipped') is not None]
if skipped:
names = sorted(tc.attrib.get('name', '<unknown>') for tc in skipped)
sys.exit(
f'{TARGET_CLASS} test(s) were skipped, meaning the -sU path '
'did not actually run as root in this job (sudo, PATH '
're-injection, or uv resolution may have broken silently): '
+ ', '.join(names)
)
print(f'{TARGET_CLASS}: {len(matches)} test(s) collected and ran as root')
PYEOF
# The workflow directory is hand-edited often and every `uses:` step in it
# carries a hand-maintained SHA pin (not a fixed count worth stating here —
# it only grows as jobs are added — but it's every `uses:` line in
# .github/workflows/), so it gets the same linting rigor as the Python it
# drives. actionlint catches YAML and expression errors (bad `if:`, unknown
# contexts, malformed matrices); zizmor is Actions-specific security
# auditing (unpinned actions, script injection via untrusted input,
# credential persistence). One job for both, on the same reasoning as
# combining `test`/`test-legacy` under one workflow instead of two.
#
# Both tools are pointed at the whole .github/workflows/ directory, not
# just this file, so a future workflow file is covered without anyone
# having to remember to add it here. .github/dependabot.yml is deliberately
# left out of both invocations: it isn't a workflow file and neither tool
# understands it (zizmor's `--collect dependabot` mode is a separate,
# unrelated check this repo isn't opting into) — its own YAML validity is
# covered by the plain `yaml.safe_load` parse this PR's own verification
# runs, not by this job.
#
# actionlint and zizmor are both installed via a bare `uvx --from`, the
# same pattern bandit's move into the locked `dev` group was meant to move
# away from — but the exception is deliberate here: neither tool is part of
# spoonmap's own dependency surface (nothing here imports them, and they
# never touch spoonmap.py), both are single, effectively dependency-free
# Rust/Go-style binaries wrapped for PyPI, and their blast radius is
# limited to linting this workflow file, not running against or shipping
# with the project's own source the way bandit does.
workflow-lint:
name: workflow lint (actionlint + zizmor)
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# `uv`/`uvx` is not preinstalled on ubuntu-latest, unlike every other
# job here — this one was missing this step and its two `uvx` steps
# were failing at "command not found" on every run, invisibly, because
# local verification always had `uv` on PATH already. enable-cache is
# on: unlike nse-root, nothing here runs under sudo, so there is no
# root-owned-cache/runner-owned-prune conflict, and caching speeds up
# repeated `uvx` tool installs across runs.
- name: Install uv
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with:
python-version: '3.12'
enable-cache: true
# No path argument: actionlint auto-discovers the nearest
# .github/workflows/ directory from the current repo root and checks
# every file in it, so a new workflow file needs no edit here.
- name: actionlint
run: uvx --from "actionlint-py==1.7.12.24" actionlint
- name: zizmor
run: uvx zizmor==1.29.0 --persona=regular .github/workflows/
# The built artifacts are a claimed interface ([project.scripts] installs a
# spoonmap entry point) that nothing else here exercises. This job builds
# them and asserts on the actual archive contents rather than trusting that
# [tool.hatch.build.targets.*] in pyproject.toml says what it means — that
# trust is exactly what let the wheel ship spoonmap.py alone, pointing at an
# nse/ directory that was never packaged, and let the sdist sweep in 54
# entries of local session scratch.
build:
name: build artifacts
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- 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@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
with:
python-version: '3.12'
enable-cache: true
- 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'
import glob
import sys
import tarfile
path = glob.glob('dist/*.tar.gz')[0]
# config.json.sample is expected and must not trip this, so match
# the config.json basename exactly rather than as a substring.
#
# Only 'ranges.txt' can actually fire in CI today: '.remember',
# '.superpowers', '.claude' and 'PR_ai_ports' are all untracked
# local scratch that a checkout never has, and this checkout has no
# committed config.json either. They stay in the list anyway as a
# guard against a future commit accidentally adding one of them —
# a passing run here is evidence about ranges.txt specifically, not
# proof that every banned pattern is exercised.
banned_substrings = (
'.remember', '.superpowers', '.claude', 'PR_ai_ports', 'ranges.txt',
)
with tarfile.open(path) as tar:
names = tar.getnames()
offenders = [
n for n in names
if any(s in n for s in banned_substrings)
or n.split('/')[-1] == 'config.json'
]
if offenders:
sys.exit(
'sdist packaged local scratch it must not ship: '
+ ', '.join(offenders)
)
print(f'sdist clean: {len(names)} entries, none banned')
PYEOF
# A blocklist alone let a gutted [tool.hatch.build.targets.sdist]
# include list still pass as "clean" (4 entries, none banned) — clean
# is not the same as complete. This adds the missing floor: every entry
# below must actually have landed in the sdist. Deliberately hardcoded
# here rather than read back out of pyproject.toml's own include list:
# the exact failure this guards against is that include list itself
# being gutted (accidentally or otherwise), so the floor cannot be
# derived from the same config it exists to check — that would just
# compare the edited list to itself and always agree.
#
# It also closes the other half of the local-scratch leak the blocklist
# above can't see: "nse/" and "tests/" are directory-wide includes, so
# anything sitting in nse/ ships regardless of what it's named — a
# custom NSE script an operator drops in mid-engagement included. This
# asserts the sdist's nse/ contents match nse/ as *git tracks it*
# exactly (set equality, not mere inclusion) — deliberately `git
# ls-files`, not `os.listdir`, because the packaging step reads from
# this same working tree: comparing against the working tree's own
# listing would just compare the leaked file to itself and always
# "match", proving nothing. `git ls-files` is what the operator
# actually committed, which is what's supposed to ship.
- name: Assert sdist has every required entry and nse/ matches exactly
run: |
python3 - <<'PYEOF'
import glob
import subprocess
import sys
import tarfile
# Mirrors [tool.hatch.build.targets.sdist].include in pyproject.toml
# 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/', 'tools/', 'README.md', 'CLAUDE.md',
'config.json.sample', 'exclusions.txt', 'pyproject.toml',
'uv.lock', '.bandit-baseline.json', '.github/workflows/',
)
path = glob.glob('dist/*.tar.gz')[0]
with tarfile.open(path) as tar:
names = tar.getnames()
# hatchling prefixes every entry with 'spoonmap-<version>/'; strip
# that one leading path component so entries compare directly
# against `required` above.
stripped = ['/'.join(n.split('/')[1:]) for n in names]
missing_entries = [
pattern for pattern in required
if (pattern.endswith('/') and not any(
s.startswith(pattern) for s in stripped))
or (not pattern.endswith('/') and pattern not in stripped)
]
if missing_entries:
sys.exit(f'sdist is missing required entries: {missing_entries}')
tracked = subprocess.run(
['git', 'ls-files', 'nse/'],
capture_output=True, text=True, check=True,
).stdout.splitlines()
tracked_nse = {t[len('nse/'):] for t in tracked}
sdist_nse = {
s[len('nse/'):] for s in stripped
if s.startswith('nse/') and not s.endswith('/')
}
nse_missing = sorted(tracked_nse - sdist_nse)
nse_extra = sorted(sdist_nse - tracked_nse)
if nse_missing or nse_extra:
sys.exit(
'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'tools/ matches git-tracked files exactly ({len(tracked_tools_set)} files)'
)
PYEOF
# Set equality against nse/, not mere inclusion: `force-include` copies
# the whole nse/ tree into the wheel, so an extra file sitting there —
# an operator's engagement-specific NSE script left behind, or scratch
# notes — would silently ship to every install if this only checked
# that the expected scripts were present. Compared against `git
# ls-files`, not `os.listdir`, for the same reason as the sdist check
# above: the wheel is built from this same working tree, so comparing
# against the working tree's own listing can't detect an untracked file
# that both "sides" already agree on.
- name: Assert wheel spoonmap_nse/ matches nse/ exactly, and ships config.json.sample
run: |
python3 - <<'PYEOF'
import glob
import subprocess
import sys
import zipfile
tracked = subprocess.run(
['git', 'ls-files', 'nse/'],
capture_output=True, text=True, check=True,
).stdout.splitlines()
tracked_nse = {t[len('nse/'):] for t in tracked}
if not tracked_nse:
sys.exit('expected at least one bundled NSE script to check')
path = glob.glob('dist/*.whl')[0]
with zipfile.ZipFile(path) as z:
names = set(z.namelist())
packaged_nse = {
n[len('spoonmap_nse/'):] for n in names
if n.startswith('spoonmap_nse/') and not n.endswith('/')
}
missing = sorted(tracked_nse - packaged_nse)
extra = sorted(packaged_nse - tracked_nse)
if missing or extra:
sys.exit(
'wheel spoonmap_nse/ does not match git-tracked nse/ exactly — '
f'missing: {missing}, extra: {extra}'
)
print(
'wheel spoonmap_nse/ matches git-tracked nse/ exactly: '
f'{len(tracked_nse)} files'
)
# spoonmap.py's own config-error messages ("See config.json.sample
# for the expected keys") name this file by bare filename; a wheel
# that omitted it left that message pointing at nothing.
if 'config.json.sample' not in names:
sys.exit(
"wheel is missing config.json.sample, which spoonmap.py's "
'own config-error messages point operators at'
)
print('wheel contains config.json.sample')
PYEOF
- name: Install wheel into a clean venv and verify NSE paths resolve
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 out of the checkout first: spoonmap.py sitting in the cwd would
# shadow the installed site-packages copy, defeating the point of
# this check.
cd "$venv_dir"
"$venv_dir/venv/bin/python" - <<'PYEOF'
import os
import sys
from spoonmap import EXTERNAL_PORT_SCRIPTS, INTERNAL_PORT_SCRIPTS
def nse_paths(port_scripts):
paths = []
for flags in port_scripts.values():
for token in flags.split(','):
if token.endswith('.nse'):
paths.append(token)
return paths
paths = nse_paths(INTERNAL_PORT_SCRIPTS) + nse_paths(EXTERNAL_PORT_SCRIPTS)
if not paths:
sys.exit('expected at least one bundled .nse path to check')
missing = [p for p in paths if not os.path.isfile(p)]
if missing:
sys.exit(
'installed wheel resolves NSE paths that do not exist on disk: '
+ str(missing)
)
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
# 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
}
# 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: 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
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:-<nothing to 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