Skip to content

Commit 2a588e4

Browse files
[ci/publish] Remove utils from macros (#3340)
1 parent 9418221 commit 2a588e4

6 files changed

Lines changed: 206 additions & 5 deletions

File tree

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
#!/usr/bin/env -S uv run -s
2+
3+
# /// script
4+
# requires-python = ">=3.9"
5+
# dependencies = []
6+
# ///
7+
"""
8+
Validate that the publish workflow orders crates after all publishable internal
9+
dependencies.
10+
11+
This catches `cargo publish` failures caused by publishing a crate before one of
12+
its workspace dependencies is available on crates.io. The check includes normal,
13+
build, and dev dependencies because `cargo publish` verifies all of them.
14+
15+
Usage
16+
-----
17+
./check_publish_order.py
18+
./check_publish_order.py /path/to/repo
19+
"""
20+
21+
from __future__ import annotations
22+
23+
import json
24+
import re
25+
import subprocess
26+
import sys
27+
from pathlib import Path
28+
29+
30+
PUBLISH_WORKFLOW = Path(".github/workflows/publish.yml")
31+
PUBLISH_PATTERN = re.compile(r"cargo publish --manifest-path (?P<path>\S+)")
32+
33+
34+
def find_repo_root(start: Path) -> Path:
35+
root = start.resolve()
36+
while root != root.parent:
37+
if (root / "Cargo.toml").exists() and (root / ".github").exists():
38+
return root
39+
root = root.parent
40+
raise SystemExit("ERROR: could not find repository root")
41+
42+
43+
def load_publish_order(root: Path) -> list[Path]:
44+
workflow = root / PUBLISH_WORKFLOW
45+
text = workflow.read_text(encoding="utf-8")
46+
manifests = [
47+
(root / match.group("path").strip("'\"")).resolve()
48+
for match in PUBLISH_PATTERN.finditer(text)
49+
]
50+
if not manifests:
51+
raise SystemExit(f"ERROR: no cargo publish steps found in {workflow}")
52+
return manifests
53+
54+
55+
def load_workspace_metadata(root: Path) -> dict:
56+
result = subprocess.run(
57+
["cargo", "metadata", "--locked", "--no-deps", "--format-version", "1"],
58+
cwd=root,
59+
capture_output=True,
60+
text=True,
61+
check=False,
62+
)
63+
if result.returncode != 0:
64+
raise SystemExit(
65+
"ERROR: failed to load cargo metadata\n"
66+
f"{result.stderr.strip()}"
67+
)
68+
return json.loads(result.stdout)
69+
70+
71+
def rel(root: Path, path: Path) -> str:
72+
return str(path.resolve().relative_to(root))
73+
74+
75+
def dependency_kind(dep: dict) -> str:
76+
return dep["kind"] or "normal"
77+
78+
79+
def workspace_packages(metadata: dict, *, publishable_only: bool = False) -> dict[Path, dict]:
80+
workspace_members = set(metadata["workspace_members"])
81+
packages = {}
82+
for package in metadata["packages"]:
83+
if package["id"] not in workspace_members:
84+
continue
85+
if publishable_only and package.get("publish") == []:
86+
continue
87+
manifest = Path(package["manifest_path"]).resolve()
88+
packages[manifest] = package
89+
return packages
90+
91+
92+
def main() -> int:
93+
root = find_repo_root(Path(sys.argv[1]) if len(sys.argv) > 1 else Path.cwd())
94+
publish_order = load_publish_order(root)
95+
metadata = load_workspace_metadata(root)
96+
workspace = workspace_packages(metadata)
97+
packages = workspace_packages(metadata, publishable_only=True)
98+
99+
duplicates = []
100+
seen = set()
101+
for manifest in publish_order:
102+
if manifest in seen:
103+
duplicates.append(manifest)
104+
seen.add(manifest)
105+
106+
problems = []
107+
if duplicates:
108+
problems.extend(
109+
f"publish.yml contains duplicate publish steps for {rel(root, manifest)}"
110+
for manifest in duplicates
111+
)
112+
113+
workflow_manifests = set(publish_order)
114+
package_manifests = set(packages)
115+
116+
missing = sorted(package_manifests - workflow_manifests)
117+
extra = sorted(workflow_manifests - package_manifests)
118+
119+
problems.extend(
120+
f"publish.yml is missing publishable workspace crate {packages[manifest]['name']} "
121+
f"({rel(root, manifest)})"
122+
for manifest in missing
123+
)
124+
problems.extend(
125+
f"publish.yml includes non-publishable or unknown manifest {rel(root, manifest)}"
126+
for manifest in extra
127+
)
128+
129+
order_index = {manifest: idx for idx, manifest in enumerate(publish_order)}
130+
workspace_by_root = {manifest.parent.resolve(): manifest for manifest in workspace}
131+
132+
for manifest in publish_order:
133+
package = packages.get(manifest)
134+
if package is None:
135+
continue
136+
137+
blocked_by: dict[Path, set[str]] = {}
138+
for dep in package["dependencies"]:
139+
dep_path = dep.get("path")
140+
if dep_path is None:
141+
continue
142+
143+
dep_manifest = workspace_by_root.get(Path(dep_path).resolve())
144+
if dep_manifest is None:
145+
problems.append(
146+
f"{package['name']} ({rel(root, manifest)}) depends on unknown workspace "
147+
f"path {dep_path}"
148+
)
149+
continue
150+
151+
dep_package = packages.get(dep_manifest)
152+
if dep_package is None:
153+
problems.append(
154+
f"{package['name']} ({rel(root, manifest)}) depends on unpublished workspace "
155+
f"crate {dep['name']} ({rel(root, dep_manifest)})"
156+
)
157+
continue
158+
159+
if dep_manifest == manifest:
160+
continue
161+
162+
if order_index.get(dep_manifest, -1) >= order_index[manifest]:
163+
blocked_by.setdefault(dep_manifest, set()).add(dependency_kind(dep))
164+
165+
for dep_manifest, kinds in sorted(blocked_by.items(), key=lambda item: str(item[0])):
166+
dep_package = packages[dep_manifest]
167+
kind_list = ", ".join(sorted(kinds))
168+
problems.append(
169+
f"{package['name']} ({rel(root, manifest)}) is published at step "
170+
f"{order_index[manifest] + 1}, but depends on {dep_package['name']} "
171+
f"({rel(root, dep_manifest)}) at step {order_index[dep_manifest] + 1} "
172+
f"via {kind_list} dependencies"
173+
)
174+
175+
if problems:
176+
print("Publish order validation failed:\n")
177+
print("\n".join(f"- {problem}" for problem in problems))
178+
return 1
179+
180+
print(
181+
f"Validated publish order for {len(publish_order)} publishable workspace crates."
182+
)
183+
return 0
184+
185+
186+
if __name__ == "__main__":
187+
sys.exit(main())

.github/workflows/fast.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,18 @@ jobs:
336336
- name: Check benchmark naming conventions
337337
run: python3 .github/scripts/lint_benchmark_names.py
338338

339+
publish-order:
340+
name: Check Publish Order
341+
runs-on: ubuntu-latest
342+
timeout-minutes: 10
343+
steps:
344+
- name: Checkout repository
345+
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
346+
- name: Run setup
347+
uses: ./.github/actions/setup
348+
- name: Check publish order
349+
run: python3 .github/scripts/check_publish_order.py
350+
339351
zepter:
340352
name: Feature Propagation
341353
runs-on: ubuntu-latest

Cargo.lock

Lines changed: 0 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

justfile

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,8 @@ clippy *args='':
4545
fix-clippy *args='':
4646
cargo clippy --all-targets --fix --allow-dirty $@
4747

48-
# Runs all lints (fmt, clippy, docs, features, toml, benchmark names, and stability)
49-
lint: check-fmt check-toml-fmt clippy check-docs check-features check-benchmark-names check-stability
48+
# Runs all lints (fmt, clippy, docs, features, toml, publish order, benchmark names, and stability)
49+
lint: check-fmt check-toml-fmt clippy check-docs check-features check-publish-order check-benchmark-names check-stability
5050

5151
# Fixes all lint issues in the workspace
5252
fix: fix-clippy fix-fmt fix-toml-fmt fix-features
@@ -71,6 +71,10 @@ check-docs *args='':
7171
check-benchmark-names:
7272
python3 .github/scripts/lint_benchmark_names.py
7373

74+
# Check publish workflow ordering against workspace dependencies
75+
check-publish-order:
76+
python3 .github/scripts/check_publish_order.py
77+
7478
# Run all fuzz tests in a given directory
7579
fuzz fuzz_dir max_time='60' max_mem='4000':
7680
#!/usr/bin/env bash

macros/Cargo.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ commonware-macros-impl.workspace = true
1818
tokio = { workspace = true, features = ["macros"], optional = true }
1919

2020
[dev-dependencies]
21-
commonware-utils.workspace = true
2221
futures.workspace = true
2322
futures-timer.workspace = true
2423
tokio = { workspace = true, features = ["sync"] }

macros/tests/select.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
#[cfg(test)]
22
mod tests {
33
use commonware_macros::{select, select_loop};
4-
use commonware_utils::channel::mpsc;
54
use futures::executor::block_on;
65
use std::future::Future;
6+
use tokio::sync::mpsc;
77

88
#[test]
99
fn test_select_macro() {

0 commit comments

Comments
 (0)