From e18567202ed7945dbd16a26be2e65f04b7d85019 Mon Sep 17 00:00:00 2001 From: David Spencer <1526975+DecisionNerd@users.noreply.github.com> Date: Thu, 17 Sep 2026 00:26:45 +0000 Subject: [PATCH] fix(release): align release inventories with the crates.io publish plan `crate-publish-plan.py list` emits 20 crates, but every hand-maintained crates.io inventory carried only 17. `graphforge-discovery`, `graphforge-observability`, and `graphforge-portable-oci` were missing, so offline rehearsal rejected the assembled candidate with "unexpected crates.io package identity". The same three crates also shipped no NOTICE, which `release_candidate_manifest._validate_crate` requires inside every packaged `.crate`, so both halves land together. - add the three crates to `release_candidate_manifest.CRATES`, `clean-env-verify.DEFAULT_CRATES`, and `verify_package_licenses.CARGO_PUBLISH_CRATES`, restating each inventory in the publish plan's topological order - add the three crate directories to `license_check.CARGO_PACKAGE_DIRS`, which also extends NOTICE_COPIES coverage - add `crates/graphforge-{discovery,observability,portable-oci}/NOTICE`, byte identical to the other 17 crates and the workspace root - fail closed on future drift: `test-crate-publish-plan.py` now asserts every inventory equals `crate-publish-plan.py list`, that the license gate covers each publishable crate directory, and that each one has a NOTICE - keep the `DEFAULT_CRATES` shape assertion in `test-clean-env-verify.py` and update it to 20, naming the three recovered crates - derive the 26/17 node and package counts in the release candidate, registry, and rehearsal tests from the inventories instead of hardcoding - refresh publication-order.md and release-artifact-record.md: the release is 29 nodes over 20 crates, and the printed order was missing `graphforge-value` and `graphforge-portable-oci` Closes #1373 Co-Authored-By: Claude Opus 5 (1M context) --- crates/graphforge-discovery/NOTICE | 18 ++++++++++ crates/graphforge-observability/NOTICE | 18 ++++++++++ crates/graphforge-portable-oci/NOTICE | 18 ++++++++++ docs/development/publication-order.md | 32 +++++++++-------- docs/development/release-artifact-record.md | 2 +- scripts/ci/clean-env-verify.py | 13 ++++--- scripts/ci/release_candidate_manifest.py | 13 ++++--- scripts/ci/test-clean-env-verify.py | 5 ++- scripts/ci/test-crate-publish-plan.py | 39 +++++++++++++++++++++ scripts/ci/test-release-candidate.py | 4 ++- scripts/ci/test-release-registry.py | 4 ++- scripts/ci/test-release-rehearsal.py | 16 +++++---- scripts/license_check.py | 3 ++ scripts/verify_package_licenses.py | 13 ++++--- 14 files changed, 161 insertions(+), 37 deletions(-) create mode 100644 crates/graphforge-discovery/NOTICE create mode 100644 crates/graphforge-observability/NOTICE create mode 100644 crates/graphforge-portable-oci/NOTICE diff --git a/crates/graphforge-discovery/NOTICE b/crates/graphforge-discovery/NOTICE new file mode 100644 index 000000000..7538d7aec --- /dev/null +++ b/crates/graphforge-discovery/NOTICE @@ -0,0 +1,18 @@ +GraphForge +Copyright 2026 Curate Labs Inc. + +GraphForge v0.5.0 and later are licensed under the Apache License, Version 2.0. +Previously published v0.4.0 and earlier artifacts remain under the MIT License +shipped with those versions. + +The openCypher Technology Compatibility Kit material under tests/tck/ is +third-party material distributed under its own Apache License 2.0 and NOTICE. +Dependencies and other third-party materials remain under their respective +licenses and are not relicensed as part of GraphForge. + +A machine-generated inventory of third-party Rust dependency license texts for +binary redistributions (Python wheels, native Node addons, and the CLI) is in +legal/THIRD_PARTY_NOTICES.md. Published binary packages include a copy of that +file. Regenerate it with `make third-party-notices` after dependency changes. +Rust dependency SPDX allowlisting is enforced with `cargo deny check licenses` +using deny.toml. diff --git a/crates/graphforge-observability/NOTICE b/crates/graphforge-observability/NOTICE new file mode 100644 index 000000000..7538d7aec --- /dev/null +++ b/crates/graphforge-observability/NOTICE @@ -0,0 +1,18 @@ +GraphForge +Copyright 2026 Curate Labs Inc. + +GraphForge v0.5.0 and later are licensed under the Apache License, Version 2.0. +Previously published v0.4.0 and earlier artifacts remain under the MIT License +shipped with those versions. + +The openCypher Technology Compatibility Kit material under tests/tck/ is +third-party material distributed under its own Apache License 2.0 and NOTICE. +Dependencies and other third-party materials remain under their respective +licenses and are not relicensed as part of GraphForge. + +A machine-generated inventory of third-party Rust dependency license texts for +binary redistributions (Python wheels, native Node addons, and the CLI) is in +legal/THIRD_PARTY_NOTICES.md. Published binary packages include a copy of that +file. Regenerate it with `make third-party-notices` after dependency changes. +Rust dependency SPDX allowlisting is enforced with `cargo deny check licenses` +using deny.toml. diff --git a/crates/graphforge-portable-oci/NOTICE b/crates/graphforge-portable-oci/NOTICE new file mode 100644 index 000000000..7538d7aec --- /dev/null +++ b/crates/graphforge-portable-oci/NOTICE @@ -0,0 +1,18 @@ +GraphForge +Copyright 2026 Curate Labs Inc. + +GraphForge v0.5.0 and later are licensed under the Apache License, Version 2.0. +Previously published v0.4.0 and earlier artifacts remain under the MIT License +shipped with those versions. + +The openCypher Technology Compatibility Kit material under tests/tck/ is +third-party material distributed under its own Apache License 2.0 and NOTICE. +Dependencies and other third-party materials remain under their respective +licenses and are not relicensed as part of GraphForge. + +A machine-generated inventory of third-party Rust dependency license texts for +binary redistributions (Python wheels, native Node addons, and the CLI) is in +legal/THIRD_PARTY_NOTICES.md. Published binary packages include a copy of that +file. Regenerate it with `make third-party-notices` after dependency changes. +Rust dependency SPDX allowlisting is enforced with `cargo deny check licenses` +using deny.toml. diff --git a/docs/development/publication-order.md b/docs/development/publication-order.md index 5e72893db..b75d24f74 100644 --- a/docs/development/publication-order.md +++ b/docs/development/publication-order.md @@ -1,6 +1,6 @@ # Publication and recovery order (v0.5.2) -GraphForge v0.5.2 is one 27-node release: 18 crates.io crates, one PyPI +GraphForge v0.5.2 is one 29-node release: 20 crates.io crates, one PyPI project, five native npm packages, the npm main package, CLI, and agent skills. [ADR 0017](../adr/0017-unified-release-version.md) forbids a registry-specific version. Existing v0.5.0 tags, records, supplements, and published packages are @@ -129,17 +129,19 @@ The finite order is generated by `scripts/ci/crate-publish-plan.py`: 5. `graphforge-observability` 6. `graphforge-ast` 7. `graphforge-knowledge` -8. `graphforge-ontology` +8. `graphforge-portable-oci` 9. `graphforge-provenance` -10. `graphforge-ir` -11. `graphforge-plan` -12. `graphforge-storage` -13. `graphforge-rel` -14. `graphforge-search` -15. `graphforge-cypher` -16. `graphforge-exec` -17. `graphforge-api` -18. `graphforge-cli` +10. `graphforge-value` +11. `graphforge-cypher` +12. `graphforge-ontology` +13. `graphforge-ir` +14. `graphforge-plan` +15. `graphforge-storage` +16. `graphforge-rel` +17. `graphforge-search` +18. `graphforge-exec` +19. `graphforge-api` +20. `graphforge-cli` Each invocation validates the retained `.crate` checksum before `cargo publish`. After an accepted write it observes the public version once. A verified result @@ -149,13 +151,13 @@ may unlock the next crate; pending or unsafe truth stops the finite loop. The final job uses `if: always()` and records every lane conclusion, including failure, cancellation, timeout, and skip. When the candidate is available it -re-observes all three registries and produces one stable 27-node summary. Job +re-observes all three registries and produces one stable 29-node summary. Job history is operator context only; registry state and the next safe actions come from the immutable candidate plus live registry truth. If candidate preflight failed before a manifest was available, reconciliation -still emits all 27 node identities as `indeterminate` and identifies the -candidate blocker. The workflow is green only when all 27 nodes are publicly +still emits all 29 node identities as `indeterminate` and identifies the +candidate blocker. The workflow is green only when all 29 nodes are publicly `verified`. The summary is retained for 30 days and contains no credentials, headers, cookies, tokens, or raw registry bodies. @@ -195,4 +197,4 @@ boundary explicit. Maintainers still make these decisions: v0.5.1 tag and GitHub Release; - decide any registry-specific yank/deprecation if reconciliation finds a conflict; and -- close the human release tracker only after the 27-node summary is complete. +- close the human release tracker only after the 29-node summary is complete. diff --git a/docs/development/release-artifact-record.md b/docs/development/release-artifact-record.md index b90755930..33886b262 100644 --- a/docs/development/release-artifact-record.md +++ b/docs/development/release-artifact-record.md @@ -200,7 +200,7 @@ Before a write the lane persists a sanitized immutable attempt record; after a successful registry response it persists an accepted receipt and performs one public observation. Later recovery runs load both, so cancellation, timeout, or propagation delay cannot become permission for a second write. There is no polling loop. The `always()` -reconciliation job then observes all 27 nodes and combines those states with +reconciliation job then observes all 29 nodes and combines those states with job conclusions for operator context. See [`publication-order.md`](publication-order.md) for the recovery and human stop decisions. diff --git a/scripts/ci/clean-env-verify.py b/scripts/ci/clean-env-verify.py index 8efc961bf..ec74b634f 100644 --- a/scripts/ci/clean-env-verify.py +++ b/scripts/ci/clean-env-verify.py @@ -31,21 +31,26 @@ RELEASE_RECORD_SCHEMAS = (RELEASE_RECORD_SCHEMA, RELEASE_CANDIDATE_SCHEMA) DEFAULT_VERSION = "0.5.2" DEFAULT_DOCS_BASE = "https://docs.graphforge.sh" +# crates.io publish set and order; must equal `crate-publish-plan.py list`. +# scripts/ci/test-crate-publish-plan.py fails when this drifts from the plan. DEFAULT_CRATES = ( "graphforge-core", - "graphforge-value", + "graphforge-discovery", "graphforge-filesystem", + "graphforge-io", + "graphforge-observability", "graphforge-ast", "graphforge-knowledge", - "graphforge-ontology", + "graphforge-portable-oci", "graphforge-provenance", + "graphforge-value", + "graphforge-cypher", + "graphforge-ontology", "graphforge-ir", "graphforge-plan", "graphforge-storage", - "graphforge-io", "graphforge-rel", "graphforge-search", - "graphforge-cypher", "graphforge-exec", "graphforge-api", "graphforge-cli", diff --git a/scripts/ci/release_candidate_manifest.py b/scripts/ci/release_candidate_manifest.py index da4af4109..18e6a690a 100644 --- a/scripts/ci/release_candidate_manifest.py +++ b/scripts/ci/release_candidate_manifest.py @@ -28,21 +28,26 @@ "indeterminate": "authoritative registry truth cannot be classified safely", "failed": "a deterministic local or registry operation failed", } +# crates.io publish set and order; must equal `crate-publish-plan.py list`. +# scripts/ci/test-crate-publish-plan.py fails when this drifts from the plan. CRATES = ( "graphforge-core", - "graphforge-value", + "graphforge-discovery", "graphforge-filesystem", + "graphforge-io", + "graphforge-observability", "graphforge-ast", "graphforge-knowledge", - "graphforge-ontology", + "graphforge-portable-oci", "graphforge-provenance", + "graphforge-value", + "graphforge-cypher", + "graphforge-ontology", "graphforge-ir", "graphforge-plan", "graphforge-storage", - "graphforge-io", "graphforge-rel", "graphforge-search", - "graphforge-cypher", "graphforge-exec", "graphforge-api", "graphforge-cli", diff --git a/scripts/ci/test-clean-env-verify.py b/scripts/ci/test-clean-env-verify.py index d2ca0edc1..fa4fbc770 100644 --- a/scripts/ci/test-clean-env-verify.py +++ b/scripts/ci/test-clean-env-verify.py @@ -18,8 +18,11 @@ sys.modules[SPEC.name] = cev # required for dataclasses under Python 3.9 SPEC.loader.exec_module(cev) -assert len(cev.DEFAULT_CRATES) == 17 +assert len(cev.DEFAULT_CRATES) == 20 assert "graphforge-value" in cev.DEFAULT_CRATES +# #1373: these three were in the publish plan but missing from the inventory. +for crate_name in ("graphforge-discovery", "graphforge-observability", "graphforge-portable-oci"): + assert crate_name in cev.DEFAULT_CRATES assert cev.DEFAULT_CRATES[0] == "graphforge-core" assert cev.DEFAULT_CRATES[-1] == "graphforge-cli" assert cev.LANE_ISSUES["cargo"] == 185 diff --git a/scripts/ci/test-crate-publish-plan.py b/scripts/ci/test-crate-publish-plan.py index f1480d7f5..77432198b 100755 --- a/scripts/ci/test-crate-publish-plan.py +++ b/scripts/ci/test-crate-publish-plan.py @@ -20,6 +20,17 @@ def load_module(): return module +def load_named_module(path: Path): + """Import a scripts/ module by path (names contain hyphens).""" + name = path.stem.replace("-", "_") + spec = importlib.util.spec_from_file_location(name, path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + def run(*args: str) -> subprocess.CompletedProcess[str]: return subprocess.run( [sys.executable, str(SCRIPT), *args], @@ -98,4 +109,32 @@ def run(*args: str) -> subprocess.CompletedProcess[str]: assert commands[0].startswith("cargo publish -p graphforge-core ") assert commands[-1].startswith("cargo publish -p graphforge-cli ") +# --- Release inventories must not diverge from the publish plan (#1373) ------- +# Every hand-maintained crates.io inventory is compared against the plan here so +# that adding a crate cannot silently leave a release gate behind. +INVENTORIES = ( + (ROOT / "scripts" / "ci" / "release_candidate_manifest.py", "CRATES"), + (ROOT / "scripts" / "ci" / "clean-env-verify.py", "DEFAULT_CRATES"), + (ROOT / "scripts" / "verify_package_licenses.py", "CARGO_PUBLISH_CRATES"), +) + +for script, attribute in INVENTORIES: + inventory_module = load_named_module(script) + inventory = tuple(getattr(inventory_module, attribute)) + assert inventory == tuple(names), ( + f"{script.relative_to(ROOT)}:{attribute} diverges from " + f"crate-publish-plan.py list; expected {list(names)}, found {list(inventory)}" + ) + +license_check = load_named_module(ROOT / "scripts" / "license_check.py") +license_dirs = {path.name for path in license_check.CARGO_PACKAGE_DIRS} +missing_license_dirs = sorted(set(names) - license_dirs) +assert not missing_license_dirs, ( + f"scripts/license_check.py:CARGO_PACKAGE_DIRS lacks publishable crates: {missing_license_dirs}" +) + +missing_notice = sorted(name for name in names if not (ROOT / "crates" / name / "NOTICE").is_file()) +assert not missing_notice, f"publishable crates without a NOTICE file: {missing_notice}" + + print("crate-publish-plan tests passed") diff --git a/scripts/ci/test-release-candidate.py b/scripts/ci/test-release-candidate.py index 58f5a7bcb..c002555eb 100644 --- a/scripts/ci/test-release-candidate.py +++ b/scripts/ci/test-release-candidate.py @@ -254,7 +254,9 @@ def main() -> None: root = Path(temp) manifest_path, artifacts, manifest = create_candidate(root) validated = release_candidate.validate(manifest_path, artifacts, SHA, VERSION) - assert len(validated["nodes"]) == 26 + assert len(validated["nodes"]) == 1 + len(manifest_module.NPM_PACKAGES) + len( + manifest_module.CRATES + ) assert { "from": "crates:graphforge-storage", "requires": "crates:graphforge-filesystem", diff --git a/scripts/ci/test-release-registry.py b/scripts/ci/test-release-registry.py index 372ecf616..ae385fa62 100644 --- a/scripts/ci/test-release-registry.py +++ b/scripts/ci/test-release-registry.py @@ -16,6 +16,8 @@ VERSION = "0.5.1" SHA = "a" * 40 NOW = "2030-01-01T12:02:00+00:00" +# One PyPI node, every npm package, and every crates.io crate (#1373). +NODE_COUNT = 1 + len(NPM_PACKAGES) + len(CRATES) def artifact(path: str, group: str, surface: str, name: str, *, dependencies=None): @@ -206,7 +208,7 @@ def main() -> None: result = plan(manifest, all_verified) assert result["actions"] == [] assert result["download_groups"] == [] - assert result["summary"]["verified"] == 26 + assert result["summary"]["verified"] == NODE_COUNT for node_id in ( "pypi:graphforge", diff --git a/scripts/ci/test-release-rehearsal.py b/scripts/ci/test-release-rehearsal.py index b31af6002..e7336088c 100644 --- a/scripts/ci/test-release-rehearsal.py +++ b/scripts/ci/test-release-rehearsal.py @@ -111,7 +111,9 @@ def fake_python(manifest, _artifacts, _root): ) assert report["status"] == "passed" assert report["registry_writes"] == 0 - assert report["checks"]["candidate_completeness"]["nodes"] == 26 + assert ( + report["checks"]["candidate_completeness"]["nodes"] == registry_fixture.NODE_COUNT + ) node_check = report["checks"]["node_cli_skills_clean_consumer"] assert node_check["loaded_version"] == candidate_fixture.VERSION host_native = rehearsal._compatible_native_npm_name() @@ -122,7 +124,9 @@ def fake_python(manifest, _artifacts, _root): "@curatelabs/graphforge-agent-skills", host_native, ] - assert len(report["checks"]["rust_packages"]["packages"]) == 17 + assert len(report["checks"]["rust_packages"]["packages"]) == len( + candidate_contract.CRATES + ) assert not any(word in json.dumps(report).lower() for word in rehearsal.FORBIDDEN_TEXT) with tempfile.TemporaryDirectory() as temporary: @@ -168,14 +172,14 @@ def test_sequential_reconciliation() -> None: availability = _availability() absent = _all(manifest, {"status": 404}) transitions = _sequential_happy_path(manifest) - assert len(transitions) == 26 + assert len(transitions) == registry_fixture.NODE_COUNT report = rehearsal.simulate_sequential( manifest, absent, availability, transitions, simulated_at=NOW ) assert report["complete"] is True - assert report["summary"]["nodes"] == 26 - assert report["summary"]["verified"] == 26 - assert len(report["events"]) == 26 + assert report["summary"]["nodes"] == registry_fixture.NODE_COUNT + assert report["summary"]["verified"] == registry_fixture.NODE_COUNT + assert len(report["events"]) == registry_fixture.NODE_COUNT assert all(event["sequence"] == index for index, event in enumerate(report["events"], 1)) all_verified = registry_fixture.observation_set(manifest) diff --git a/scripts/license_check.py b/scripts/license_check.py index 18f504b2d..573546749 100644 --- a/scripts/license_check.py +++ b/scripts/license_check.py @@ -36,13 +36,16 @@ "graphforge-core", "graphforge-value", "graphforge-cypher", + "graphforge-discovery", "graphforge-exec", "graphforge-filesystem", "graphforge-io", "graphforge-ir", "graphforge-knowledge", + "graphforge-observability", "graphforge-ontology", "graphforge-plan", + "graphforge-portable-oci", "graphforge-provenance", "graphforge-rel", "graphforge-search", diff --git a/scripts/verify_package_licenses.py b/scripts/verify_package_licenses.py index 5b57609a7..8668d074d 100644 --- a/scripts/verify_package_licenses.py +++ b/scripts/verify_package_licenses.py @@ -27,21 +27,26 @@ ROOT = Path(__file__).resolve().parents[1] # Crates intended for crates.io (bindings ship via PyPI/npm). +# Must equal `scripts/ci/crate-publish-plan.py list`; enforced by +# scripts/ci/test-crate-publish-plan.py. CARGO_PUBLISH_CRATES = ( "graphforge-core", - "graphforge-value", + "graphforge-discovery", "graphforge-filesystem", + "graphforge-io", + "graphforge-observability", "graphforge-ast", "graphforge-knowledge", - "graphforge-ontology", + "graphforge-portable-oci", "graphforge-provenance", + "graphforge-value", + "graphforge-cypher", + "graphforge-ontology", "graphforge-ir", "graphforge-plan", "graphforge-storage", - "graphforge-io", "graphforge-rel", "graphforge-search", - "graphforge-cypher", "graphforge-exec", "graphforge-api", "graphforge-cli",