Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/dependency-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,15 @@ jobs:
# `on-failure`, not `always`: a comment on every green pull request trains reviewers
# to skim past the one that is not green.
comment-summary-in-pr: on-failure
# RENDERED from security/atif-sql.openvex.json by `mise run security:vex`; do not edit
# by hand. The OpenVEX ledger is the one record of why a finding is suppressed, but
# this action has no `--vex` input: its only suppression surface is this list, which
# src/filter.ts at a1d282b matches on the GHSA id alone, blind to package and version.
# So the value is the GHSA ids of the ledger's `not_affected` and `fixed` statements,
# and `mise run security:vex:check` (inside `mise run check`) fails when this set and
# the ledger's differ in either direction, or when a ledger npm version is not the one
# site/pnpm-lock.yaml locks, which puts the version scoping back.
allow-ghsas: GHSA-ch52-4w7c-c8xp, GHSA-vfj7-8cjw-p6xm
# Strong copyleft cannot enter an Apache-2.0 distribution. LGPL is deliberately
# absent (weak copyleft, linkable) and so is MPL-2.0, which
# `mise run security:licenses` already allows by name for certifi, pathspec, and
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -412,7 +412,7 @@ the derived example must execute — or a documented `EXCLUSIONS` entry.
## Definition of done

`mise run check` fully green (lint + fmt + typecheck + lint:imports + lint:workflows +
lint:hooks + test). `mise run security` is the report-only tier beside it: findings do not fail
lint:hooks + lint:pnpm-lock + test). `mise run security` is the report-only tier beside it: findings do not fail
it, a scanner that produced no usable SARIF does.
First-time setup: `mise trust && mise install && mise run install`.

Expand Down
2 changes: 2 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ that.
| `fmt` | `uv run ruff format --check .` | formatting; `mise run fmt:write` applies it |
| `typecheck` | `uv run ty check` | `[tool.ty.rules] all = "error"`, and `[tool.ty.terminal] error-on-warning = true` means a warning is also a failure |
| `lint:imports` | `uv run lint-imports` | the import contracts below |
| `lint:pnpm-lock` | `python3 scripts/verify_single_yaml_document.py site/pnpm-lock.yaml` | `site/pnpm-lock.yaml` holding more than one YAML document, which GitHub's dependency graph cannot read |
| `security:vex:check` | `uv run python scripts/vex_to_osv_config.py ... --check` | `osv-scanner.toml` or the `allow-ghsas:` line of `.github/workflows/dependency-review.yml` drifting from `security/atif-sql.openvex.json`, or a ledger PURL version that `uv.lock` or `site/pnpm-lock.yaml` no longer locks; `mise run security:vex` re-renders both |
| `test` | `uv run pytest --no-header -q` | `[tool.pytest.ini_options] testpaths = ["packages/*/tests"]` |

Each gate declares `sources`, so mise skips one whose inputs have not moved by
Expand Down
5 changes: 5 additions & 0 deletions lefthook.yml
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,11 @@ pre-push:
# because a config edit is not file-scoped work.
run: mise run lint:hooks

- name: lint-pnpm-lock
# site/pnpm-lock.yaml must stay one YAML document or GitHub's dependency graph loses the
# docs site's packages. Milliseconds, and the failure only shows on GitHub otherwise.
run: mise run lint:pnpm-lock

- name: test
run: mise run test
skip:
Expand Down
43 changes: 35 additions & 8 deletions mise.toml
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,16 @@ description = "Validate lefthook.yml against lefthook's own schema"
sources = ["lefthook.yml"]
run = "uv run lefthook validate"

[tasks."lint:pnpm-lock"]
description = "Fail unless site/pnpm-lock.yaml is one YAML document that lists packages"
# pnpm 12 writes its own pinned version into the lockfile as a separate FIRST YAML document, and
# GitHub's dependency graph reads only that one: the docs site's dependencies vanished from the
# SBOM and Dependabot closed three alerts as fixed with nothing fixed (2026-10-08, PR #37).
# `pmOnFail: ignore` in site/pnpm-workspace.yaml keeps the file one document; this gate is what
# notices the day a pnpm bump or a stray `pnpm install` brings the second document back. No
# `sources`: it is a few milliseconds, and a skipped gate reads like a passing one.
run = "python3 scripts/verify_single_yaml_document.py site/pnpm-lock.yaml lint:pnpm-lock"

[tasks.test]
description = "Run the full pytest suite across all members"
sources = ["packages/*/src/**/*.py", "packages/*/tests/**/*.py", "pyproject.toml", "packages/*/pyproject.toml"]
Expand All @@ -203,7 +213,7 @@ description = "The suite under branch coverage; writes coverage.xml and enforces
run = "uv run pytest --no-header -q --cov --cov-report=term-missing --cov-report=xml"

[tasks.check]
description = "All gates: lint + fmt + typecheck (ty + pyright) + imports + workflows + hooks + vex drift + test"
description = "All gates: lint + fmt + typecheck (ty + pyright) + imports + workflows + hooks + pnpm lock + vex drift + test"
depends = [
"lint",
"fmt",
Expand All @@ -212,6 +222,7 @@ depends = [
"lint:imports",
"lint:workflows",
"lint:hooks",
"lint:pnpm-lock",
# The VEX drift gate is here rather than in `security` on purpose: `security` is report-only
# and this must FAIL. A generated suppression surface nothing verifies is a suppression
# surface that outlives its reason.
Expand Down Expand Up @@ -314,26 +325,41 @@ depends = [
# ---------------------------------------------------------------------------

[tasks."security:vex"]
description = "Regenerate osv-scanner.toml from the OpenVEX ledger"
description = "Regenerate osv-scanner.toml and dependency-review's allow-ghsas from the OpenVEX ledger"
# NOT in `security`'s depends, deliberately. `osv-scanner.toml` is COMMITTED and drift-gated, so
# regenerating it as part of the scan would let a scan run against a suppression surface no gate
# has ever seen — the scan would define its own exemptions and then pass. Editing
# `security/atif-sql.openvex.json` means running this task and committing BOTH files.
run = "uv run python scripts/vex_to_osv_config.py security/atif-sql.openvex.json osv-scanner.toml"
# `security/atif-sql.openvex.json` means running this task and committing what it rewrote:
# osv-scanner.toml and the `allow-ghsas:` line of .github/workflows/dependency-review.yml.
run = """
uv run python scripts/vex_to_osv_config.py security/atif-sql.openvex.json osv-scanner.toml \
--pnpm-lock site/pnpm-lock.yaml --workflow .github/workflows/dependency-review.yml
"""

[tasks."security:vex:check"]
description = "Drift gate: osv-scanner.toml must match the ledger, and the renderer must work"
description = "Drift gate: osv-scanner.toml and allow-ghsas must match the ledger, and the renderer must work"
# In `check`'s depends, because a generated file nothing verifies is a file that drifts. Two
# assertions, and the second exists because the live ledger ships EMPTY:
#
# --check re-renders the live ledger in memory and diffs it against the committed
# osv-scanner.toml, printing a unified diff and failing on any difference. This also
# re-runs every ledger validation, so a stale PURL version fails here even when the
# rendered TOML happens not to change.
# rendered TOML happens not to change. With `--pnpm-lock` it holds every
# `pkg:npm` PURL to the version site/pnpm-lock.yaml locks, and with `--workflow` it
# fails when dependency-review.yml's `allow-ghsas` set differs from the ledger's
# GHSA set in either direction: that action has no `--vex`, and its allow list is
# version-blind, so the lock assertion is what scopes it.
# examples/ renders the worked example to a temp path and asserts both advisory ids reach the
# output. With no live statements, `--check` alone would exercise only the
# empty-ledger branch and the suppression-emitting path could rot unnoticed.
sources = ["security/**/*.json", "osv-scanner.toml", "scripts/vex_to_osv_config.py", "uv.lock"]
sources = [
"security/**/*.json",
"osv-scanner.toml",
"scripts/vex_to_osv_config.py",
"uv.lock",
"site/pnpm-lock.yaml",
".github/workflows/dependency-review.yml",
]
#
# `uv run python`, not bare `python3`: the renderer uses 3.12+ syntax (a `type` statement), and
# on a host whose /usr/bin precedes the mise tool dir, `python3` is the system 3.9 and the task
Expand All @@ -345,7 +371,8 @@ sources = ["security/**/*.json", "osv-scanner.toml", "scripts/vex_to_osv_config.
# report.
run = """
set -eu
uv run python scripts/vex_to_osv_config.py security/atif-sql.openvex.json osv-scanner.toml --check
uv run python scripts/vex_to_osv_config.py security/atif-sql.openvex.json osv-scanner.toml \
--pnpm-lock site/pnpm-lock.yaml --workflow .github/workflows/dependency-review.yml --check
scratch=$(mktemp -d)
trap 'rm -rf "$scratch"' EXIT
uv run python scripts/vex_to_osv_config.py \
Expand Down
122 changes: 122 additions & 0 deletions packages/atif-cli/tests/test_pnpm_lock_guard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# SPDX-License-Identifier: Apache-2.0

"""`scripts/verify_single_yaml_document.py` rejects what GitHub's dependency graph cannot read.

pnpm 12 can write its own pinned version into `site/pnpm-lock.yaml` as a separate first YAML
document, and GitHub reads only that one. The guard is a `check` leg, so it needs the same
anti-vacuity proof every gate here carries: a planted second document turns it red, an empty
or package-less file turns it red, and the real lockfile is green.
"""

from __future__ import annotations

import subprocess
import sys
from pathlib import Path

import pytest

#: Repository root: `packages/atif-cli/tests/` -> three parents up.
ROOT = Path(__file__).resolve().parents[3]
SCRIPT = ROOT / "scripts" / "verify_single_yaml_document.py"
LOCKFILE = ROOT / "site" / "pnpm-lock.yaml"

_ENV_DOCUMENT = """---
lockfileVersion: '9.0'

importers:

.:
configDependencies: {}
packageManagerDependencies:
pnpm:
specifier: 12.10.1
version: 12.10.1

packages:

pnpm@12.10.1:
hasBin: true

"""

_PROJECT_DOCUMENT = """---
lockfileVersion: '9.0'

importers:

.:
dependencies:
astro:
specifier: 7.3.5
version: 7.3.5

packages:

astro@7.3.5:
resolution: {integrity: sha512-x}

snapshots:

astro@7.3.5: {}
"""


def _run(path: Path) -> subprocess.CompletedProcess[str]:
return subprocess.run( # noqa: S603 - fixed interpreter and repository script
[sys.executable, str(SCRIPT), str(path)],
capture_output=True,
text=True,
check=False,
)


def test_real_lockfile_is_one_document() -> None:
assert LOCKFILE.is_file(), f"{LOCKFILE} is missing"
result = _run(LOCKFILE)
assert result.returncode == 0, result.stderr


def test_second_document_is_rejected(tmp_path: Path) -> None:
lock = tmp_path / "pnpm-lock.yaml"
lock.write_text(_ENV_DOCUMENT + _PROJECT_DOCUMENT, encoding="utf-8")
result = _run(lock)
assert result.returncode == 1
assert "2 YAML documents" in result.stderr


def test_single_document_with_leading_marker_passes(tmp_path: Path) -> None:
lock = tmp_path / "pnpm-lock.yaml"
lock.write_text(_PROJECT_DOCUMENT, encoding="utf-8")
assert _run(lock).returncode == 0


@pytest.mark.parametrize(
("body", "reason"),
[
("", "no content"),
("# only a comment\n", "no content"),
("lockfileVersion: '9.0'\npackages:\n", "no top-level importers:"),
("lockfileVersion: '9.0'\nimporters:\n .: {}\n", "no top-level packages:"),
("lockfileVersion: '9.0'\nimporters:\n .: {}\npackages:\n\n", "lists no package"),
],
)
def test_vacuous_lockfile_is_rejected(tmp_path: Path, body: str, reason: str) -> None:
lock = tmp_path / "pnpm-lock.yaml"
lock.write_text(body, encoding="utf-8")
result = _run(lock)
assert result.returncode == 1
assert reason in result.stderr


def test_missing_file_is_rejected(tmp_path: Path) -> None:
result = _run(tmp_path / "absent.yaml")
assert result.returncode == 1
assert "unreadable" in result.stderr


def test_wrong_arguments_exit_two() -> None:
result = subprocess.run( # noqa: S603 - fixed interpreter and repository script
[sys.executable, str(SCRIPT)], capture_output=True, text=True, check=False
)
assert result.returncode == 2
Loading
Loading