From 80e87c2297cfc3c0935c25e61654b7fa8afd0595 Mon Sep 17 00:00:00 2001 From: David Spencer <1526975+DecisionNerd@users.noreply.github.com> Date: Sat, 1 Aug 2026 07:38:59 -0600 Subject: [PATCH 1/3] feat(release): validate complete partitioned candidates --- .github/workflows/README.md | 18 +- .../workflows/binding-release-candidate.yml | 43 +- .github/workflows/test.yml | 1 + CHANGELOG.md | 5 + docs/development/release-artifact-record.md | 117 +-- docs/reference/changelog.md | 5 + scripts/ci/clean-env-verify.py | 13 +- scripts/ci/prepare-napi-packages.py | 47 + scripts/ci/release-candidate.py | 172 +--- scripts/ci/release_candidate_manifest.py | 804 ++++++++++++++++++ scripts/ci/test-binding-release-candidate.py | 10 +- scripts/ci/test-clean-env-verify.py | 3 + scripts/ci/test-prepare-napi-packages.py | 46 + scripts/ci/test-release-candidate.py | 372 ++++++-- scripts/publish_crates.py | 7 +- scripts/record_release_artifacts.py | 214 ++--- tests/unit/test_record_release_artifacts.py | 37 +- 17 files changed, 1457 insertions(+), 457 deletions(-) create mode 100644 scripts/ci/prepare-napi-packages.py create mode 100644 scripts/ci/release_candidate_manifest.py create mode 100644 scripts/ci/test-prepare-napi-packages.py diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 43bf3c372..af310503e 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -77,6 +77,11 @@ target, and produces one fail-closed aggregate report. Missing targets, mixed SHAs or per-language versions, fallback execution, failed or unclassified cases, and parity differences reject the candidate. This workflow does not create a tag or release and does not publish to PyPI or npm. +Its final assembly derives one aligned root version, packs the complete PyPI, +npm, and crates.io surfaces, records four non-overlapping artifact groups, and +reopens every archive with `graphforge-release-candidate-v2` completeness +validation. A checksum-valid archive with missing entrypoints, types, native +modules, dependency metadata, or legal files is rejected. After the maturin wheel build, the workflow verifies that any inherited Rust compiler wrapper is still executable before Python contracts may launch Cargo; an unavailable wrapper is cleared without printing the job environment or PATH. @@ -135,11 +140,10 @@ construction issues; those close on outcomes (see `AGENTS.md` § Issue close). ### `binding-release-candidate.yml`, `release-credential-preflight.yml`, and `publish.yaml` -The exact-SHA Binding RC retains tested release bytes and their checksum record -for 30 days. Credential preflight verifies the npm/crates.io secret projections -without publishing. The release-event workflow consumes the retained candidate, -attaches its record, and publishes PyPI, npm, then crates.io in fail-closed order; -ordinary PRs do not repeat that certification. +The exact-SHA Binding RC retains tested release bytes and their partitioned v2 +candidate manifest for 30 days. Credential preflight verifies the npm/crates.io +secret projections without publishing. The release-event workflow consumes the +retained candidate; ordinary PRs do not repeat that certification. ### `clean-env-verify.yml` @@ -148,7 +152,9 @@ PyPI/npm only and runs the #167 lanes (pip quickstart, npm smoke, NPX CLI and skills compatibility, create/close/reopen Arrow rows, docs/package URL resolve, optional checksum match against a `graphforge-release-record-v1` file). Preflight fails closed when the requested -version is unpublished. Ordinary PRs run only the harness unit tests via +version is unpublished. Candidate v2 manifests and historical +`graphforge-release-record-v1` files are both accepted for checksum lookup. +Ordinary PRs run only the harness unit tests via Repository Policy — they never claim clean-env success against missing packages. See [`docs/development/clean-environment-verification.md`](../../docs/development/clean-environment-verification.md). diff --git a/.github/workflows/binding-release-candidate.yml b/.github/workflows/binding-release-candidate.yml index c26b921da..8f55c89ef 100644 --- a/.github/workflows/binding-release-candidate.yml +++ b/.github/workflows/binding-release-candidate.yml @@ -411,6 +411,25 @@ jobs: [[ "$EVIDENCE_SHA" =~ ^[0-9a-f]{40}$ ]] test "$(git rev-parse HEAD)" = "$EVIDENCE_SHA" + - name: Derive the one root release version + shell: bash + run: | + python3 scripts/set_release_version.py --check + release_version="$(python3 - <<'PY' + import re + from pathlib import Path + text = Path("Cargo.toml").read_text(encoding="utf-8") + match = re.search(r'(?m)^version = "([^"]+)"$', text) + if match is None: + raise SystemExit("workspace release version is missing") + print(match.group(1)) + PY + )" + case "$release_version" in + *dev*) echo "release candidate requires a non-development version" >&2; exit 1 ;; + esac + printf 'RELEASE_VERSION=%s\n' "$release_version" >> "$GITHUB_ENV" + - uses: actions/setup-python@v6 with: python-version: "3.13" @@ -469,6 +488,8 @@ jobs: python3 "$GITHUB_WORKSPACE/scripts/ci/validate-napi-artifacts.py" \ --npm-dir npm --manifest package.json pnpm exec napi pre-publish -t npm --skip-optional-publish --no-gh-release + python3 "$GITHUB_WORKSPACE/scripts/ci/prepare-napi-packages.py" \ + --npm-dir npm --legal-dir . for package_dir in npm/*; do npm pack "./$package_dir" --ignore-scripts \ --pack-destination "$GITHUB_WORKSPACE/candidate/release-artifacts/npm" @@ -476,10 +497,10 @@ jobs: npm pack . --ignore-scripts \ --pack-destination "$GITHUB_WORKSPACE/candidate/release-artifacts/npm" popd - npm pack ./packages/cli --ignore-scripts \ - --pack-destination candidate/release-artifacts/npm - npm pack ./packages/agent-skills --ignore-scripts \ - --pack-destination candidate/release-artifacts/npm + pnpm --dir packages/cli pack \ + --pack-destination "$GITHUB_WORKSPACE/candidate/release-artifacts/npm" + pnpm --dir packages/agent-skills pack \ + --pack-destination "$GITHUB_WORKSPACE/candidate/release-artifacts/npm" - name: Package the complete Rust surface shell: bash @@ -494,7 +515,7 @@ jobs: done < <(python3 scripts/ci/crate-publish-plan.py list) cargo package "${package_args[@]}" --allow-dirty --no-verify for crate in "${crates[@]}"; do - cp "target/release-candidate/package/${crate}-0.5.0.crate" \ + cp "target/release-candidate/package/${crate}-${RELEASE_VERSION}.crate" \ candidate/release-artifacts/crates/ done @@ -507,18 +528,20 @@ jobs: - name: Create and validate the immutable checksum record run: | + recorded_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" python3 scripts/record_release_artifacts.py \ - --version 0.5.0 \ + --version "$RELEASE_VERSION" \ --dist-dir candidate/release-artifacts \ - --out candidate/v0.5.0-artifacts.json \ + --out "candidate/v${RELEASE_VERSION}-artifacts.json" \ + --recorded-at "$recorded_at" \ --notes "M1 Binding Release Candidate run $GITHUB_RUN_ID; exact SHA $EVIDENCE_SHA" python3 scripts/ci/clean-env-verify.py validate-release-record \ - candidate/v0.5.0-artifacts.json + "candidate/v${RELEASE_VERSION}-artifacts.json" python3 scripts/ci/release-candidate.py validate \ - --record candidate/v0.5.0-artifacts.json \ + --record "candidate/v${RELEASE_VERSION}-artifacts.json" \ --artifacts-dir candidate/release-artifacts \ --expected-sha "$EVIDENCE_SHA" \ - --version 0.5.0 + --version "$RELEASE_VERSION" - name: Retain the release candidate for publication uses: actions/upload-artifact@v7 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2cefd1705..caaf76ae0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -120,6 +120,7 @@ jobs: run: | python3 scripts/ci/test-release-publish-preflight.py python3 scripts/ci/test-release-candidate.py + python3 scripts/ci/test-prepare-napi-packages.py python3 scripts/ci/test-release-notes.py python3 scripts/ci/test-publish-npm-artifacts.py python3 scripts/ci/test-amend-npm-main-artifact.py diff --git a/CHANGELOG.md b/CHANGELOG.md index db2d3d845..2f4bf4eda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- Replace checksum-only release records with a deterministic, partitioned + candidate manifest that enforces the complete 24-node public package set, + one root version, exact dependency edges, archive entrypoints/legal files, + retention, and an explicit registry-independent publication state model + before any write (#293). - Adopt ADR 0017's single-version release invariant: all public Rust crates, Python and Node/native adapters, CLI, and agent skills must publish one exact GraphForge version, and partial-publication recovery may not introduce diff --git a/docs/development/release-artifact-record.md b/docs/development/release-artifact-record.md index 97a705ea2..544bce371 100644 --- a/docs/development/release-artifact-record.md +++ b/docs/development/release-artifact-record.md @@ -1,67 +1,84 @@ -# Release artifact record +# Release candidate manifest -This page is the §5 checklist home for **checksums, SBOM/provenance, licenses, and -contents** of v0.5.0 release-candidate artifacts -([M1 #192](https://github.com/CurateLabs/graphforge/issues/192)). +GraphForge publication consumes one immutable, partitioned candidate. The +candidate manifest is the authority for release identity, package inventory, +dependency order, exact bytes, and retained-artifact availability. A matching +checksum proves byte identity; it does **not** prove that a package contains its +required runtime, metadata, and legal files. -It does **not** replace the authoritative publication order / stop conditions in -[`publication-order.md`](publication-order.md). +This page does not authorize publication or replace the operator stop conditions +in [`publication-order.md`](publication-order.md). -## Same-tagged-commit rule +## Canonical contract -Every first-party publishable artifact for version `0.5.0` must be built from one -verified commit (the eventual `v0.5.0` tag target) or have an explicit reproducible -link to that commit recorded in the artifact JSON. Do not mix bytes from different -commits under the same version. +`graphforge-release-candidate-v2` has one root `version` and no per-node version +field. The public node set is fixed: -## How to record +- 15 `graphforge-*` crates on crates.io; +- `graphforge` on PyPI (three tested wheels and one source distribution); +- five native npm packages and `@curatelabs/graphforge`; +- `@curatelabs/graphforge-cli` and + `@curatelabs/graphforge-agent-skills`. -1. Freeze the RC SHA and surface versions (`scripts/set_release_version.py` / #192). -2. Dispatch `Binding Release Candidate` for that exact current `main` SHA. Its - final job builds `M1-Release-Candidate-` from the tested wheels/addons, - then adds the sdist, npm tarballs, all 15 `.crate` archives, dry-run evidence, - and license reports. -3. The workflow runs the equivalent of: +Every archive records its byte length, SHA-256, SRI integrity, package identity, +required files, member count, and an inventory digest. Validation reopens the +exact archive and compares those facts. It rejects missing Python import/native +surfaces, Node entrypoints or types, native addons, CLI/skills entrypoints, crate +sources, legal files, or exact-version first-party dependency metadata—even when +the recorded checksum matches the incomplete archive. -```bash -python3 scripts/record_release_artifacts.py \ - --version 0.5.0 \ - --dist-dir path/to/artifacts \ - --out docs/releases/records/v0.5.0-artifacts.json \ - --notes "RC sha=<40-char> built via " -``` +The dependency graph includes crate-to-crate publication prerequisites, all five +native npm packages before the npm main package, main before CLI, and CLI before +agent skills. It must be complete, refer only to declared nodes, and be acyclic. -4. `publish.yaml` validates the complete bundle and attaches the JSON to the - GitHub Release before the first registry write (#194). -5. Post-release clean-env verification (#167) matches `sha256` values from this record. +## Artifact groups and retention -The generated document uses the same `graphforge-release-record-v1` schema -consumed by `clean-env-verify.py`. Validate it before attaching: +Candidate bytes are routed into four non-overlapping groups: -```bash -python3 scripts/ci/clean-env-verify.py validate-release-record \ - docs/releases/records/v0.5.0-artifacts.json -``` +| Group | Contents | +| --- | --- | +| `python` | Three tested wheels and one sdist | +| `npm` | Five native packages, main package, CLI, and agent skills | +| `crates` | All 15 `.crate` archives | +| `evidence` | Five tested Node addons plus dry-run and legal reports | + +The small manifest lives beside those partitions. Each group declares its +retention period and expiry. Missing, expired, overlapping, unrecorded, or +wrongly routed files fail closed. Later recovery may download only a needed +partition, but it may never rebuild or substitute candidate bytes. -Template-only (no files yet): +## Publication states + +The manifest names the release state vocabulary without deriving state from a +workflow job result: `not_attempted`, `absent`, `accepted_pending_visibility`, +`verified`, `conflict`, `indeterminate`, and `failed`. Registry observation and +recovery planning define how those states are reached; the candidate only fixes +their meanings and the bytes being observed. + +## Build and validate offline + +After the binding workflow has assembled the four directories, it creates the +manifest and immediately validates the complete candidate before any registry +write: ```bash python3 scripts/record_release_artifacts.py \ - --version 0.5.0 \ - --dist-dir target/release-artifacts \ - --allow-empty \ - --out docs/releases/records/v0.5.0-artifacts.template.json + --version "$RELEASE_VERSION" \ + --dist-dir candidate/release-artifacts \ + --out "candidate/v${RELEASE_VERSION}-artifacts.json" \ + --recorded-at "$RECORDED_AT" + +python3 scripts/ci/release-candidate.py validate \ + --record "candidate/v${RELEASE_VERSION}-artifacts.json" \ + --artifacts-dir candidate/release-artifacts \ + --expected-sha "$RELEASE_SHA" \ + --version "$RELEASE_VERSION" ``` -## License / third-party pointers - -- First-party: `Apache-2.0`, shipped `LICENSE` + `NOTICE` (`make package-license-verify` / #218). -- Third-party inventory: [`legal/THIRD_PARTY_NOTICES.md`](../../legal/THIRD_PARTY_NOTICES.md) (#218). - -## SBOM / provenance +The recorder produces stable JSON for the same version, SHA, timestamp, notes, +and exact partitions. The validator uses only local bytes; it performs no +registry access, tag creation, release creation, or publication. -When the release process emits SBOM or provenance files, place them in the same -`--dist-dir` so `record_release_artifacts.py` classifies them (`sbom` / -`provenance`). If none are configured for a surface, the record’s -`sbom_provenance.configured` stays false — that is an explicit disposition, not a -silent skip. +`clean-env-verify.py` continues to accept historical +`graphforge-release-record-v1` documents while also reading the v2 artifact list. +Historical v0.5.0 records remain immutable. diff --git a/docs/reference/changelog.md b/docs/reference/changelog.md index db2d3d845..2f4bf4eda 100644 --- a/docs/reference/changelog.md +++ b/docs/reference/changelog.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- Replace checksum-only release records with a deterministic, partitioned + candidate manifest that enforces the complete 24-node public package set, + one root version, exact dependency edges, archive entrypoints/legal files, + retention, and an explicit registry-independent publication state model + before any write (#293). - Adopt ADR 0017's single-version release invariant: all public Rust crates, Python and Node/native adapters, CLI, and agent skills must publish one exact GraphForge version, and partial-publication recovery may not introduce diff --git a/scripts/ci/clean-env-verify.py b/scripts/ci/clean-env-verify.py index cb2fd00b4..e652b113f 100644 --- a/scripts/ci/clean-env-verify.py +++ b/scripts/ci/clean-env-verify.py @@ -27,6 +27,8 @@ ROOT = Path(__file__).resolve().parents[2] EVIDENCE_SCHEMA = "graphforge-clean-env-evidence-v1" RELEASE_RECORD_SCHEMA = "graphforge-release-record-v1" +RELEASE_CANDIDATE_SCHEMA = "graphforge-release-candidate-v2" +RELEASE_RECORD_SCHEMAS = (RELEASE_RECORD_SCHEMA, RELEASE_CANDIDATE_SCHEMA) DEFAULT_VERSION = "0.5.0" DEFAULT_DOCS_BASE = "https://docs.graphforge.sh" DEFAULT_CRATES = ( @@ -109,9 +111,10 @@ def parse_json(data: bytes, *, context: str) -> Any: def validate_release_record(record: dict[str, Any]) -> dict[str, Any]: - if record.get("schema") != RELEASE_RECORD_SCHEMA: + if record.get("schema") not in RELEASE_RECORD_SCHEMAS: raise VerifyError( - f"release record schema must be {RELEASE_RECORD_SCHEMA!r}, got {record.get('schema')!r}" + "release record schema must be one of " + f"{RELEASE_RECORD_SCHEMAS!r}, got {record.get('schema')!r}" ) version = record.get("version") if not isinstance(version, str) or not version: @@ -897,7 +900,7 @@ def build_parser() -> argparse.ArgumentParser: run.add_argument("--crate", action="append", default=[]) run.add_argument("--lane", action="append", choices=list(ALL_LANES)) run.add_argument("--all", action="store_true") - run.add_argument("--release-record", help=f"Path to {RELEASE_RECORD_SCHEMA} JSON") + run.add_argument("--release-record", help="Path to release record or candidate manifest JSON") run.add_argument("--work", help="Work directory (default: temp dir)") run.add_argument("--output", help="Write evidence JSON to this path") run.add_argument( @@ -917,7 +920,9 @@ def build_parser() -> argparse.ArgumentParser: ve.add_argument("--require-ok", action="store_true") ve.set_defaults(func=cmd_validate_evidence) - vr = sub.add_parser("validate-release-record", help=f"Validate {RELEASE_RECORD_SCHEMA}") + vr = sub.add_parser( + "validate-release-record", help="Validate a release record or candidate manifest" + ) vr.add_argument("path") vr.set_defaults(func=cmd_validate_release_record) diff --git a/scripts/ci/prepare-napi-packages.py b/scripts/ci/prepare-napi-packages.py new file mode 100644 index 000000000..2038b2dab --- /dev/null +++ b/scripts/ci/prepare-napi-packages.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +"""Add the required legal inventory to generated native npm packages.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import shutil + +LEGAL_FILES = ("LICENSE", "NOTICE", "THIRD_PARTY_NOTICES.md") + + +def prepare(npm_dir: Path, legal_dir: Path) -> None: + package_dirs = sorted(path.parent for path in npm_dir.glob("*/package.json")) + if not package_dirs: + raise ValueError(f"no generated npm packages under {npm_dir}") + for source_name in LEGAL_FILES: + if not (legal_dir / source_name).is_file(): + raise ValueError(f"legal source is missing: {legal_dir / source_name}") + for package_dir in package_dirs: + manifest_path = package_dir / "package.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + files = manifest.get("files") + if not isinstance(files, list): + raise ValueError(f"{manifest_path} files must be an array") + for source_name in LEGAL_FILES: + shutil.copyfile(legal_dir / source_name, package_dir / source_name) + if source_name not in files: + files.append(source_name) + manifest["files"] = files + manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--npm-dir", type=Path, required=True) + parser.add_argument("--legal-dir", type=Path, required=True) + args = parser.parse_args() + try: + prepare(args.npm_dir, args.legal_dir) + except (OSError, ValueError, json.JSONDecodeError) as error: + raise SystemExit(f"prepare-napi-packages: {error}") from error + + +if __name__ == "__main__": + main() diff --git a/scripts/ci/release-candidate.py b/scripts/ci/release-candidate.py index a9b842173..ef964edb5 100644 --- a/scripts/ci/release-candidate.py +++ b/scripts/ci/release-candidate.py @@ -1,153 +1,23 @@ #!/usr/bin/env python3 -"""Validate and query the immutable M1 release-candidate artifact bundle.""" +"""Validate and query the immutable partitioned release candidate.""" from __future__ import annotations import argparse -import hashlib -import json +from datetime import datetime from pathlib import Path -import re import sys -from typing import Any - -SCHEMA = "graphforge-release-record-v1" -SHA_RE = re.compile(r"[0-9a-f]{40}") -HASH_RE = re.compile(r"[0-9a-f]{64}") -CRATES = ( - "graphforge-core", - "graphforge-ast", - "graphforge-knowledge", - "graphforge-ontology", - "graphforge-provenance", - "graphforge-ir", - "graphforge-plan", - "graphforge-storage", - "graphforge-io", - "graphforge-rel", - "graphforge-search", - "graphforge-cypher", - "graphforge-exec", - "graphforge-api", - "graphforge-cli", -) -NPM_PACKAGES = ( - "@curatelabs/graphforge-darwin-arm64", - "@curatelabs/graphforge-darwin-x64", - "@curatelabs/graphforge-linux-arm64-gnu", - "@curatelabs/graphforge-linux-x64-gnu", - "@curatelabs/graphforge-win32-x64-msvc", - "@curatelabs/graphforge", - "@curatelabs/graphforge-cli", - "@curatelabs/graphforge-agent-skills", -) - - -class CandidateError(ValueError): - """The candidate bundle does not satisfy the release contract.""" - - -def _sha256(path: Path) -> str: - digest = hashlib.sha256() - with path.open("rb") as handle: - for chunk in iter(lambda: handle.read(1024 * 1024), b""): - digest.update(chunk) - return digest.hexdigest() - - -def _load(path: Path) -> dict[str, Any]: - try: - value = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as error: - raise CandidateError(f"cannot read release record {path}: {error}") from error - if not isinstance(value, dict): - raise CandidateError("release record must be a JSON object") - return value +sys.path.insert(0, str(Path(__file__).resolve().parent)) -def validate( - record_path: Path, artifacts_dir: Path, expected_sha: str, version: str -) -> dict[str, Any]: - if SHA_RE.fullmatch(expected_sha) is None: - raise CandidateError("expected SHA must be 40 lowercase hexadecimal characters") - record = _load(record_path) - if record.get("schema") != SCHEMA: - raise CandidateError(f"unexpected release record schema: {record.get('schema')!r}") - if record.get("version") != version or record.get("tag") != f"v{version}": - raise CandidateError("release record version/tag does not match the requested version") - if record.get("commit_sha") != expected_sha: - raise CandidateError("release record commit does not match the requested SHA") - - items = record.get("artifacts") - if not isinstance(items, list) or not items: - raise CandidateError("release record has no artifacts") - - seen_paths: set[str] = set() - names_by_surface: dict[str, set[str]] = {"pypi": set(), "npm": set(), "crates": set()} - wheel_count = 0 - sdist_count = 0 - addon_count = 0 - for index, item in enumerate(items): - if not isinstance(item, dict): - raise CandidateError(f"artifacts[{index}] must be an object") - relative = item.get("path") - digest = item.get("sha256") - if not isinstance(relative, str) or not relative or relative.startswith(("/", "../")): - raise CandidateError(f"artifacts[{index}] has an unsafe path") - if relative in seen_paths: - raise CandidateError(f"duplicate artifact path: {relative}") - seen_paths.add(relative) - path = artifacts_dir / relative - if not path.is_file(): - raise CandidateError(f"recorded artifact is missing: {relative}") - if not isinstance(digest, str) or HASH_RE.fullmatch(digest) is None: - raise CandidateError(f"artifacts[{index}] has an invalid SHA-256") - if _sha256(path) != digest: - raise CandidateError(f"artifact checksum mismatch: {relative}") - if item.get("version") != version: - raise CandidateError(f"artifact version mismatch: {relative}") - surface = item.get("surface") - name = item.get("name") - if surface in names_by_surface and isinstance(name, str): - names_by_surface[surface].add(name) - if item.get("class") == "python-wheel": - wheel_count += 1 - elif item.get("class") == "python-sdist": - sdist_count += 1 - elif item.get("class") == "node-addon": - addon_count += 1 - - actual_files = { - str(path.relative_to(artifacts_dir)) - for path in artifacts_dir.rglob("*") - if path.is_file() and not path.name.startswith(".") - } - if actual_files != seen_paths: - missing = sorted(seen_paths - actual_files) - extra = sorted(actual_files - seen_paths) - raise CandidateError(f"record/file inventory drift: missing={missing} extra={extra}") - if wheel_count != 3 or sdist_count != 1 or names_by_surface["pypi"] != {"graphforge"}: - raise CandidateError("candidate must contain three graphforge wheels plus its sdist") - if addon_count != 5: - raise CandidateError("candidate must contain the five tested Node addons") - if names_by_surface["npm"] != set(NPM_PACKAGES): - raise CandidateError( - "candidate npm set mismatch: " - f"expected={list(NPM_PACKAGES)} actual={sorted(names_by_surface['npm'])}" - ) - if names_by_surface["crates"] != set(CRATES): - raise CandidateError( - f"candidate crates set mismatch: expected={list(CRATES)} " - f"actual={sorted(names_by_surface['crates'])}" - ) - return record - - -def npm_paths(record: dict[str, Any]) -> list[str]: - by_name = { - item["name"]: item["path"] for item in record["artifacts"] if item.get("surface") == "npm" - } - return [by_name[name] for name in NPM_PACKAGES] +from release_candidate_manifest import ( # noqa: F401 + CRATES, + NPM_PACKAGES, + SCHEMA, + CandidateError, + npm_paths, + validate, +) def main(argv: list[str] | None = None) -> int: @@ -157,18 +27,30 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--artifacts-dir", type=Path, required=True) parser.add_argument("--expected-sha", required=True) parser.add_argument("--version", required=True) + parser.add_argument( + "--as-of", + help="ISO-8601 retention check time (default: current UTC time)", + ) args = parser.parse_args(argv) try: - record = validate(args.record, args.artifacts_dir, args.expected_sha, args.version) + as_of = datetime.fromisoformat(args.as_of.replace("Z", "+00:00")) if args.as_of else None + manifest = validate( + args.record, + args.artifacts_dir, + args.expected_sha, + args.version, + as_of=as_of, + ) if args.command == "npm-paths": - print("\n".join(npm_paths(record))) + print("\n".join(npm_paths(manifest))) else: print( f"release-candidate: valid version={args.version} " - f"sha={args.expected_sha} artifacts={len(record['artifacts'])}" + f"sha={args.expected_sha} artifacts={len(manifest['artifacts'])} " + f"nodes={len(manifest['nodes'])} groups=4" ) return 0 - except CandidateError as error: + except (CandidateError, ValueError) as error: print(f"release-candidate: {error}", file=sys.stderr) return 1 diff --git a/scripts/ci/release_candidate_manifest.py b/scripts/ci/release_candidate_manifest.py new file mode 100644 index 000000000..607c51e62 --- /dev/null +++ b/scripts/ci/release_candidate_manifest.py @@ -0,0 +1,804 @@ +#!/usr/bin/env python3 +"""Canonical, offline-verifiable GraphForge release-candidate manifest.""" + +from __future__ import annotations + +import base64 +from collections import defaultdict +from datetime import datetime, timedelta, timezone +from email.parser import Parser +import hashlib +import json +from pathlib import Path, PurePosixPath +import re +import tarfile +from typing import Any +import zipfile + +SCHEMA = "graphforge-release-candidate-v2" +SHA_RE = re.compile(r"[0-9a-f]{40}") +HASH_RE = re.compile(r"[0-9a-f]{64}") +GROUP_RETENTION_DAYS = 30 +PUBLICATION_STATES = { + "not_attempted": "no registry write has been attempted", + "absent": "authoritative registry lookup proves the release identity is absent", + "accepted_pending_visibility": "a write was accepted but public visibility is not verified", + "verified": "public registry identity, metadata, and bytes match the candidate", + "conflict": "the public identity exists but differs from the candidate", + "indeterminate": "authoritative registry truth cannot be classified safely", + "failed": "a deterministic local or registry operation failed", +} +CRATES = ( + "graphforge-core", + "graphforge-ast", + "graphforge-knowledge", + "graphforge-ontology", + "graphforge-provenance", + "graphforge-ir", + "graphforge-plan", + "graphforge-storage", + "graphforge-io", + "graphforge-rel", + "graphforge-search", + "graphforge-cypher", + "graphforge-exec", + "graphforge-api", + "graphforge-cli", +) +NATIVE_NPM_PACKAGES = ( + "@curatelabs/graphforge-darwin-arm64", + "@curatelabs/graphforge-darwin-x64", + "@curatelabs/graphforge-linux-arm64-gnu", + "@curatelabs/graphforge-linux-x64-gnu", + "@curatelabs/graphforge-win32-x64-msvc", +) +NPM_PACKAGES = ( + *NATIVE_NPM_PACKAGES, + "@curatelabs/graphforge", + "@curatelabs/graphforge-cli", + "@curatelabs/graphforge-agent-skills", +) +GROUPS = ("python", "npm", "crates", "evidence") + + +class CandidateError(ValueError): + """The candidate does not satisfy the immutable release contract.""" + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _integrity(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return "sha256-" + base64.b64encode(digest.digest()).decode("ascii") + + +def _safe_relative(value: str, *, context: str) -> str: + if not value or "\\" in value or "\x00" in value: + raise CandidateError(f"{context} has an unsafe path: {value!r}") + path = PurePosixPath(value) + if path.is_absolute() or any(part in {"", ".", ".."} for part in path.parts): + raise CandidateError(f"{context} has an unsafe path: {value!r}") + return value + + +def classify(path: Path) -> str: + name = path.name.lower() + if name.endswith(".whl"): + return "python-wheel" + if name.endswith(".crate"): + return "rust-crate" + if name.endswith(".tgz"): + return "npm-tarball" + if name.endswith(".tar.gz"): + return "python-sdist" + if name.endswith(".node"): + return "node-addon" + if "sbom" in name or name.endswith((".spdx.json", ".cdx.json")): + return "sbom" + if "provenance" in name: + return "provenance" + return "evidence" + + +def artifact_identity(path: Path, artifact_class: str, version: str) -> tuple[str, str]: + if artifact_class in {"python-wheel", "python-sdist"}: + return "pypi", "graphforge" + if artifact_class == "npm-tarball": + suffix = f"-{version}.tgz" + if path.name.startswith("curatelabs-") and path.name.endswith(suffix): + return "npm", "@curatelabs/" + path.name[len("curatelabs-") : -len(suffix)] + return "npm", path.stem + if artifact_class == "rust-crate": + suffix = f"-{version}.crate" + if path.name.endswith(suffix): + return "crates", path.name[: -len(suffix)] + return "crates", path.stem + return "evidence", path.name + + +class ArchiveView: + """A path-safe archive inventory with bounded reads for metadata files.""" + + def __init__(self, path: Path): + self.path = path + self.members: tuple[str, ...] + self._kind: str + if path.suffix == ".whl": + self._kind = "zip" + try: + with zipfile.ZipFile(path) as archive: + members: list[str] = [] + for info in archive.infolist(): + if info.is_dir(): + continue + name = _safe_relative(info.filename, context=f"archive {path.name}") + file_type = (info.external_attr >> 16) & 0o170000 + if file_type == 0o120000: + raise CandidateError(f"archive {path.name} contains symlink {name}") + members.append(name) + except zipfile.BadZipFile as error: + raise CandidateError(f"cannot read archive {path.name}: {error}") from error + else: + self._kind = "tar" + try: + with tarfile.open(path, mode="r:*") as archive: + members = [] + for info in archive.getmembers(): + if info.isdir(): + continue + name = _safe_relative(info.name, context=f"archive {path.name}") + if not info.isfile(): + raise CandidateError( + f"archive {path.name} contains non-regular member {name}" + ) + members.append(name) + except tarfile.TarError as error: + raise CandidateError(f"cannot read archive {path.name}: {error}") from error + if len(members) > 50_000: + raise CandidateError(f"archive {path.name} has an unreasonable member count") + if len(set(members)) != len(members): + raise CandidateError(f"archive {path.name} contains duplicate member paths") + self.members = tuple(sorted(members)) + + def read(self, member: str) -> bytes: + if member not in self.members: + raise CandidateError(f"archive {self.path.name} is missing {member}") + if self._kind == "zip": + with zipfile.ZipFile(self.path) as archive: + data = archive.read(member) + else: + with tarfile.open(self.path, mode="r:*") as archive: + extracted = archive.extractfile(member) + if extracted is None: + raise CandidateError(f"cannot read {member} from {self.path.name}") + data = extracted.read() + if len(data) > 4 * 1024 * 1024: + raise CandidateError(f"metadata member {member} is unexpectedly large") + return data + + def text(self, member: str) -> str: + try: + return self.read(member).decode("utf-8") + except UnicodeDecodeError as error: + raise CandidateError(f"archive member is not UTF-8: {member}") from error + + +def _strip_single_root(members: tuple[str, ...], expected: str) -> tuple[str, ...]: + prefix = expected.rstrip("/") + "/" + if not all(member.startswith(prefix) for member in members): + raise CandidateError(f"archive does not have the required root {expected}/") + return tuple(member[len(prefix) :] for member in members) + + +def _inventory_digest(members: tuple[str, ...]) -> str: + return hashlib.sha256(("\n".join(members) + "\n").encode()).hexdigest() + + +def _require(members: set[str], required: list[str], *, archive: str) -> None: + missing = sorted(set(required) - members) + if missing: + raise CandidateError(f"{archive} is incomplete; missing required files: {missing}") + + +def _package_json(view: ArchiveView) -> dict[str, Any]: + try: + value = json.loads(view.text("package/package.json")) + except json.JSONDecodeError as error: + raise CandidateError(f"{view.path.name} has invalid package.json: {error}") from error + if not isinstance(value, dict): + raise CandidateError(f"{view.path.name} package.json must be an object") + return value + + +def _validate_npm(view: ArchiveView, version: str) -> dict[str, Any]: + members = set(view.members) + metadata = _package_json(view) + name = metadata.get("name") + if name not in NPM_PACKAGES: + raise CandidateError(f"unexpected npm package identity: {name!r}") + if metadata.get("version") != version: + raise CandidateError(f"npm package {name} does not derive root version {version}") + required = ["package/package.json", "package/LICENSE", "package/NOTICE"] + dependencies: dict[str, str] = {} + if name in NATIVE_NPM_PACKAGES: + required.append("package/THIRD_PARTY_NOTICES.md") + addons = [member for member in members if member.endswith(".node")] + if len(addons) != 1: + raise CandidateError(f"npm native package {name} must contain exactly one addon") + required.append(addons[0]) + if metadata.get("main") != addons[0].removeprefix("package/"): + raise CandidateError(f"npm native package {name} main entrypoint is incomplete") + files = metadata.get("files") + expected_files = { + addons[0].removeprefix("package/"), + "LICENSE", + "NOTICE", + "THIRD_PARTY_NOTICES.md", + } + if not isinstance(files, list) or not expected_files.issubset(set(files)): + raise CandidateError(f"npm native package {name} files metadata is incomplete") + elif name == "@curatelabs/graphforge": + required += [ + "package/index.js", + "package/index.d.ts", + "package/THIRD_PARTY_NOTICES.md", + ] + if metadata.get("main") != "index.js" or metadata.get("types") != "index.d.ts": + raise CandidateError("npm main entrypoint metadata is incomplete") + optional = metadata.get("optionalDependencies") + if not isinstance(optional, dict): + raise CandidateError("npm main package lacks native optionalDependencies") + dependencies = {str(key): str(value) for key, value in optional.items()} + if dependencies != dict.fromkeys(NATIVE_NPM_PACKAGES, version): + raise CandidateError("npm main native dependency set/version is incomplete") + elif name == "@curatelabs/graphforge-cli": + required += [ + "package/bin/graphforge.js", + "package/lib/run.mjs", + "package/THIRD_PARTY_NOTICES.md", + ] + dependencies_raw = metadata.get("dependencies") + dependencies = ( + {str(key): str(value) for key, value in dependencies_raw.items()} + if isinstance(dependencies_raw, dict) + else {} + ) + if dependencies.get("@curatelabs/graphforge") != version: + raise CandidateError("npm CLI must depend on the exact root GraphForge version") + binaries = metadata.get("bin") + if not isinstance(binaries, dict) or set(binaries.values()) != {"bin/graphforge.js"}: + raise CandidateError("npm CLI binary metadata is incomplete") + else: + required += [ + "package/bin/graphforge-agent-skills.js", + "package/adapter/index.js", + "package/schemas/validator.js", + "package/workflows/index.js", + "package/compatibility.json", + "package/skills/README.md", + ] + if not any( + member.startswith("package/skills/") and member.endswith("/manifest.json") + for member in members + ): + raise CandidateError("agent-skills archive contains no skill manifests") + try: + compatibility = json.loads(view.text("package/compatibility.json")) + except json.JSONDecodeError as error: + raise CandidateError("agent-skills compatibility.json is invalid") from error + if ( + compatibility.get("package_version") != version + or compatibility.get("graphforge_release") != version + ): + raise CandidateError("agent-skills compatibility does not derive the root version") + package_compatibility = metadata.get("graphforgeCompatibility") + if ( + not isinstance(package_compatibility, dict) + or package_compatibility.get("release") != version + ): + raise CandidateError("agent-skills package metadata does not derive the root version") + _require(members, required, archive=view.path.name) + if metadata.get("license") != "Apache-2.0": + raise CandidateError(f"npm package {name} lacks Apache-2.0 metadata") + return { + "name": name, + "version": version, + "dependencies": dict(sorted(dependencies.items())), + "required_files": sorted(required), + } + + +def _validate_wheel(view: ArchiveView, version: str) -> dict[str, Any]: + members = set(view.members) + metadata_paths = [member for member in members if member.endswith(".dist-info/METADATA")] + if len(metadata_paths) != 1: + raise CandidateError(f"{view.path.name} must contain exactly one METADATA file") + metadata = Parser().parsestr(view.text(metadata_paths[0])) + if metadata.get("Name") != "graphforge" or metadata.get("Version") != version: + raise CandidateError(f"{view.path.name} Python identity/version mismatch") + if (metadata.get("License-Expression") or metadata.get("License")) != "Apache-2.0": + raise CandidateError(f"{view.path.name} lacks Apache-2.0 metadata") + required = ["graphforge/__init__.py", metadata_paths[0]] + native = [ + member + for member in members + if member.startswith("graphforge/_graphforge_rs.") + and member.endswith((".so", ".pyd", ".dylib")) + ] + if len(native) != 1: + raise CandidateError(f"{view.path.name} must contain one native Python module") + required.append(native[0]) + for legal in ("LICENSE", "NOTICE", "THIRD_PARTY_NOTICES.md"): + matches = [member for member in members if member.endswith("/" + legal)] + if len(matches) != 1: + raise CandidateError(f"{view.path.name} must contain exactly one {legal}") + required.append(matches[0]) + _require(members, required, archive=view.path.name) + return { + "name": "graphforge", + "version": version, + "dependencies": {}, + "required_files": sorted(required), + } + + +def _validate_sdist(view: ArchiveView, version: str) -> dict[str, Any]: + root = f"graphforge-{version}" + stripped = _strip_single_root(view.members, root) + members = set(stripped) + required = [ + "PKG-INFO", + "pyproject.toml", + "python/graphforge/__init__.py", + "crates/graphforge-bindings-py/src/lib.rs", + "LICENSE", + "NOTICE", + "THIRD_PARTY_NOTICES.md", + ] + _require(members, required, archive=view.path.name) + metadata = Parser().parsestr(view.text(f"{root}/PKG-INFO")) + if metadata.get("Name") != "graphforge" or metadata.get("Version") != version: + raise CandidateError(f"{view.path.name} Python identity/version mismatch") + if (metadata.get("License-Expression") or metadata.get("License")) != "Apache-2.0": + raise CandidateError(f"{view.path.name} lacks Apache-2.0 metadata") + return { + "name": "graphforge", + "version": version, + "dependencies": {}, + "required_files": sorted(f"{root}/{item}" for item in required), + } + + +def _validate_crate(view: ArchiveView, version: str) -> dict[str, Any]: + suffix = f"-{version}.crate" + if not view.path.name.endswith(suffix): + raise CandidateError(f"crate filename does not derive root version: {view.path.name}") + name = view.path.name[: -len(suffix)] + if name not in CRATES: + raise CandidateError(f"unexpected crates.io package identity: {name}") + root = f"{name}-{version}" + stripped = _strip_single_root(view.members, root) + members = set(stripped) + required = ["Cargo.toml", "LICENSE", "NOTICE"] + if "src/lib.rs" in members: + required.append("src/lib.rs") + elif "src/main.rs" in members: + required.append("src/main.rs") + else: + raise CandidateError(f"crate {name} has no Rust entrypoint") + _require(members, required, archive=view.path.name) + cargo = view.text(f"{root}/Cargo.toml") + package_match = re.search(r"(?ms)^\[package\]\s*(.*?)(?=^\[|\Z)", cargo) + if package_match is None: + raise CandidateError(f"crate {name} has no [package] metadata") + package_text = package_match.group(1) + name_match = re.search(r'(?m)^name\s*=\s*"([^"]+)"', package_text) + version_match = re.search(r'(?m)^version\s*=\s*"([^"]+)"', package_text) + if name_match is None or name_match.group(1) != name: + raise CandidateError(f"crate {name} package metadata mismatch") + if version_match is None or version_match.group(1) != version: + raise CandidateError(f"crate {name} does not derive root version {version}") + license_match = re.search(r'(?m)^license\s*=\s*"([^"]+)"', package_text) + license_file_match = re.search(r'(?m)^license-file\s*=\s*"([^"]+)"', package_text) + if not ( + (license_match is not None and license_match.group(1) == "Apache-2.0") + or (license_file_match is not None and license_file_match.group(1) == "LICENSE") + ): + raise CandidateError(f"crate {name} lacks Apache-2.0 license metadata") + dependencies: dict[str, str] = {} + dependencies_match = re.search(r"(?ms)^\[dependencies\]\s*(.*?)(?=^\[|\Z)", cargo) + if dependencies_match: + for dependency, value in re.findall( + r'(?m)^(graphforge-[a-z0-9-]+)\s*=\s*("[^"]+"|\{[^}]+\})', + dependencies_match.group(1), + ): + value_match = re.search(r'version\s*=\s*"([^"]+)"', value) + dep_version = ( + value.strip('"') + if value.startswith('"') + else (value_match.group(1) if value_match else None) + ) + if dep_version != version: + raise CandidateError( + f"crate {name} dependency {dependency} does not use root version {version}" + ) + dependencies[dependency] = version + for dependency, body in re.findall( + r"(?ms)^\[dependencies\.(graphforge-[a-z0-9-]+)\]\s*(.*?)(?=^\[|\Z)", cargo + ): + value_match = re.search(r'(?m)^version\s*=\s*"([^"]+)"', body) + if value_match is None or value_match.group(1) != version: + raise CandidateError( + f"crate {name} dependency {dependency} does not use root version {version}" + ) + dependencies[dependency] = version + return { + "name": name, + "version": version, + "dependencies": dict(sorted(dependencies.items())), + "required_files": sorted(f"{root}/{item}" for item in required), + } + + +def inspect_archive(path: Path, artifact_class: str, version: str) -> dict[str, Any]: + view = ArchiveView(path) + if artifact_class == "npm-tarball": + package = _validate_npm(view, version) + elif artifact_class == "python-wheel": + package = _validate_wheel(view, version) + elif artifact_class == "python-sdist": + package = _validate_sdist(view, version) + elif artifact_class == "rust-crate": + package = _validate_crate(view, version) + else: + raise CandidateError(f"{path.name} is not a package archive") + return { + "member_count": len(view.members), + "inventory_sha256": _inventory_digest(view.members), + "required_files": package.pop("required_files"), + "package": package, + } + + +def _group_for(relative: str, artifact_class: str) -> str: + first = PurePosixPath(relative).parts[0] + expected = { + "python-wheel": "python", + "python-sdist": "python", + "npm-tarball": "npm", + "rust-crate": "crates", + }.get(artifact_class, "evidence") + if first != expected and not (expected == "evidence" and first in {"evidence", "node-addons"}): + raise CandidateError( + f"artifact {relative} is routed to {first!r}, expected group {expected!r}" + ) + return expected + + +def scan_dist(dist_dir: Path, version: str) -> list[dict[str, Any]]: + artifacts: list[dict[str, Any]] = [] + if not dist_dir.exists(): + return artifacts + for path in sorted(dist_dir.rglob("*")): + if not path.is_file() or path.name.startswith("."): + continue + relative = _safe_relative(path.relative_to(dist_dir).as_posix(), context="artifact") + artifact_class = classify(path) + group = _group_for(relative, artifact_class) + surface, name = artifact_identity(path, artifact_class, version) + archive: dict[str, Any] | None = None + inspection_error: str | None = None + if artifact_class in {"python-wheel", "python-sdist", "npm-tarball", "rust-crate"}: + try: + archive = inspect_archive(path, artifact_class, version) + name = archive["package"]["name"] + except CandidateError as error: + inspection_error = str(error) + artifacts.append( + { + "path": relative, + "group": group, + "class": artifact_class, + "surface": surface, + "name": name, + "version": version, + "filename": path.name, + "bytes": path.stat().st_size, + "sha256": sha256_file(path), + "integrity": _integrity(path), + "archive": archive, + **({"inspection_error": inspection_error} if inspection_error else {}), + } + ) + return artifacts + + +def _node_id(surface: str, name: str) -> str: + return f"{surface}:{name}" + + +def _build_nodes( + artifacts: list[dict[str, Any]], +) -> tuple[list[dict[str, Any]], list[dict[str, str]]]: + paths: dict[str, list[str]] = defaultdict(list) + dependencies: dict[str, set[str]] = defaultdict(set) + registries: dict[str, tuple[str, str]] = {} + for artifact in artifacts: + if artifact["surface"] not in {"pypi", "npm", "crates"}: + continue + node = _node_id(artifact["surface"], artifact["name"]) + paths[node].append(artifact["path"]) + registries[node] = (artifact["surface"], artifact["name"]) + package = (artifact.get("archive") or {}).get("package", {}) + for dependency in package.get("dependencies", {}): + dep_surface = "npm" if artifact["surface"] == "npm" else "crates" + dependencies[node].add(_node_id(dep_surface, dependency)) + dependencies[_node_id("npm", "@curatelabs/graphforge-agent-skills")].add( + _node_id("npm", "@curatelabs/graphforge-cli") + ) + nodes = [ + { + "id": node, + "registry": registries[node][0], + "name": registries[node][1], + "artifact_paths": sorted(paths[node]), + } + for node in sorted(paths) + ] + edges = [ + {"from": node, "requires": dependency} + for node in sorted(dependencies) + for dependency in sorted(dependencies[node]) + ] + return nodes, edges + + +def build_manifest( + *, + version: str, + dist_dir: Path, + commit_sha: str, + recorded_at: str, + notes: str = "", +) -> dict[str, Any]: + created = datetime.fromisoformat(recorded_at.replace("Z", "+00:00")) + if created.tzinfo is None: + raise CandidateError("recorded_at must include a timezone") + artifacts = scan_dist(dist_dir, version) + nodes, dependencies = _build_nodes(artifacts) + groups = [] + for group in GROUPS: + paths = sorted(item["path"] for item in artifacts if item["group"] == group) + groups.append( + { + "id": group, + "directories": [group] if group != "evidence" else ["evidence", "node-addons"], + "retention_days": GROUP_RETENTION_DAYS, + "expires_at": ( + created.astimezone(timezone.utc) + timedelta(days=GROUP_RETENTION_DAYS) + ).isoformat(), + "artifact_paths": paths, + } + ) + return { + "schema": SCHEMA, + "version": version, + "tag": f"v{version}", + "commit_sha": commit_sha, + "recorded_at": created.astimezone(timezone.utc).isoformat(), + "publication_states": PUBLICATION_STATES, + "manifest_retention_days": GROUP_RETENTION_DAYS, + "artifact_groups": groups, + "nodes": nodes, + "dependencies": dependencies, + "artifacts": artifacts, + "notes": notes, + } + + +def _require_exact_public_nodes(nodes: list[dict[str, Any]]) -> None: + actual = {node["id"] for node in nodes} + expected = { + "pypi:graphforge", + *(_node_id("npm", name) for name in NPM_PACKAGES), + *(_node_id("crates", name) for name in CRATES), + } + if actual != expected: + raise CandidateError( + f"candidate public node set mismatch: missing={sorted(expected - actual)} " + f"extra={sorted(actual - expected)}" + ) + + +def _validate_graph(nodes: list[dict[str, Any]], edges: list[dict[str, str]]) -> None: + node_ids = {node["id"] for node in nodes} + incoming: dict[str, set[str]] = {node: set() for node in node_ids} + seen: set[tuple[str, str]] = set() + for edge in edges: + pair = (edge.get("from", ""), edge.get("requires", "")) + if pair in seen: + raise CandidateError(f"duplicate dependency edge: {pair}") + seen.add(pair) + if pair[0] not in node_ids or pair[1] not in node_ids: + raise CandidateError(f"dependency edge references missing node: {pair}") + incoming[pair[0]].add(pair[1]) + remaining = {node: set(deps) for node, deps in incoming.items()} + while remaining: + ready = sorted(node for node, deps in remaining.items() if not deps) + if not ready: + raise CandidateError( + f"candidate dependency graph contains a cycle: {sorted(remaining)}" + ) + for node in ready: + del remaining[node] + for deps in remaining.values(): + deps.difference_update(ready) + + +def validate( + manifest_path: Path, + artifacts_dir: Path, + expected_sha: str, + version: str, + *, + as_of: datetime | None = None, +) -> dict[str, Any]: + if SHA_RE.fullmatch(expected_sha) is None: + raise CandidateError("expected SHA must be 40 lowercase hexadecimal characters") + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise CandidateError(f"cannot read candidate manifest {manifest_path}: {error}") from error + if not isinstance(manifest, dict) or manifest.get("schema") != SCHEMA: + raise CandidateError(f"unexpected candidate manifest schema: {manifest.get('schema')!r}") + if manifest.get("version") != version or manifest.get("tag") != f"v{version}": + raise CandidateError("candidate version/tag does not match the requested root version") + if manifest.get("commit_sha") != expected_sha: + raise CandidateError("candidate commit does not match the requested SHA") + if manifest.get("publication_states") != PUBLICATION_STATES: + raise CandidateError("candidate publication state model is incomplete") + if manifest.get("manifest_retention_days") != GROUP_RETENTION_DAYS: + raise CandidateError("candidate manifest retention metadata is invalid") + try: + recorded_at = datetime.fromisoformat( + str(manifest.get("recorded_at")).replace("Z", "+00:00") + ) + except ValueError as error: + raise CandidateError("candidate recorded_at is invalid") from error + if recorded_at.tzinfo is None: + raise CandidateError("candidate recorded_at must include a timezone") + + artifacts = manifest.get("artifacts") + nodes = manifest.get("nodes") + edges = manifest.get("dependencies") + groups = manifest.get("artifact_groups") + if not isinstance(artifacts, list) or not artifacts: + raise CandidateError("candidate manifest has no artifacts") + if not isinstance(nodes, list) or not isinstance(edges, list) or not isinstance(groups, list): + raise CandidateError("candidate nodes, dependencies, and groups must be arrays") + if any("version" in node for node in nodes): + raise CandidateError("public nodes may not override the root release version") + node_ids = [node.get("id") for node in nodes] + if any(not isinstance(node, str) or not node for node in node_ids): + raise CandidateError("every public node requires an id") + if len(set(node_ids)) != len(node_ids): + raise CandidateError("candidate contains duplicate public nodes") + _require_exact_public_nodes(nodes) + _validate_graph(nodes, edges) + + group_by_id = {group.get("id"): group for group in groups if isinstance(group, dict)} + if set(group_by_id) != set(GROUPS) or len(group_by_id) != len(groups): + raise CandidateError("candidate artifact groups must be exactly python/npm/crates/evidence") + now = as_of or datetime.now(timezone.utc) + if now.tzinfo is None: + raise CandidateError("retention check time must include a timezone") + grouped_paths: set[str] = set() + for group_id in GROUPS: + group = group_by_id[group_id] + expected_directories = [group_id] if group_id != "evidence" else ["evidence", "node-addons"] + if group.get("directories") != expected_directories: + raise CandidateError(f"artifact group {group_id} directories are invalid") + if group.get("retention_days") != GROUP_RETENTION_DAYS: + raise CandidateError(f"artifact group {group_id} retention is invalid") + try: + expiry = datetime.fromisoformat(str(group.get("expires_at")).replace("Z", "+00:00")) + except ValueError as error: + raise CandidateError(f"artifact group {group_id} expiry is invalid") from error + if expiry != recorded_at + timedelta(days=GROUP_RETENTION_DAYS): + raise CandidateError(f"artifact group {group_id} expiry does not match retention") + if expiry <= now: + raise CandidateError(f"artifact group {group_id} retention has expired") + paths = group.get("artifact_paths") + if not isinstance(paths, list) or not paths: + raise CandidateError(f"artifact group {group_id} is missing") + for relative in paths: + _safe_relative(relative, context=f"artifact group {group_id}") + if relative in grouped_paths: + raise CandidateError(f"artifact belongs to multiple groups: {relative}") + grouped_paths.add(relative) + + seen_paths: set[str] = set() + names_by_surface: dict[str, set[str]] = defaultdict(set) + class_counts: dict[str, int] = defaultdict(int) + for index, item in enumerate(artifacts): + if not isinstance(item, dict): + raise CandidateError(f"artifacts[{index}] must be an object") + relative = _safe_relative(item.get("path", ""), context=f"artifacts[{index}]") + if relative in seen_paths: + raise CandidateError(f"duplicate artifact path: {relative}") + seen_paths.add(relative) + if ( + item.get("group") not in GROUPS + or relative not in group_by_id[item["group"]]["artifact_paths"] + ): + raise CandidateError(f"artifact group membership mismatch: {relative}") + path = artifacts_dir / relative + if not path.is_file(): + raise CandidateError(f"recorded artifact is missing: {relative}") + digest = item.get("sha256") + if not isinstance(digest, str) or HASH_RE.fullmatch(digest) is None: + raise CandidateError(f"artifacts[{index}] has an invalid SHA-256") + if sha256_file(path) != digest: + raise CandidateError(f"artifact checksum mismatch: {relative}") + if item.get("integrity") != _integrity(path): + raise CandidateError(f"artifact integrity mismatch: {relative}") + if item.get("bytes") != path.stat().st_size: + raise CandidateError(f"artifact byte count mismatch: {relative}") + if item.get("version") != version: + raise CandidateError(f"artifact version mismatch: {relative}") + artifact_class = item.get("class") + class_counts[str(artifact_class)] += 1 + surface = item.get("surface") + name = item.get("name") + if surface in {"pypi", "npm", "crates"} and isinstance(name, str): + names_by_surface[surface].add(name) + if artifact_class in {"python-wheel", "python-sdist", "npm-tarball", "rust-crate"}: + inspected = inspect_archive(path, artifact_class, version) + if item.get("inspection_error") is not None or item.get("archive") != inspected: + raise CandidateError(f"archive inventory/completeness mismatch: {relative}") + if inspected["package"]["name"] != name: + raise CandidateError(f"archive package identity mismatch: {relative}") + actual_files = { + path.relative_to(artifacts_dir).as_posix() + for path in artifacts_dir.rglob("*") + if path.is_file() and not path.name.startswith(".") + } + if actual_files != seen_paths or grouped_paths != seen_paths: + raise CandidateError( + "candidate file/group inventory drift: " + f"unrecorded={sorted(actual_files - seen_paths)} " + f"missing={sorted(seen_paths - actual_files)} " + f"ungrouped={sorted(seen_paths - grouped_paths)}" + ) + if class_counts["python-wheel"] != 3 or class_counts["python-sdist"] != 1: + raise CandidateError("candidate must contain three graphforge wheels and one sdist") + if class_counts["node-addon"] != 5: + raise CandidateError("candidate evidence must contain the five tested Node addons") + if names_by_surface["pypi"] != {"graphforge"}: + raise CandidateError("candidate PyPI identity is incomplete") + if names_by_surface["npm"] != set(NPM_PACKAGES): + raise CandidateError("candidate npm package set is incomplete") + if names_by_surface["crates"] != set(CRATES): + raise CandidateError("candidate crates.io package set is incomplete") + expected_nodes, expected_edges = _build_nodes(artifacts) + if nodes != expected_nodes: + raise CandidateError("public node artifact membership is incomplete") + if edges != expected_edges: + raise CandidateError("candidate dependency metadata is incomplete") + return manifest + + +def npm_paths(manifest: dict[str, Any]) -> list[str]: + by_name = { + item["name"]: item["path"] for item in manifest["artifacts"] if item.get("surface") == "npm" + } + return [by_name[name] for name in NPM_PACKAGES] diff --git a/scripts/ci/test-binding-release-candidate.py b/scripts/ci/test-binding-release-candidate.py index 24735664b..4c4ce3358 100644 --- a/scripts/ci/test-binding-release-candidate.py +++ b/scripts/ci/test-binding-release-candidate.py @@ -595,10 +595,16 @@ def main() -> None: assert "../../../scripts/ci/validate-napi-artifacts.py" not in release_candidate_job assert "--skip-optional-publish --no-gh-release" in release_candidate_job assert 'npm pack "./$package_dir"' in release_candidate_job - assert "npm pack ./packages/cli" in release_candidate_job - assert "npm pack ./packages/agent-skills" in release_candidate_job + assert "scripts/ci/prepare-napi-packages.py" in release_candidate_job + assert "pnpm --dir packages/cli pack" in release_candidate_job + assert "pnpm --dir packages/agent-skills pack" in release_candidate_job assert 'cargo package "${package_args[@]}" --allow-dirty --no-verify' in (release_candidate_job) assert 'cargo package -p "$crate"' not in release_candidate_job + assert "scripts/set_release_version.py --check" in release_candidate_job + assert "${crate}-${RELEASE_VERSION}.crate" in release_candidate_job + assert '--version "$RELEASE_VERSION"' in release_candidate_job + assert "--version 0.5.0" not in release_candidate_job + assert "${crate}-0.5.0.crate" not in release_candidate_job assert "scripts/ci/release-candidate.py validate" in rc_workflow_text assert "M1-Release-Candidate-${{ needs.validate_source.outputs.evidence_sha }}" in ( rc_workflow_text diff --git a/scripts/ci/test-clean-env-verify.py b/scripts/ci/test-clean-env-verify.py index a70034f18..945854a24 100644 --- a/scripts/ci/test-clean-env-verify.py +++ b/scripts/ci/test-clean-env-verify.py @@ -59,6 +59,9 @@ def test_reject_dev_version() -> None: def test_validate_release_record_ok() -> None: cev.validate_release_record(sample_record()) + candidate = sample_record() + candidate["schema"] = cev.RELEASE_CANDIDATE_SCHEMA + cev.validate_release_record(candidate) def test_validate_release_record_bad_digest() -> None: diff --git a/scripts/ci/test-prepare-napi-packages.py b/scripts/ci/test-prepare-napi-packages.py new file mode 100644 index 000000000..6afca2ec0 --- /dev/null +++ b/scripts/ci/test-prepare-napi-packages.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""Tests for native npm legal-inventory preparation.""" + +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path +import tempfile + +SCRIPT = Path(__file__).with_name("prepare-napi-packages.py") +SPEC = importlib.util.spec_from_file_location("prepare_napi_packages", SCRIPT) +assert SPEC and SPEC.loader +module = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(module) + +with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + npm = root / "npm" + package = npm / "darwin-arm64" + package.mkdir(parents=True) + (package / "package.json").write_text( + json.dumps({"name": "@curatelabs/graphforge-darwin-arm64", "files": ["addon.node"]}), + encoding="utf-8", + ) + (package / "addon.node").write_bytes(b"native") + legal = root / "legal" + legal.mkdir() + for name in module.LEGAL_FILES: + (legal / name).write_text(name, encoding="utf-8") + + module.prepare(npm, legal) + manifest = json.loads((package / "package.json").read_text(encoding="utf-8")) + assert manifest["files"] == ["addon.node", *module.LEGAL_FILES] + for name in module.LEGAL_FILES: + assert (package / name).read_text(encoding="utf-8") == name + + (legal / "NOTICE").unlink() + try: + module.prepare(npm, legal) + except ValueError as error: + assert "legal source is missing" in str(error) + else: + raise AssertionError("missing legal input was accepted") + +print("prepare-napi-packages tests passed") diff --git a/scripts/ci/test-release-candidate.py b/scripts/ci/test-release-candidate.py index 3eeda8ca3..c4557f767 100644 --- a/scripts/ci/test-release-candidate.py +++ b/scripts/ci/test-release-candidate.py @@ -1,100 +1,344 @@ #!/usr/bin/env python3 -"""Mutation-sensitive tests for the immutable M1 release-candidate bundle.""" +"""Mutation-sensitive tests for the partitioned release-candidate contract.""" from __future__ import annotations -import hashlib +import copy +from datetime import datetime, timedelta, timezone import importlib.util +import io import json from pathlib import Path +import shutil +import tarfile import tempfile +import zipfile SCRIPT = Path(__file__).with_name("release-candidate.py") SPEC = importlib.util.spec_from_file_location("release_candidate", SCRIPT) assert SPEC and SPEC.loader release_candidate = importlib.util.module_from_spec(SPEC) SPEC.loader.exec_module(release_candidate) +manifest_module = __import__("release_candidate_manifest") +SHA = "a" * 40 +VERSION = "0.5.0" -def _artifact(root: Path, relative: str, surface: str, name: str, kind: str) -> dict[str, object]: - path = root / relative +def write_tar(path: Path, members: dict[str, bytes]) -> None: path.parent.mkdir(parents=True, exist_ok=True) - path.write_bytes(relative.encode()) - return { - "path": relative, - "class": kind, - "surface": surface, + with tarfile.open(path, "w:gz") as archive: + for name, data in sorted(members.items()): + info = tarfile.TarInfo(name) + info.size = len(data) + info.mtime = 0 + archive.addfile(info, io.BytesIO(data)) + + +def write_wheel(path: Path, *, omit: str | None = None) -> None: + dist = f"graphforge-{VERSION}.dist-info" + members = { + "graphforge/__init__.py": b"from ._graphforge_rs import *\n", + "graphforge/_graphforge_rs.abi3.so": b"native", + f"{dist}/METADATA": ( + f"Name: graphforge\nVersion: {VERSION}\nLicense-Expression: Apache-2.0\n" + ).encode(), + f"{dist}/WHEEL": b"Wheel-Version: 1.0\n", + f"{dist}/licenses/LICENSE": b"Apache-2.0", + f"{dist}/licenses/NOTICE": b"GraphForge", + f"{dist}/licenses/THIRD_PARTY_NOTICES.md": b"Third party", + } + if omit: + members.pop(omit) + path.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(path, "w") as archive: + for name, data in sorted(members.items()): + archive.writestr(name, data) + + +def npm_members(name: str, *, package_version: str = VERSION) -> dict[str, bytes]: + metadata: dict[str, object] = { "name": name, - "version": "0.5.0", - "filename": path.name, - "bytes": path.stat().st_size, - "sha256": hashlib.sha256(path.read_bytes()).hexdigest(), + "version": package_version, + "license": "Apache-2.0", + } + members = { + "package/LICENSE": b"Apache-2.0", + "package/NOTICE": b"GraphForge", } + if name in manifest_module.NATIVE_NPM_PACKAGES: + members["package/THIRD_PARTY_NOTICES.md"] = b"Third party" + members["package/graphforge.node"] = b"native" + metadata["main"] = "graphforge.node" + metadata["files"] = [ + "graphforge.node", + "LICENSE", + "NOTICE", + "THIRD_PARTY_NOTICES.md", + ] + elif name == "@curatelabs/graphforge": + metadata.update( + { + "main": "index.js", + "types": "index.d.ts", + "optionalDependencies": dict.fromkeys(manifest_module.NATIVE_NPM_PACKAGES, VERSION), + } + ) + members.update( + { + "package/index.js": b"module.exports = {}\n", + "package/index.d.ts": b"export declare function version(): string\n", + "package/lib/index.mjs": b"export const loaded = true\n", + "package/THIRD_PARTY_NOTICES.md": b"Third party", + } + ) + elif name == "@curatelabs/graphforge-cli": + metadata.update( + { + "bin": {"graphforge": "bin/graphforge.js", "gf": "bin/graphforge.js"}, + "dependencies": {"@curatelabs/graphforge": VERSION}, + } + ) + members.update( + { + "package/bin/graphforge.js": b"#!/usr/bin/env node\n", + "package/lib/run.mjs": b"export function run() {}\n", + "package/THIRD_PARTY_NOTICES.md": b"Third party", + } + ) + else: + metadata["bin"] = {"graphforge-agent-skills": "bin/graphforge-agent-skills.js"} + metadata["graphforgeCompatibility"] = {"release": package_version} + members.update( + { + "package/bin/graphforge-agent-skills.js": b"#!/usr/bin/env node\n", + "package/adapter/index.js": b"export {}\n", + "package/schemas/validator.js": b"export {}\n", + "package/workflows/index.js": b"export {}\n", + "package/skills/README.md": b"# GraphForge skills\n", + "package/skills/graphforge/manifest.json": b"{}\n", + "package/compatibility.json": json.dumps( + { + "package_version": package_version, + "graphforge_release": package_version, + } + ).encode(), + } + ) + members["package/package.json"] = json.dumps(metadata, sort_keys=True).encode() + return members -def _fixture(root: Path) -> tuple[Path, Path]: - artifacts: list[dict[str, object]] = [] +def create_candidate( + root: Path, + *, + omit_npm: tuple[str, str] | None = None, + divergent_npm: str | None = None, + omit_wheel: str | None = None, + omit_crate_notice: str | None = None, +) -> tuple[Path, Path, dict[str, object]]: + artifacts = root / "artifacts" for platform in ("linux", "macos", "windows"): - artifacts.append( - _artifact( - root, f"python/graphforge-{platform}.whl", "pypi", "graphforge", "python-wheel" - ) + omit = omit_wheel if platform == "linux" else None + write_wheel( + artifacts / "python" / f"graphforge-{VERSION}-{platform}.whl", + omit=omit, ) - for target in ("darwin-arm64", "darwin-x64", "linux-arm64", "linux-x64", "win32-x64"): - artifacts.append( - _artifact(root, f"node-addons/graphforge.{target}.node", "github", target, "node-addon") - ) - artifacts.append( - _artifact(root, "python/graphforge-0.5.0.tar.gz", "pypi", "graphforge", "python-sdist") + sdist_root = f"graphforge-{VERSION}" + write_tar( + artifacts / "python" / f"graphforge-{VERSION}.tar.gz", + { + f"{sdist_root}/PKG-INFO": ( + f"Name: graphforge\nVersion: {VERSION}\nLicense-Expression: Apache-2.0\n" + ).encode(), + f"{sdist_root}/pyproject.toml": b"[project]\nname='graphforge'\n", + f"{sdist_root}/python/graphforge/__init__.py": b"", + f"{sdist_root}/crates/graphforge-bindings-py/src/lib.rs": b"", + f"{sdist_root}/LICENSE": b"Apache-2.0", + f"{sdist_root}/NOTICE": b"GraphForge", + f"{sdist_root}/THIRD_PARTY_NOTICES.md": b"Third party", + }, ) - for index, name in enumerate(release_candidate.NPM_PACKAGES): - artifacts.append(_artifact(root, f"npm/{index}.tgz", "npm", name, "npm-tarball")) - for name in release_candidate.CRATES: - artifacts.append( - _artifact(root, f"crates/{name}-0.5.0.crate", "crates", name, "rust-crate") + for name in manifest_module.NPM_PACKAGES: + members = npm_members(name, package_version="0.5.1" if name == divergent_npm else VERSION) + if omit_npm and omit_npm[0] == name: + members.pop(omit_npm[1]) + filename = name.removeprefix("@curatelabs/").replace("/", "-") + write_tar( + artifacts / "npm" / f"curatelabs-{filename}-{VERSION}.tgz", + members, ) - record = { - "schema": release_candidate.SCHEMA, - "version": "0.5.0", - "tag": "v0.5.0", - "commit_sha": "a" * 40, - "artifacts": artifacts, - } - record_path = root.parent / "record.json" - record_path.write_text(json.dumps(record), encoding="utf-8") - return record_path, root + for name in manifest_module.CRATES: + crate_root = f"{name}-{VERSION}" + dependency = ( + "" + if name == "graphforge-core" + else (f'graphforge-core = {{ version = "{VERSION}" }}\n') + ) + members = { + f"{crate_root}/Cargo.toml": ( + f'[package]\nname = "{name}"\nversion = "{VERSION}"\n' + 'license = "Apache-2.0"\n[dependencies]\n' + dependency + ).encode(), + f"{crate_root}/src/lib.rs": b"pub fn candidate() {}\n", + f"{crate_root}/LICENSE": b"Apache-2.0", + f"{crate_root}/NOTICE": b"GraphForge", + } + if name == omit_crate_notice: + members.pop(f"{crate_root}/NOTICE") + write_tar(artifacts / "crates" / f"{name}-{VERSION}.crate", members) + for target in ("darwin-arm64", "darwin-x64", "linux-arm64", "linux-x64", "win32-x64"): + path = artifacts / "node-addons" / f"graphforge.{target}.node" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(target.encode()) + evidence = artifacts / "evidence" / "offline-rehearsal.json" + evidence.parent.mkdir(parents=True, exist_ok=True) + evidence.write_text('{"offline":true}\n', encoding="utf-8") + recorded_at = datetime.now(timezone.utc).replace(microsecond=0).isoformat() + manifest = manifest_module.build_manifest( + version=VERSION, + dist_dir=artifacts, + commit_sha=SHA, + recorded_at=recorded_at, + notes="deterministic offline fixture", + ) + manifest_path = root / "candidate-manifest.json" + manifest_path.write_text(json.dumps(manifest, indent=2, sort_keys=True), encoding="utf-8") + return manifest_path, artifacts, manifest + + +def rejected( + manifest_path: Path, + artifacts: Path, + message: str, + *, + as_of: datetime | None = None, +) -> None: + try: + release_candidate.validate(manifest_path, artifacts, SHA, VERSION, as_of=as_of) + except release_candidate.CandidateError as error: + assert message in str(error), error + else: + raise AssertionError(f"candidate mutation was accepted: {message}") + + +def write_mutation(root: Path, manifest: dict[str, object]) -> Path: + path = root / "mutated-manifest.json" + path.write_text(json.dumps(manifest, indent=2, sort_keys=True), encoding="utf-8") + return path def main() -> None: with tempfile.TemporaryDirectory() as temp: - root = Path(temp) / "artifacts" - record_path, artifacts_dir = _fixture(root) - record = release_candidate.validate(record_path, artifacts_dir, "a" * 40, "0.5.0") - assert len(release_candidate.npm_paths(record)) == 8 - assert release_candidate.npm_paths(record)[-3:] == ["npm/5.tgz", "npm/6.tgz", "npm/7.tgz"] + root = Path(temp) + manifest_path, artifacts, manifest = create_candidate(root) + validated = release_candidate.validate(manifest_path, artifacts, SHA, VERSION) + assert len(validated["nodes"]) == 24 + assert len(release_candidate.npm_paths(validated)) == 8 + assert set(validated["publication_states"]) == set(manifest_module.PUBLICATION_STATES) + rebuilt = manifest_module.build_manifest( + version=VERSION, + dist_dir=artifacts, + commit_sha=SHA, + recorded_at=manifest["recorded_at"], + notes="deterministic offline fixture", + ) + assert rebuilt == manifest + + assembled = root / "offline-assembled" + for item in validated["artifacts"]: + source = artifacts / item["path"] + destination = assembled / item["path"] + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source, destination) + release_candidate.validate(manifest_path, assembled, SHA, VERSION) - target = artifacts_dir / record["artifacts"][0]["path"] + target = artifacts / validated["artifacts"][0]["path"] target.write_bytes(b"mutated") - try: - release_candidate.validate(record_path, artifacts_dir, "a" * 40, "0.5.0") - except release_candidate.CandidateError as error: - assert "checksum mismatch" in str(error) - else: - raise AssertionError("checksum mutation should fail") + rejected(manifest_path, artifacts, "checksum mismatch") + + completeness_cases = ( + ({"omit_npm": ("@curatelabs/graphforge", "package/index.js")}, "index.js"), + ({"omit_npm": ("@curatelabs/graphforge", "package/index.d.ts")}, "index.d.ts"), + ({"omit_npm": ("@curatelabs/graphforge-cli", "package/bin/graphforge.js")}, "bin"), + ( + { + "omit_npm": ( + "@curatelabs/graphforge-agent-skills", + "package/skills/graphforge/manifest.json", + ) + }, + "skill manifests", + ), + ({"omit_wheel": "graphforge/_graphforge_rs.abi3.so"}, "native Python module"), + ({"omit_wheel": "graphforge/__init__.py"}, "graphforge/__init__.py"), + ( + { + "omit_npm": ( + "@curatelabs/graphforge-darwin-arm64", + "package/graphforge.node", + ) + }, + "native package", + ), + ({"omit_crate_notice": "graphforge-api"}, "NOTICE"), + ({"divergent_npm": "@curatelabs/graphforge-cli"}, "root version"), + ) + for options, message in completeness_cases: + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + manifest_path, artifacts, _ = create_candidate(root, **options) + rejected(manifest_path, artifacts, message) with tempfile.TemporaryDirectory() as temp: - root = Path(temp) / "artifacts" - record_path, artifacts_dir = _fixture(root) - record = json.loads(record_path.read_text(encoding="utf-8")) - npm_item = next(item for item in record["artifacts"] if item["surface"] == "npm") - npm_item["version"] = "0.5.1" - record_path.write_text(json.dumps(record), encoding="utf-8") - try: - release_candidate.validate(record_path, artifacts_dir, "a" * 40, "0.5.0") - except release_candidate.CandidateError as error: - assert "artifact version mismatch" in str(error) - else: - raise AssertionError("ADR 0017 forbids npm/core version divergence") + root = Path(temp) + manifest_path, artifacts, manifest = create_candidate(root) + mutated = copy.deepcopy(manifest) + mutated["nodes"][0]["version"] = VERSION + rejected(write_mutation(root, mutated), artifacts, "may not override") + + mutated = copy.deepcopy(manifest) + mutated["nodes"].append(copy.deepcopy(mutated["nodes"][0])) + rejected(write_mutation(root, mutated), artifacts, "duplicate public nodes") + + mutated = copy.deepcopy(manifest) + mutated["dependencies"] = mutated["dependencies"][1:] + rejected(write_mutation(root, mutated), artifacts, "dependency metadata") + + mutated = copy.deepcopy(manifest) + mutated["dependencies"].append( + {"from": "crates:graphforge-core", "requires": "crates:graphforge-api"} + ) + rejected(write_mutation(root, mutated), artifacts, "cycle") + + mutated = copy.deepcopy(manifest) + mutated["artifacts"][0]["path"] = "../escape.whl" + rejected(write_mutation(root, mutated), artifacts, "unsafe path") + + mutated = copy.deepcopy(manifest) + mutated["artifact_groups"][1]["artifact_paths"].append( + mutated["artifact_groups"][0]["artifact_paths"][0] + ) + rejected(write_mutation(root, mutated), artifacts, "multiple groups") + + mutated = copy.deepcopy(manifest) + mutated["artifact_groups"] = mutated["artifact_groups"][:-1] + rejected(write_mutation(root, mutated), artifacts, "exactly python/npm/crates/evidence") + + mutated = copy.deepcopy(manifest) + mutated["publication_states"].pop("indeterminate") + rejected(write_mutation(root, mutated), artifacts, "state model") + + expiry = datetime.fromisoformat(manifest["artifact_groups"][0]["expires_at"]) + rejected( + manifest_path, + artifacts, + "retention has expired", + as_of=expiry + timedelta(seconds=1), + ) + + malformed = copy.deepcopy(manifest) + malformed["schema"] = "unknown" + rejected(write_mutation(root, malformed), artifacts, "unexpected candidate manifest schema") print("release-candidate tests: ok") diff --git a/scripts/publish_crates.py b/scripts/publish_crates.py index 27820f1f0..235541747 100755 --- a/scripts/publish_crates.py +++ b/scripts/publish_crates.py @@ -158,7 +158,10 @@ def release_record_checksums(record_path: Path, artifacts_dir: Path) -> dict[str record = json.loads(record_path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as error: raise RuntimeError(f"cannot read release record {record_path}: {error}") from error - if record.get("schema") != "graphforge-release-record-v1": + if record.get("schema") not in { + "graphforge-release-record-v1", + "graphforge-release-candidate-v2", + }: raise RuntimeError("unexpected release record schema") if record.get("version") != VERSION or record.get("tag") != f"v{VERSION}": raise RuntimeError("release record version/tag does not match the Cargo version") @@ -212,7 +215,7 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument( "--release-record", type=Path, - help="Certified graphforge-release-record-v1 JSON", + help="Certified release record or candidate-manifest JSON", ) parser.add_argument( "--artifacts-dir", diff --git a/scripts/record_release_artifacts.py b/scripts/record_release_artifacts.py index 2b90ade02..fe603d0fc 100644 --- a/scripts/record_release_artifacts.py +++ b/scripts/record_release_artifacts.py @@ -1,117 +1,46 @@ #!/usr/bin/env python3 -"""Record checksums and a contents inventory for release-candidate artifacts. - -Builds a JSON record suitable for the M1 release close (#192), the GitHub -Release outcome (#194), and post-release clean-env checksum matching (#167). -Does not create the GitHub Release. - -Usage: - python3 scripts/record_release_artifacts.py \\ - --dist-dir dist \\ - --version 0.5.0 \\ - --out docs/releases/records/v0.5.0-artifacts.json - - # Or record npm pack / cargo package outputs already on disk: - python3 scripts/record_release_artifacts.py --dist-dir target/release-artifacts --version 0.5.0 -""" +"""Create the canonical manifest for exact, already-built release artifacts.""" from __future__ import annotations import argparse -from datetime import datetime, timezone -import hashlib import json from pathlib import Path import subprocess import sys -from typing import Any ROOT = Path(__file__).resolve().parents[1] -RELEASE_RECORD_SCHEMA = "graphforge-release-record-v1" +sys.path.insert(0, str(ROOT / "scripts" / "ci")) +from release_candidate_manifest import SCHEMA as RELEASE_RECORD_SCHEMA # noqa: E402, F401 +from release_candidate_manifest import ( # noqa: E402, F401 + artifact_identity, + build_manifest, + classify, + scan_dist, + sha256_file, +) -def _git_sha() -> str: + +def _git_value(*args: str) -> str: result = subprocess.run( - ["git", "-C", str(ROOT), "rev-parse", "--verify", "HEAD"], + ["git", "-C", str(ROOT), *args], check=False, capture_output=True, text=True, encoding="utf-8", ) - return result.stdout.strip() if result.returncode == 0 else "unknown" - - -def sha256_file(path: Path) -> str: - digest = hashlib.sha256() - with path.open("rb") as handle: - for chunk in iter(lambda: handle.read(1024 * 1024), b""): - digest.update(chunk) - return digest.hexdigest() - - -def classify(path: Path) -> str: - name = path.name.lower() - if name.endswith(".whl"): - return "python-wheel" - if name.endswith(".tar.gz") and "graphforge" in name and "gf-" not in name: - return "python-sdist" - if name.endswith(".crate"): - return "rust-crate" - if name.endswith(".tgz") or (name.endswith(".tar.gz") and "graphforge" in name): - return "npm-tarball" - if name.endswith(".node"): - return "node-addon" - if "sbom" in name or name.endswith(".spdx.json") or name.endswith(".cdx.json"): - return "sbom" - if "provenance" in name: - return "provenance" - return "other" - - -def artifact_identity(path: Path, artifact_class: str, version: str) -> tuple[str, str]: - """Return the public registry surface and package name for an artifact.""" - if artifact_class in {"python-wheel", "python-sdist"}: - return "pypi", "graphforge" - if artifact_class == "npm-tarball": - suffix = f"-{version}.tgz" - normalized = path.name - if normalized.startswith("curatelabs-") and normalized.endswith(suffix): - package = normalized[len("curatelabs-") : -len(suffix)] - return "npm", f"@curatelabs/{package}" - return "npm", path.stem - if artifact_class == "rust-crate": - suffix = f"-{version}.crate" - if path.name.startswith("graphforge-") and path.name.endswith(suffix): - return "crates", path.name[: -len(suffix)] - return "crates", path.stem - return "github", path.name - - -def scan_dist(dist_dir: Path, version: str) -> list[dict[str, Any]]: - artifacts: list[dict[str, Any]] = [] - if not dist_dir.exists(): - return artifacts - for path in sorted(dist_dir.rglob("*")): - if not path.is_file(): - continue - if path.name.startswith("."): - continue - rel = str(path.relative_to(dist_dir)) - artifact_class = classify(path) - surface, name = artifact_identity(path, artifact_class, version) - artifacts.append( - { - "path": rel, - "class": artifact_class, - "surface": surface, - "name": name, - "version": version, - "filename": path.name, - "bytes": path.stat().st_size, - "sha256": sha256_file(path), - } - ) - return artifacts + if result.returncode != 0: + raise RuntimeError((result.stderr or result.stdout).strip()) + return result.stdout.strip() + + +def _git_sha() -> str: + return _git_value("rev-parse", "--verify", "HEAD") + + +def _git_recorded_at() -> str: + return _git_value("show", "-s", "--format=%cI", "HEAD") def build_record( @@ -119,96 +48,57 @@ def build_record( version: str, dist_dir: Path, notes: str | None, -) -> dict[str, Any]: - artifacts = scan_dist(dist_dir, version) - commit_sha = _git_sha() - return { - "schema": RELEASE_RECORD_SCHEMA, - "version": version, - "tag": f"v{version}", - "commit_sha": commit_sha, - "recorded_at": datetime.now(timezone.utc).isoformat(), - "dist_dir": str(dist_dir), - "same_tagged_commit_policy": ( - "Every first-party publishable artifact for this version must be built " - f"from commit_sha {commit_sha} (the eventual v{version} tag target) " - "or have an explicit " - "reproducible link recorded in notes/links." - ), - "licenses": { - "first_party_spdx": "Apache-2.0", - "license_files": ["LICENSE", "NOTICE"], - "third_party_notices": "legal/THIRD_PARTY_NOTICES.md", - "related_issues": ["#218", "#200"], - }, - "artifacts": artifacts, - "contents_summary": { - "counts_by_class": _counts(artifacts), - "total_artifacts": len(artifacts), - }, - "sbom_provenance": { - "configured": any(item["class"] in {"sbom", "provenance"} for item in artifacts), - "note": ( - "Attach workflow-produced SBOM/provenance here when the release " - "process emits them; leave empty list when not configured." - ), - }, - "notes": notes or "", - "links": { - "publishing": "docs/engineering/PUBLISHING.md", - "third_party": "legal/THIRD_PARTY_NOTICES.md", - "parent_tracker": "#192", - "execution_tracker": "#194", - }, - } - - -def _counts(artifacts: list[dict[str, Any]]) -> dict[str, int]: - counts: dict[str, int] = {} - for item in artifacts: - counts[item["class"]] = counts.get(item["class"], 0) + 1 - return dict(sorted(counts.items())) + commit_sha: str | None = None, + recorded_at: str | None = None, +) -> dict[str, object]: + """Build a deterministic manifest; strict completeness is a separate validation step.""" + return build_manifest( + version=version, + dist_dir=dist_dir, + commit_sha=commit_sha or _git_sha(), + recorded_at=recorded_at or _git_recorded_at(), + notes=notes or "", + ) def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--version", required=True, help="Release version, e.g. 0.5.0") + parser.add_argument("--version", required=True, help="One root release version") + parser.add_argument("--dist-dir", type=Path, required=True) + parser.add_argument("--out", type=Path, help="Write JSON manifest (default: stdout)") + parser.add_argument("--notes", default="", help="Non-sensitive candidate lineage notes") parser.add_argument( - "--dist-dir", - type=Path, - required=True, - help="Directory of built artifacts to hash", + "--recorded-at", + help="ISO-8601 retention start (default: source commit time)", ) - parser.add_argument( - "--out", - type=Path, - help="Write JSON record (default: stdout)", - ) - parser.add_argument("--notes", default="", help="Free-form lineage notes") parser.add_argument( "--allow-empty", action="store_true", - help="Permit writing a template record when dist-dir has no files yet", + help="Permit a schema template with no artifacts; it cannot pass candidate validation", ) args = parser.parse_args(argv) dist_dir = args.dist_dir if args.dist_dir.is_absolute() else ROOT / args.dist_dir - record = build_record(version=args.version, dist_dir=dist_dir, notes=args.notes) + record = build_record( + version=args.version, + dist_dir=dist_dir, + notes=args.notes, + recorded_at=args.recorded_at, + ) if not record["artifacts"] and not args.allow_empty: print( - "record-release-artifacts: no files under dist-dir " - f"{dist_dir} (pass --allow-empty for a template)", + f"record-release-artifacts: no files under dist-dir {dist_dir}", file=sys.stderr, ) return 1 - text = json.dumps(record, indent=2) + "\n" + output = json.dumps(record, indent=2, sort_keys=True) + "\n" if args.out: out = args.out if args.out.is_absolute() else ROOT / args.out out.parent.mkdir(parents=True, exist_ok=True) - out.write_text(text, encoding="utf-8") + out.write_text(output, encoding="utf-8") print(f"record-release-artifacts: wrote {out}") else: - sys.stdout.write(text) + sys.stdout.write(output) return 0 diff --git a/tests/unit/test_record_release_artifacts.py b/tests/unit/test_record_release_artifacts.py index b4e146dca..df4143d9b 100644 --- a/tests/unit/test_record_release_artifacts.py +++ b/tests/unit/test_record_release_artifacts.py @@ -12,29 +12,39 @@ def test_classify_and_hash(tmp_path: Path) -> None: - wheel = tmp_path / "graphforge-0.5.0-py3-none-any.whl" + wheel = tmp_path / "python" / "graphforge-0.5.0-py3-none-any.whl" + wheel.parent.mkdir() wheel.write_bytes(b"fake-wheel") record = record_release_artifacts.build_record( version="0.5.0", dist_dir=tmp_path, notes="test", ) - assert record["schema"] == "graphforge-release-record-v1" + assert record["schema"] == "graphforge-release-candidate-v2" assert record["version"] == "0.5.0" assert record["tag"] == "v0.5.0" assert len(record["commit_sha"]) == 40 - assert record["licenses"]["first_party_spdx"] == "Apache-2.0" - assert record["licenses"]["related_issues"] == ["#218", "#200"] - assert record["links"]["parent_tracker"] == "#192" - assert record["links"]["execution_tracker"] == "#194" - assert record["contents_summary"]["total_artifacts"] == 1 + assert set(record["publication_states"]) == { + "not_attempted", + "absent", + "accepted_pending_visibility", + "verified", + "conflict", + "indeterminate", + "failed", + } + assert [group["id"] for group in record["artifact_groups"]] == [ + "python", + "npm", + "crates", + "evidence", + ] assert record["artifacts"][0]["class"] == "python-wheel" assert record["artifacts"][0]["surface"] == "pypi" assert record["artifacts"][0]["name"] == "graphforge" assert record["artifacts"][0]["version"] == "0.5.0" assert record["artifacts"][0]["filename"] == wheel.name assert len(record["artifacts"][0]["sha256"]) == 64 - assert "same_tagged_commit_policy" in record serialized = json.dumps(record) for retired_tracker in ("#742", "#2783", "#2793", "#2794", "#2799"): assert retired_tracker not in serialized @@ -42,8 +52,8 @@ def test_classify_and_hash(tmp_path: Path) -> None: def test_cli_writes_json(tmp_path: Path) -> None: dist = tmp_path / "dist" - dist.mkdir() - (dist / "pkg.tgz").write_bytes(b"npm") + (dist / "npm").mkdir(parents=True) + (dist / "npm" / "pkg.tgz").write_bytes(b"npm") out = tmp_path / "out.json" assert ( record_release_artifacts.main( @@ -57,7 +67,8 @@ def test_cli_writes_json(tmp_path: Path) -> None: def test_crate_artifact_uses_crates_surface(tmp_path: Path) -> None: - archive = tmp_path / "graphforge-core-0.5.0.crate" + archive = tmp_path / "crates" / "graphforge-core-0.5.0.crate" + archive.parent.mkdir() archive.write_bytes(b"crate") record = record_release_artifacts.build_record( version="0.5.0", @@ -78,7 +89,9 @@ def test_owned_scope_npm_artifacts_keep_their_public_identity(tmp_path: Path) -> "curatelabs-graphforge-agent-skills-0.5.0.tgz": ("@curatelabs/graphforge-agent-skills"), } for filename in expected: - (tmp_path / filename).write_bytes(filename.encode()) + path = tmp_path / "npm" / filename + path.parent.mkdir(exist_ok=True) + path.write_bytes(filename.encode()) record = record_release_artifacts.build_record( version="0.5.0", From cd052ba28e3b30c4329b3ea30d4863feeccf85e6 Mon Sep 17 00:00:00 2001 From: David Spencer <1526975+DecisionNerd@users.noreply.github.com> Date: Sat, 1 Aug 2026 07:41:03 -0600 Subject: [PATCH 2/3] fix(release): retain npm-verifiable integrities --- docs/development/release-artifact-record.md | 4 ++-- scripts/ci/release_candidate_manifest.py | 18 ++++++++++++------ scripts/ci/test-release-candidate.py | 4 ++++ 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/docs/development/release-artifact-record.md b/docs/development/release-artifact-record.md index 544bce371..148746df9 100644 --- a/docs/development/release-artifact-record.md +++ b/docs/development/release-artifact-record.md @@ -20,8 +20,8 @@ field. The public node set is fixed: - `@curatelabs/graphforge-cli` and `@curatelabs/graphforge-agent-skills`. -Every archive records its byte length, SHA-256, SRI integrity, package identity, -required files, member count, and an inventory digest. Validation reopens the +Every archive records its byte length, SHA-256, SHA-256/SHA-512 SRI integrities, +package identity, required files, member count, and an inventory digest. Validation reopens the exact archive and compares those facts. It rejects missing Python import/native surfaces, Node entrypoints or types, native addons, CLI/skills entrypoints, crate sources, legal files, or exact-version first-party dependency metadata—even when diff --git a/scripts/ci/release_candidate_manifest.py b/scripts/ci/release_candidate_manifest.py index 607c51e62..5d683ffd6 100644 --- a/scripts/ci/release_candidate_manifest.py +++ b/scripts/ci/release_candidate_manifest.py @@ -73,12 +73,15 @@ def sha256_file(path: Path) -> str: return digest.hexdigest() -def _integrity(path: Path) -> str: - digest = hashlib.sha256() +def _integrities(path: Path) -> list[str]: + digests = (hashlib.sha256(), hashlib.sha512()) with path.open("rb") as handle: for chunk in iter(lambda: handle.read(1024 * 1024), b""): - digest.update(chunk) - return "sha256-" + base64.b64encode(digest.digest()).decode("ascii") + for digest in digests: + digest.update(chunk) + return [ + f"{digest.name}-" + base64.b64encode(digest.digest()).decode("ascii") for digest in digests + ] def _safe_relative(value: str, *, context: str) -> str: @@ -503,6 +506,7 @@ def scan_dist(dist_dir: Path, version: str) -> list[dict[str, Any]]: name = archive["package"]["name"] except CandidateError as error: inspection_error = str(error) + integrities = _integrities(path) artifacts.append( { "path": relative, @@ -514,7 +518,8 @@ def scan_dist(dist_dir: Path, version: str) -> list[dict[str, Any]]: "filename": path.name, "bytes": path.stat().st_size, "sha256": sha256_file(path), - "integrity": _integrity(path), + "integrity": integrities[0], + "integrities": integrities, "archive": archive, **({"inspection_error": inspection_error} if inspection_error else {}), } @@ -749,7 +754,8 @@ def validate( raise CandidateError(f"artifacts[{index}] has an invalid SHA-256") if sha256_file(path) != digest: raise CandidateError(f"artifact checksum mismatch: {relative}") - if item.get("integrity") != _integrity(path): + integrities = _integrities(path) + if item.get("integrity") != integrities[0] or item.get("integrities") != integrities: raise CandidateError(f"artifact integrity mismatch: {relative}") if item.get("bytes") != path.stat().st_size: raise CandidateError(f"artifact byte count mismatch: {relative}") diff --git a/scripts/ci/test-release-candidate.py b/scripts/ci/test-release-candidate.py index c4557f767..382077e2b 100644 --- a/scripts/ci/test-release-candidate.py +++ b/scripts/ci/test-release-candidate.py @@ -234,6 +234,10 @@ def main() -> None: validated = release_candidate.validate(manifest_path, artifacts, SHA, VERSION) assert len(validated["nodes"]) == 24 assert len(release_candidate.npm_paths(validated)) == 8 + assert all( + [value.split("-", 1)[0] for value in item["integrities"]] == ["sha256", "sha512"] + for item in validated["artifacts"] + ) assert set(validated["publication_states"]) == set(manifest_module.PUBLICATION_STATES) rebuilt = manifest_module.build_manifest( version=VERSION, From 8a09aa2d466a948e091ec9b41126487e51a88ba6 Mon Sep 17 00:00:00 2001 From: David Spencer <1526975+DecisionNerd@users.noreply.github.com> Date: Sat, 1 Aug 2026 07:42:11 -0600 Subject: [PATCH 3/3] test(release): reject integrity metadata drift --- scripts/ci/test-release-candidate.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scripts/ci/test-release-candidate.py b/scripts/ci/test-release-candidate.py index 382077e2b..333e82ee9 100644 --- a/scripts/ci/test-release-candidate.py +++ b/scripts/ci/test-release-candidate.py @@ -318,6 +318,10 @@ def main() -> None: mutated["artifacts"][0]["path"] = "../escape.whl" rejected(write_mutation(root, mutated), artifacts, "unsafe path") + mutated = copy.deepcopy(manifest) + mutated["artifacts"][0]["integrities"][1] = "sha512-incorrect" + rejected(write_mutation(root, mutated), artifacts, "integrity mismatch") + mutated = copy.deepcopy(manifest) mutated["artifact_groups"][1]["artifact_paths"].append( mutated["artifact_groups"][0]["artifact_paths"][0]