Skip to content
Closed
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
14 changes: 14 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,20 @@ uv run --project services/api python services/api/scripts/audit_community_export
uv run --project services/api python services/api/scripts/check_web_contracts.py --require-complete
```

`COMMUNITY_EXPORT_MANIFEST.json` binds every file under `services/api/` to its
digest, so any change there — including a dependency bump that rewrites
`uv.lock` — has to rewrite the manifest too, or the audit fails with the path it
did not recognise:

```console
uv run --project services/api python services/api/scripts/audit_community_export.py services/api --refresh-manifest
```

The refresh runs every other gate first and rewrites the manifest only once they
pass, so it records a tree that already holds the source boundary; it can never
approve one that does not. It prints exactly which entries it added, changed or
removed.

### Web application

```console
Expand Down
26 changes: 16 additions & 10 deletions services/api/COMMUNITY_EXPORT_MANIFEST.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,17 +31,23 @@
"sha256": "e0bfeec4f11af50244091b2a1323faaeb056ef1e3ee5d4a94d91e40fb9706b2e"
},
{
"bytes": 3417,
"bytes": 3743,
"mode": "0644",
"path": "README.md",
"sha256": "ce529fd31b7d3a783c81d8f5bd24922a20a24eead0fc9371c74e4b2dc9026419"
"sha256": "fb6aa7e29bf14442aac57d59b1cc99907f096077cab58e16ed827ca41003db07"
},
{
"bytes": 142910,
"mode": "0644",
"path": "SOURCE_EXPORT_MANIFEST.json",
"sha256": "ec4685034118a8cd91e099b12cf780e139077125074af37be68acdfdf4e8d2cd"
},
{
"bytes": 5000,
"mode": "0644",
"path": "alembic.ini",
"sha256": "17673a380d40c2763b72794d87ce3d9b7311c0d123457efb0e66082189ebdf11"
},
{
"bytes": 38,
"mode": "0644",
Expand All @@ -66,12 +72,6 @@
"path": "alembic/versions/20260912_0001_community_baseline.py",
"sha256": "0b2acc3adc93a5e9a465d97f9ab0f21b33139aefb2337c945803fbf47fd04a27"
},
{
"bytes": 5000,
"mode": "0644",
"path": "alembic.ini",
"sha256": "17673a380d40c2763b72794d87ce3d9b7311c0d123457efb0e66082189ebdf11"
},
{
"bytes": 2113,
"mode": "0644",
Expand Down Expand Up @@ -115,10 +115,10 @@
"sha256": "7918b810601db56cf0fcd7028aada0259b86aea62bbe0fabcd99abee9287f85f"
},
{
"bytes": 11451,
"bytes": 13588,
"mode": "0644",
"path": "scripts/audit_community_export.py",
"sha256": "1ab30a234b8fb26bb7f934e84708311fa91578df01d14cd0eff2e3911ff5c8c4"
"sha256": "49dac3a6d4d4bc55d8da2e63a30117fcffee3f7d38d098f06c54b0ee2c352cb6"
},
{
"bytes": 9906,
Expand Down Expand Up @@ -1728,6 +1728,12 @@
"path": "tests/test_evidence_type.py",
"sha256": "9c39d2dde3e3e73d27ebbc465ac41a38bb84bbde626a418c60dd29a8324dca0a"
},
{
"bytes": 3007,
"mode": "0644",
"path": "tests/test_export_manifest.py",
"sha256": "5b6d8522a1f1131406150a5f0f354eb7a580951785080b0367898d4d71df4770"
},
{
"bytes": 2730,
"mode": "0644",
Expand Down
10 changes: 10 additions & 0 deletions services/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,16 @@ python scripts/check_web_contracts.py --require-complete
python scripts/audit_community_export.py
```

A change under `services/api/` — a dependency bump included — has to rewrite the
manifest that binds these files to their digests:

```text
python scripts/audit_community_export.py --refresh-manifest
```

It runs every other gate first and only then records the tree, printing which
entries it added, changed or removed.

The generated `SOURCE_EXPORT_MANIFEST.json` binds every copied source file to the
pinned private source commit. `COMMUNITY_EXPORT_MANIFEST.json` binds the final,
sanitized service files. The export scripts refuse an unexpected commit or a
Expand Down
57 changes: 55 additions & 2 deletions services/api/scripts/audit_community_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,45 @@ def verify_manifest(root: Path, files: list[Path]) -> None:
path for path in set(expected) & set(actual) if expected[path] != actual[path]
)
detail = (missing or extra or changed or ["unknown"])[0]
raise RuntimeError(f"manifest:file-set-or-hash:{detail}")
raise RuntimeError(
f"manifest:file-set-or-hash:{detail} — a change under services/api/ has to"
" rewrite the manifest: python scripts/audit_community_export.py"
" --refresh-manifest"
)


def refresh_manifest(root: Path, files: list[Path]) -> tuple[str, ...]:
"""Rewrite the manifest for the current tree and report what it changed.

Only ever reached after every other gate passed. The manifest records a tree
that has already been shown to hold the source boundary, so refreshing it can
record new hashes but never grant approval to something the audit rejects.
"""

manifest_path = root / MANIFEST_NAME
payload = json.loads(manifest_path.read_text(encoding="utf-8"))
before = {str(item["path"]): str(item["sha256"]) for item in payload.get("files", [])}
entries = [
{
"bytes": path.stat().st_size,
"mode": f"{path.stat().st_mode & 0o777:04o}",
"path": path.relative_to(root).as_posix(),
"sha256": sha256(path),
}
for path in files
]
after = {str(entry["path"]): str(entry["sha256"]) for entry in entries}
payload["files"] = sorted(entries, key=lambda entry: str(entry["path"]))
manifest_path.write_text(
json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8"
)
return tuple(
sorted(
[f"changed:{name}" for name in before.keys() & after.keys() if before[name] != after[name]]
+ [f"added:{name}" for name in after.keys() - before.keys()]
+ [f"removed:{name}" for name in before.keys() - after.keys()]
)
)


def scan_tree(root: Path, files: list[Path]) -> None:
Expand Down Expand Up @@ -286,18 +324,33 @@ def main() -> int:
parser.add_argument(
"service", type=Path, nargs="?", default=Path(__file__).resolve().parents[1]
)
parser.add_argument(
"--refresh-manifest",
action="store_true",
help="rewrite the manifest for this tree once every other gate has passed",
)
args = parser.parse_args()
root = args.service.resolve()
refreshed: tuple[str, ...] = ()
try:
files = included_files(root)
verify_manifest(root, files)
if not args.refresh_manifest:
verify_manifest(root, files)
scan_tree(root, files)
verify_codeql_regressions(root)
verify_packaging(root)
verify_runtime(root)
if args.refresh_manifest:
refreshed = refresh_manifest(root, files)
except (OSError, ValueError, RuntimeError, subprocess.CalledProcessError) as exc:
print(f"community export audit failed: {exc}", file=sys.stderr)
return 1
if args.refresh_manifest:
print(
"community export manifest refreshed: "
+ (", ".join(refreshed) if refreshed else "no change")
)
return 0
print(
"community export audit passed: "
f"{len(files)} files, {EXPECTED_HTTP_OPERATIONS} HTTP operations, "
Expand Down
88 changes: 88 additions & 0 deletions services/api/tests/test_export_manifest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"""Whoever changes this tree has to be able to rewrite its manifest."""

from __future__ import annotations

import hashlib
import importlib.util
import json
from pathlib import Path
from types import ModuleType

import pytest


def _load_audit() -> ModuleType:
path = Path(__file__).resolve().parents[1] / "scripts" / "audit_community_export.py"
spec = importlib.util.spec_from_file_location("audit_community_export", path)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module


AUDIT = _load_audit()


def _tree(root: Path, content: str) -> None:
"""Write a minimal tree whose manifest matches it exactly."""

(root / "kept.txt").write_text(content, encoding="utf-8")
digest = hashlib.sha256(content.encode("utf-8")).hexdigest()
(root / AUDIT.MANIFEST_NAME).write_text(
json.dumps(
{
"files": [
{
"bytes": len(content.encode("utf-8")),
"mode": "0644",
"path": "kept.txt",
"sha256": digest,
}
],
"format": 1,
"namespace": "sixsentences_server",
"source_commit": AUDIT.EXPECTED_SOURCE_COMMIT,
},
indent=2,
sort_keys=True,
)
+ "\n",
encoding="utf-8",
)


def test_a_changed_file_is_reported_with_the_command_that_fixes_it(tmp_path: Path) -> None:
_tree(tmp_path, "before\n")
AUDIT.verify_manifest(tmp_path, AUDIT.included_files(tmp_path))

(tmp_path / "kept.txt").write_text("after\n", encoding="utf-8")

with pytest.raises(RuntimeError) as failure:
AUDIT.verify_manifest(tmp_path, AUDIT.included_files(tmp_path))

# A contributor who has never seen this manifest reads the failure, not the
# script: the message has to name the way out.
assert "kept.txt" in str(failure.value)
assert "--refresh-manifest" in str(failure.value)


def test_refreshing_records_the_tree_and_names_what_it_recorded(tmp_path: Path) -> None:
_tree(tmp_path, "before\n")
(tmp_path / "kept.txt").write_text("after\n", encoding="utf-8")
(tmp_path / "added.txt").write_text("new\n", encoding="utf-8")

changed = AUDIT.refresh_manifest(tmp_path, AUDIT.included_files(tmp_path))

assert changed == ("added:added.txt", "changed:kept.txt")
AUDIT.verify_manifest(tmp_path, AUDIT.included_files(tmp_path))


def test_refreshing_keeps_the_export_identity(tmp_path: Path) -> None:
"""The refreshed manifest still names the export it descends from."""

_tree(tmp_path, "before\n")
AUDIT.refresh_manifest(tmp_path, AUDIT.included_files(tmp_path))
payload = json.loads((tmp_path / AUDIT.MANIFEST_NAME).read_text(encoding="utf-8"))

assert payload["source_commit"] == AUDIT.EXPECTED_SOURCE_COMMIT
assert payload["format"] == 1
Loading