Repository navigation
build(deps-dev): Bump ruff from 0.16.7 to 0.16.8 #52
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |