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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 46 additions & 1 deletion tools/crate_prepare.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,45 @@ 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. All
top-level sections other than the package list are compared verbatim.
"""
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', []))
# 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):
R.require(R.SHA.fullmatch(base), 'base must be immutable revision')
git('cat-file', '-e', base + '^{commit}')
Expand All @@ -83,6 +122,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
Expand All @@ -95,7 +138,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')


Expand Down
58 changes: 58 additions & 0 deletions tools/test_crate_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, 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}"']
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' + metadata


def row():
data = make_archive()
contents = R.archive(data, 'example', '0.1.0')
Expand Down Expand Up @@ -546,6 +565,45 @@ 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)),
('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)
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):
Expand Down
Loading