From cb63a07bb26c6f6e6d19cce0d2d0f75f3801d63b Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 18 Oct 2026 03:08:26 -0500 Subject: [PATCH 1/2] fix(release): count lockfile changes only when the published crate's dependencies change The version gate treated every Cargo.lock diff as a change to the published atrinik-protocol package. Cargo trims a packaged crate's Cargo.lock to the graph reachable from that crate, so an update confined to an unpublished workspace member, such as sha2 for the dev-only rendezvous-conformance tool, cannot change packaged content. For a Cargo.lock change, compare the locked graph reachable from each published crate at the base and the candidate. The walk follows every workspace edge, including dev-dependencies and features enabled by other members, so it can only over-approximate the packaged lock. Unresolvable or ambiguous references fail closed. Manifest, toolchain, crate-path and cargo configuration changes still count unconditionally, and release preparation still compares complete packaged content with the owning archive. Co-Authored-By: Claude Opus 5.5 --- README.md | 5 ++++ tools/crate_prepare.py | 44 +++++++++++++++++++++++++++- tools/test_crate_release.py | 57 +++++++++++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f2012d3..e6e5815 100644 --- a/README.md +++ b/README.md @@ -309,6 +309,11 @@ python3 tools/crate_prepare.py prepare --dry-run --source-tag v0.0.0 --output /t python3 -m unittest tools.test_crate_release ``` +`check-version` counts a `Cargo.lock`-only change as package content only when it +changes the locked graph reachable from a published crate, so dependency updates +confined to unpublished workspace tools need no crate version bump. Preparation +still compares the complete packaged content with the owning archive. + The dry-run manifest is preparation evidence only. Live publication requires a clean reviewed revision, complete immutable source release and configured identity. New crate names in other families need a one-time first publication before crates.io diff --git a/tools/crate_prepare.py b/tools/crate_prepare.py index 4ac2a81..44c3a5e 100644 --- a/tools/crate_prepare.py +++ b/tools/crate_prepare.py @@ -70,6 +70,42 @@ def pending(config): return any((name, version) not in existing for name, version in versions(config).items()) +def locked_dependencies(lock_text, name): + """Return the Cargo.lock graph reachable from one workspace crate. + + Cargo trims a packaged crate's Cargo.lock to this graph. The workspace graph + also follows the crate's dev-dependency edges and optional features enabled + by other members, so it can only over-approximate the packaged lock. + """ + lock = tomllib.loads(lock_text) + packages = lock.get('package', []) + R.require(isinstance(packages, list) and all(isinstance(entry, dict) for entry in packages), 'Cargo.lock package list') + + def resolve(reference): + R.require(isinstance(reference, str), 'Cargo.lock dependency reference') + parts = reference.split(' ', 2) + source = None + if len(parts) == 3: + R.require(parts[2].startswith('(') and parts[2].endswith(')'), 'Cargo.lock dependency source') + source = parts[2][1:-1] + found = [entry for entry in packages if entry.get('name') == parts[0] + and (len(parts) == 1 or entry.get('version') == parts[1]) + and (source is None or entry.get('source') == source)] + R.require(len(found) == 1, 'Cargo.lock dependency must resolve to exactly one package') + return found[0] + + roots = [entry for entry in packages if entry.get('name') == name and 'source' not in entry] + R.require(len(roots) == 1, 'published crate must be exactly one Cargo.lock workspace package') + graph, pending = {}, roots + while pending: + entry = pending.pop() + key = R.canonical(entry) + if key not in graph: + graph[key] = entry + pending.extend(resolve(reference) for reference in entry.get('dependencies', [])) + return lock.get('version'), sorted(graph) + + def check_version(config, base): R.require(R.SHA.fullmatch(base), 'base must be immutable revision') git('cat-file', '-e', base + '^{commit}') @@ -83,6 +119,10 @@ def check_version(config, base): current_versions = versions(config) changed = set(git('diff', '--name-only', base, '--').splitlines()) old_workspace = tomllib.loads(git('show', base + ':Cargo.toml')).get('workspace', {}).get('package', {}) + # A lockfile-only change matters when it reaches a published crate's graph; + # preparation still compares complete packaged content with the owning archive. + old_lock = git('show', base + ':Cargo.lock') if 'Cargo.lock' in changed else None + new_lock = (ROOT / 'Cargo.lock').read_text() if 'Cargo.lock' in changed else None for row in config['crates']: if row['name'] not in previous_names: continue @@ -95,7 +135,9 @@ def check_version(config, base): R.require(tuple(map(int, version.split('.'))) >= tuple(map(int, old_version.split('.'))), 'crate version moved backward') package_changed = (row['path'] != prior['path'] or config['toolchain'] != previous['toolchain'] or any(path.startswith((row['path'] + '/', prior['path'] + '/', '.cargo/')) for path in changed) - or bool(changed & {'Cargo.toml', 'Cargo.lock', 'rust-toolchain', 'rust-toolchain.toml'})) + or bool(changed & {'Cargo.toml', 'rust-toolchain', 'rust-toolchain.toml'}) + or (new_lock is not None + and locked_dependencies(old_lock, prior['name']) != locked_dependencies(new_lock, row['name']))) R.require(not package_changed or version != old_version, 'package content changed without reviewed version bump') diff --git a/tools/test_crate_release.py b/tools/test_crate_release.py index f6377f3..6e09291 100644 --- a/tools/test_crate_release.py +++ b/tools/test_crate_release.py @@ -71,6 +71,25 @@ def make_archive(members=None, special=None): return output.getvalue() +CRATES_IO = 'registry+https://github.com/rust-lang/crates.io-index' + + +def lockfile(sha2=('0.11.0',), bytes_version='1.12.1', example_dependencies=('prost',), version=4): + """Workspace lock with a published crate and an unpublished conformance tool.""" + def package(name, package_version, dependencies=(), registry=True): + lines = ['[[package]]', f'name = "{name}"', f'version = "{package_version}"'] + if registry: + lines += [f'source = "{CRATES_IO}"', f'checksum = "{R.digest(f"{name}@{package_version}".encode())}"'] + if dependencies: + lines.append('dependencies = [' + ', '.join(f'"{item}"' for item in dependencies) + ']') + return '\n'.join(lines) + packages = [package('example', '0.1.0', example_dependencies, registry=False), + package('rendezvous-conformance', '0.0.0', ('hmac', 'sha2'), registry=False), + package('prost', '0.14.4', ('bytes',)), package('bytes', bytes_version), + package('hmac', '0.13.0')] + [package('sha2', item) for item in sha2] + return f'version = {version}\n\n' + '\n\n'.join(packages) + '\n' + + def row(): data = make_archive() contents = R.archive(data, 'example', '0.1.0') @@ -546,6 +565,44 @@ def git(*args): with patch.object(P, 'versions', return_value={'example': '0.1.1'}): P.check_version(config, REVISION) + def check_lockfile_change(self, current, version='0.1.0'): + def git(*args): + if args[0] == 'diff': return 'Cargo.lock' + if args[0] == 'cat-file': return '' + if args[1].endswith(':policy/crate-release.json'): return json.dumps(CONFIG) + if args[1] == REVISION + ':Cargo.toml': return '[workspace.package]\nversion="0.1.0"\n' + if args[1] == REVISION + ':Cargo.lock': return lockfile().strip() + return '[package]\nversion="0.1.0"\n' + with tempfile.TemporaryDirectory() as temporary, patch.object(P, 'ROOT', Path(temporary)), \ + patch.object(P, 'git', side_effect=git), patch.object(P.subprocess, 'run') as command, \ + patch.object(P, 'versions', return_value={'example': version}): + (Path(temporary) / 'Cargo.lock').write_text(current) + command.return_value.returncode = 0 + P.check_version(CONFIG, REVISION) + + def test_version_gate_ignores_lockfile_changes_outside_published_crate_graph(self): + # Mirrors a dev-only sha2 update in an unpublished workspace member. + self.check_lockfile_change(lockfile(sha2=('0.11.1',))) + self.check_lockfile_change(lockfile()) + + def test_version_gate_counts_lockfile_changes_inside_published_crate_graph(self): + for case, current in ( + ('transitive dependency', lockfile(bytes_version='1.12.2')), + ('new dependency edge', lockfile(example_dependencies=('prost', 'sha2'))), + ('lockfile format', lockfile(version=5))): + with self.subTest(case=case): + with self.assertRaisesRegex(ValueError, 'without reviewed version bump'): + self.check_lockfile_change(current) + self.check_lockfile_change(current, version='0.1.1') + + def test_version_gate_rejects_unresolvable_lockfile_graph(self): + for case, current in ( + ('ambiguous bare reference', lockfile(sha2=('0.10.9', '0.11.1'), example_dependencies=('prost', 'sha2'))), + ('missing package', lockfile(example_dependencies=('prost', 'absent'))), + ('missing published crate', lockfile().replace('name = "example"', 'name = "renamed"'))): + with self.subTest(case=case), self.assertRaisesRegex(ValueError, 'exactly one'): + self.check_lockfile_change(current, version='0.1.1') + def test_actions_source_ref_cannot_be_overridden_and_read_token_not_in_build(self): with patch.dict(os.environ, {'GITHUB_ACTIONS': 'true', 'GITHUB_REF': 'refs/heads/1.0.x', 'ATRINIK_RELEASE_BRANCH': 'main'}, clear=True), self.assertRaises(ValueError): From c901264b3a81c768521ff495018e981597e3cb79 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 18 Oct 2026 03:15:44 -0500 Subject: [PATCH 2/2] fix(release): compare top-level lockfile sections in the version gate Cargo can carry top-level Cargo.lock sections such as [metadata] into a packaged lock. Compare every top-level section other than the package list verbatim, so a lockfile-only edit there still requires a version bump before release preparation. Co-Authored-By: Claude Opus 5.5 --- tools/crate_prepare.py | 7 +++++-- tools/test_crate_release.py | 7 ++++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/tools/crate_prepare.py b/tools/crate_prepare.py index 44c3a5e..9ee637c 100644 --- a/tools/crate_prepare.py +++ b/tools/crate_prepare.py @@ -75,7 +75,8 @@ def locked_dependencies(lock_text, name): Cargo trims a packaged crate's Cargo.lock to this graph. The workspace graph also follows the crate's dev-dependency edges and optional features enabled - by other members, so it can only over-approximate the packaged lock. + by other members, so it can only over-approximate the packaged lock. All + top-level sections other than the package list are compared verbatim. """ lock = tomllib.loads(lock_text) packages = lock.get('package', []) @@ -103,7 +104,9 @@ def resolve(reference): if key not in graph: graph[key] = entry pending.extend(resolve(reference) for reference in entry.get('dependencies', [])) - return lock.get('version'), sorted(graph) + # Keep every other top-level section (format version, [metadata] and future + # tables) so lockfile data Cargo may carry into the package still counts. + return R.canonical({key: value for key, value in lock.items() if key != 'package'}), sorted(graph) def check_version(config, base): diff --git a/tools/test_crate_release.py b/tools/test_crate_release.py index 6e09291..dfbd275 100644 --- a/tools/test_crate_release.py +++ b/tools/test_crate_release.py @@ -74,7 +74,7 @@ def make_archive(members=None, special=None): CRATES_IO = 'registry+https://github.com/rust-lang/crates.io-index' -def lockfile(sha2=('0.11.0',), bytes_version='1.12.1', example_dependencies=('prost',), version=4): +def lockfile(sha2=('0.11.0',), bytes_version='1.12.1', example_dependencies=('prost',), version=4, metadata=''): """Workspace lock with a published crate and an unpublished conformance tool.""" def package(name, package_version, dependencies=(), registry=True): lines = ['[[package]]', f'name = "{name}"', f'version = "{package_version}"'] @@ -87,7 +87,7 @@ def package(name, package_version, dependencies=(), registry=True): package('rendezvous-conformance', '0.0.0', ('hmac', 'sha2'), registry=False), package('prost', '0.14.4', ('bytes',)), package('bytes', bytes_version), package('hmac', '0.13.0')] + [package('sha2', item) for item in sha2] - return f'version = {version}\n\n' + '\n\n'.join(packages) + '\n' + return f'version = {version}\n\n' + '\n\n'.join(packages) + '\n' + metadata def row(): @@ -589,7 +589,8 @@ def test_version_gate_counts_lockfile_changes_inside_published_crate_graph(self) for case, current in ( ('transitive dependency', lockfile(bytes_version='1.12.2')), ('new dependency edge', lockfile(example_dependencies=('prost', 'sha2'))), - ('lockfile format', lockfile(version=5))): + ('lockfile format', lockfile(version=5)), + ('top-level metadata', lockfile(metadata='\n[metadata]\nnote = "preserved"\n'))): with self.subTest(case=case): with self.assertRaisesRegex(ValueError, 'without reviewed version bump'): self.check_lockfile_change(current)