diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml index e88ba6a3..9e5bb149 100644 --- a/.github/workflows/python-ci.yml +++ b/.github/workflows/python-ci.yml @@ -3,8 +3,32 @@ name: CI on: push: branches: [ "**" ] + paths: + # The Python implementation and everything that pins its environment. + # It is the reference implementation the TypeScript port is validated + # against, so it still has to be tested — but a docs edit or a change + # under js/ has no business firing 4 jobs. + - "src/**" + - "scripts/**" + - "pyproject.toml" + - "poetry.lock" + - "requirements.lock" + - "conftest.py" + - ".github/workflows/python-ci.yml" pull_request: branches: [ "**" ] + paths: + # The Python implementation and everything that pins its environment. + # It is the reference implementation the TypeScript port is validated + # against, so it still has to be tested — but a docs edit or a change + # under js/ has no business firing 4 jobs. + - "src/**" + - "scripts/**" + - "pyproject.toml" + - "poetry.lock" + - "requirements.lock" + - "conftest.py" + - ".github/workflows/python-ci.yml" schedule: - cron: '0 3 * * *' diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d7c8470e..b6172ed4 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -3,8 +3,32 @@ name: Tests on: push: branches: ["**"] + paths: + # The Python implementation and everything that pins its environment. + # It is the reference implementation the TypeScript port is validated + # against, so it still has to be tested — but a docs edit or a change + # under js/ has no business firing 9 jobs. + - "src/**" + - "scripts/**" + - "pyproject.toml" + - "poetry.lock" + - "requirements.lock" + - "conftest.py" + - ".github/workflows/tests.yml" pull_request: branches: ["**"] + paths: + # The Python implementation and everything that pins its environment. + # It is the reference implementation the TypeScript port is validated + # against, so it still has to be tested — but a docs edit or a change + # under js/ has no business firing 9 jobs. + - "src/**" + - "scripts/**" + - "pyproject.toml" + - "poetry.lock" + - "requirements.lock" + - "conftest.py" + - ".github/workflows/tests.yml" jobs: test: diff --git a/.github/workflows/ts-parity.yml b/.github/workflows/ts-parity.yml index 1436affd..f682d191 100644 --- a/.github/workflows/ts-parity.yml +++ b/.github/workflows/ts-parity.yml @@ -94,19 +94,19 @@ jobs: # The Python side has had a dependency audit for a while; the # JavaScript tree had none. run: pnpm audit --audit-level moderate - - name: Generate a CycloneDX SBOM - run: | - npx --yes @cyclonedx/cyclonedx-npm@latest \ - --output-format JSON \ - --output-file "$RUNNER_TEMP/seedpass-js-sbom.json" \ - --package-lock-only || echo "SBOM generation failed (non-fatal)" - - name: Upload SBOM - uses: actions/upload-artifact@v4 - if: always() - with: - name: seedpass-js-sbom - path: ${{ runner.temp }}/seedpass-js-sbom.json - if-no-files-found: warn + # An SBOM step used to sit here and it never worked. It invoked + # `@cyclonedx/cyclonedx-npm@latest --package-lock-only`, which is the + # NPM tool and reads package-lock.json — a file this pnpm workspace does + # not have and never will. It then swallowed the failure with + # `|| echo "non-fatal"` and uploaded with `if-no-files-found: warn`, so + # two separate layers stopped it from ever going red. Its logs said + # "No files were found with the provided path" on every run. + # + # Removed rather than pinned: pinning the version of a tool that cannot + # read this repository's lockfile fixes the wrong problem. Generating a + # real SBOM needs a pnpm-native generator, which is tracked in TODO.md. + # `pnpm audit` above is the supply-chain check that actually runs and + # actually fails. # Fixtures prove the two implementations compute the same values; this job # proves they can read each other's on-disk profiles and backups. diff --git a/TODO.md b/TODO.md index a349282a..4fb1db5f 100644 --- a/TODO.md +++ b/TODO.md @@ -342,6 +342,22 @@ everything still open, in the order it should be tackled. smoke step assert something that can fail. Do not leave it as a dependency nobody declared and tests nobody runs. (Found 2026-08-20 while fixing the first CI run of this branch.) + - [ ] **The JavaScript SBOM needs a pnpm-native generator.** The old + step invoked `@cyclonedx/cyclonedx-npm --package-lock-only`, which + is the npm tool and reads `package-lock.json` — a file this pnpm + workspace does not have. It then swallowed the failure twice over + (`|| echo "non-fatal"` and `if-no-files-found: warn`), so it + reported success on every run while its own log said "No files + were found with the provided path". Removed 2026-08-20 rather than + pinned, because pinning a tool that cannot read our lockfile fixes + the wrong problem. + + `pnpm audit --audit-level moderate` remains and does fail on + findings, so supply-chain checking is not absent — only the bill + of materials is. The shipped bundle has six production + dependencies, so a generator over `pnpm list --json` is likely + simpler and more auditable than another third-party tool, and + fits the zero-dependency posture of the bundle itself. - [ ] **A nested export path answers 500 instead of a refusal.** `POST /api/v1/entry/:id/document/export` with `{"path": "exports/nested/secret.txt"}` fails with an opaque diff --git a/js/pnpm-workspace.yaml b/js/pnpm-workspace.yaml index 4dd398c1..d0927c4c 100644 --- a/js/pnpm-workspace.yaml +++ b/js/pnpm-workspace.yaml @@ -3,3 +3,4 @@ packages: - apps/* allowBuilds: esbuild: true + libxmljs2: set this to true or false diff --git a/scripts/ci_preflight.py b/scripts/ci_preflight.py index a42bba86..641beec8 100755 --- a/scripts/ci_preflight.py +++ b/scripts/ci_preflight.py @@ -55,7 +55,9 @@ failures: list[str] = [] def ok(label: str, detail: str = "") -> None: - print(f" {GREEN}ok{RESET} {label}" + (f" {DIM}{detail}{RESET}" if detail else "")) + print( + f" {GREEN}ok{RESET} {label}" + (f" {DIM}{detail}{RESET}" if detail else "") + ) def bad(label: str, detail: str, fix: str) -> None: @@ -101,14 +103,22 @@ def check_python_env() -> None: locked = parse_lock_versions(REPO / "poetry.lock") code, out = run( - ["poetry", "run", "python", "-c", - "import json;from importlib.metadata import distributions;" - "print(json.dumps({d.metadata['Name'].lower(): d.version " - "for d in distributions() if d.metadata['Name']}))"] + [ + "poetry", + "run", + "python", + "-c", + "import json;from importlib.metadata import distributions;" + "print(json.dumps({d.metadata['Name'].lower(): d.version " + "for d in distributions() if d.metadata['Name']}))", + ] ) if code != 0: - bad("cannot inspect the poetry environment", out.splitlines()[-1] if out else "", - "./scripts/dev_sync.sh") + bad( + "cannot inspect the poetry environment", + out.splitlines()[-1] if out else "", + "./scripts/dev_sync.sh", + ) return installed = json.loads(out.splitlines()[-1]) @@ -122,11 +132,16 @@ def check_python_env() -> None: elif have != want: drift.append(f"{tool} {have} != locked {want}") if drift: - bad("installed tools disagree with poetry.lock", "; ".join(drift), - "./scripts/dev_sync.sh") + bad( + "installed tools disagree with poetry.lock", + "; ".join(drift), + "./scripts/dev_sync.sh", + ) else: - ok("black, pytest and coverage match poetry.lock", - " ".join(f"{t}={locked.get(t,'?')}" for t in VERDICT_TOOLS)) + ok( + "black, pytest and coverage match poetry.lock", + " ".join(f"{t}={locked.get(t,'?')}" for t in VERDICT_TOOLS), + ) def check_js_env() -> None: @@ -136,19 +151,28 @@ def check_js_env() -> None: want_node = str(pkg.get("engines", {}).get("node", "")).lstrip("^>=~ ") have_node = sys.version and run(["node", "--version"])[1].lstrip("v") if not want_node: - bad("js/package.json declares no engines.node", "nothing to check against", - 'add "engines": {"node": ">=22"} to js/package.json') + bad( + "js/package.json declares no engines.node", + "nothing to check against", + 'add "engines": {"node": ">=22"} to js/package.json', + ) elif have_node.split(".")[0] != want_node.split(".")[0]: - bad("node major differs from engines.node", f"have {have_node}, want {want_node}", - f"install Node {want_node.split('.')[0]}") + bad( + "node major differs from engines.node", + f"have {have_node}, want {want_node}", + f"install Node {want_node.split('.')[0]}", + ) else: ok("node matches engines.node", f"v{have_node}") want_pnpm = str(pkg.get("packageManager", "")).removeprefix("pnpm@") have_pnpm = run(["pnpm", "--version"])[1] if want_pnpm and have_pnpm != want_pnpm: - bad("pnpm differs from packageManager", f"have {have_pnpm}, want {want_pnpm}", - "corepack enable && corepack prepare --activate") + bad( + "pnpm differs from packageManager", + f"have {have_pnpm}, want {want_pnpm}", + "corepack enable && corepack prepare --activate", + ) else: ok("pnpm matches packageManager", have_pnpm) @@ -157,8 +181,11 @@ def check_js_env() -> None: if code == 0: ok("node_modules satisfies pnpm-lock.yaml") else: - bad("pnpm-lock.yaml not satisfied", out.splitlines()[-1] if out else "", - "./scripts/dev_sync.sh") + bad( + "pnpm-lock.yaml not satisfied", + out.splitlines()[-1] if out else "", + "./scripts/dev_sync.sh", + ) def main() -> int: @@ -168,7 +195,9 @@ def main() -> int: print() if failures: - print(f"{RED}preflight failed{RESET} — a local gate run now could disagree with CI.\n") + print( + f"{RED}preflight failed{RESET} — a local gate run now could disagree with CI.\n" + ) for f in failures: print(f" - {f}") print("\nRepair with: ./scripts/dev_sync.sh")