Skip to content

Commit a58ef2d

Browse files
authored
Merge pull request #5893 from Hmbown/fix/cargo-preflight-0913-20260905
fix(release): verify all crate tarballs before the first upload
2 parents db50832 + daea481 commit a58ef2d

4 files changed

Lines changed: 190 additions & 37 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,7 @@ jobs:
213213
bash scripts/release/prepare-release.test.sh
214214
bash scripts/release/require-release-tag-checkout.test.sh
215215
bash scripts/release/validate-crate-publish-order.test.sh
216+
python3 scripts/release/publish-crates.test.py
216217
bash scripts/release/verify-remote-tag.test.sh
217218
bash packaging/aur/render.test.sh
218219
sh scripts/dev-cache.test.sh

docs/RELEASE_RUNBOOK.md

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -99,12 +99,21 @@ clippy/test/npm-smoke gates for `fix/*`, `rebrand/*`, `work/v*`, and `main`.
9999
GitHub Actions keeps the cheap drift/fmt statuses plus macOS and Windows
100100
coverage, while CNB carries the Linux work.
101101

102-
`publish-crates.sh dry-run` first validates the maintained publication order
103-
against the locked Cargo workspace graph. It then performs a full
104-
`cargo publish --dry-run` for crates without unpublished workspace dependencies
105-
and a packaging preflight for dependent workspace crates. That avoids false
106-
negatives from crates.io not yet containing the new workspace version while
107-
still validating package contents before publish.
102+
`publish-crates.sh` requires Cargo 1.90 or newer for multi-package verification;
103+
this release-tool requirement is separate from the runtime's Rust 1.88 MSRV.
104+
Use an up-to-date stable toolchain (`rustup update stable`) for release work.
105+
106+
Both modes validate publication order against the locked workspace graph, then
107+
run one `cargo publish --dry-run --locked --registry crates-io` covering all
108+
21 release crates. Cargo resolves unpublished workspace dependencies through a
109+
temporary local registry, builds every unpacked tarball, and checks publication
110+
metadata before any upload. Dry-run mode permits source edits and stops there.
111+
Publish mode requires the approved release checkout and assets, then skips
112+
versions already on crates.io and uploads the remaining crates in dependency
113+
order. Resuming still verifies the complete source release; it never weakens
114+
the artifact gate merely because an earlier crate was already uploaded.
115+
Registry-side acceptance and credentials are still checked during real upload;
116+
a successful preflight cannot guarantee that every later upload will succeed.
108117

109118
For npm wrapper verification, build the single runtime and run the
110119
cross-platform smoke harness. This packs the npm wrapper, installs it into a

scripts/release/publish-crates.sh

Lines changed: 29 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,19 @@ case "${mode}" in
1414
;;
1515
esac
1616

17+
# Multi-package publication verification was stabilized in Cargo 1.90.
18+
# Release tooling can require a newer Cargo than the runtime's Rust MSRV.
19+
cargo_version="$(cargo --version)"
20+
if [[ ! "${cargo_version}" =~ ^cargo[[:space:]]+([0-9]+)\.([0-9]+)\. ]] ||
21+
(( BASH_REMATCH[1] < 1 || (BASH_REMATCH[1] == 1 && BASH_REMATCH[2] < 90) )); then
22+
echo "Release preflight requires Cargo 1.90 or newer; found ${cargo_version}. Run rustup update stable and use that toolchain." >&2
23+
exit 1
24+
fi
25+
1726
packages=("${release_crates[@]}")
1827
crates_user_agent="CodeWhale release publish check (https://github.com/Hmbown/CodeWhale)"
1928

2029
workspace_version=""
21-
workspace_codewhale_packages=()
22-
workspace_package_dep_flags=()
2330

2431
metadata_inventory="$(
2532
python3 "${script_dir}/validate-crate-publish-order.py" "${packages[@]}"
@@ -29,10 +36,6 @@ while IFS=$'\t' read -r kind name value; do
2936
version)
3037
workspace_version="${name}"
3138
;;
32-
crate)
33-
workspace_codewhale_packages+=("${name}")
34-
workspace_package_dep_flags+=("${value}")
35-
;;
3639
esac
3740
done <<<"${metadata_inventory}"
3841

@@ -48,19 +51,23 @@ if [[ "${mode}" == "publish" ]]; then
4851
"${script_dir}/verify-release-assets.sh"
4952
fi
5053

51-
package_has_workspace_deps() {
52-
local package_name="$1"
53-
local index
54-
for ((index = 0; index < ${#workspace_codewhale_packages[@]}; index += 1)); do
55-
if [[ "${workspace_codewhale_packages[$index]}" == "${package_name}" ]]; then
56-
[[ "${workspace_package_dep_flags[$index]}" == "1" ]]
57-
return
58-
fi
59-
done
54+
# Verify the complete release together, including Cargo's publication checks.
55+
# Unpublished dependencies resolve through a temporary local registry, and
56+
# each unpacked tarball builds before the first real upload is attempted.
57+
package_args=(--dry-run --locked --registry crates-io)
58+
if [[ "${mode}" == "dry-run" ]]; then
59+
package_args+=(--allow-dirty)
60+
fi
61+
for package in "${packages[@]}"; do
62+
package_args+=(-p "${package}")
63+
done
6064

61-
echo "Unknown workspace crate: ${package_name}" >&2
62-
return 1
63-
}
65+
echo "Verifying all ${#packages[@]} release package tarballs before any upload..."
66+
cargo publish "${package_args[@]}"
67+
if [[ "${mode}" == "dry-run" ]]; then
68+
echo "Release package verification OK; no crates uploaded."
69+
exit 0
70+
fi
6471

6572
crate_version_exists() {
6673
local crate_name="$1"
@@ -87,20 +94,11 @@ wait_for_crate_version() {
8794

8895
for package in "${packages[@]}"; do
8996
echo "::group::${mode} ${package}"
90-
if [[ "${mode}" == "dry-run" ]]; then
91-
if package_has_workspace_deps "${package}"; then
92-
cargo package --allow-dirty --locked --list -p "${package}" >/dev/null
93-
echo "Verified package contents for ${package}; full crates.io dry-run requires workspace dependencies at ${workspace_version} to be published first."
94-
else
95-
cargo publish --dry-run --locked --allow-dirty -p "${package}"
96-
fi
97+
if crate_version_exists "${package}" "${workspace_version}"; then
98+
echo "Skipping ${package} ${workspace_version}; already published."
9799
else
98-
if crate_version_exists "${package}" "${workspace_version}"; then
99-
echo "Skipping ${package} ${workspace_version}; already published."
100-
else
101-
cargo publish --locked -p "${package}"
102-
wait_for_crate_version "${package}" "${workspace_version}"
103-
fi
100+
cargo publish --locked --registry crates-io -p "${package}"
101+
wait_for_crate_version "${package}" "${workspace_version}"
104102
fi
105103
echo "::endgroup::"
106104
done
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
#!/usr/bin/env python3
2+
"""Exercise the upload boundary with real Cargo tarballs and unpublished dependencies."""
3+
4+
import os
5+
from pathlib import Path
6+
import shutil
7+
import subprocess
8+
import tempfile
9+
import unittest
10+
11+
12+
SCRIPTS = Path(__file__).resolve().parent
13+
14+
15+
class PublishPreflightTests(unittest.TestCase):
16+
def setUp(self):
17+
real_cargo = shutil.which("cargo")
18+
self.assertIsNotNone(real_cargo, "Cargo 1.90+ must be installed to run release preflight tests")
19+
self.temporary = tempfile.TemporaryDirectory()
20+
self.addCleanup(self.temporary.cleanup)
21+
self.root = Path(self.temporary.name)
22+
self.scripts = self.root / "scripts/release"
23+
self.scripts.mkdir(parents=True)
24+
for name in ("publish-crates.sh", "validate-crate-publish-order.py"):
25+
shutil.copy2(SCRIPTS / name, self.scripts / name)
26+
(self.scripts / "crates.sh").write_text(
27+
"release_crates=(codewhale-preflight-base codewhale-preflight-app)\n"
28+
)
29+
# These independently tested guards require a real GitHub release. The
30+
# fixture tests the subsequent Cargo boundary without contacting GitHub.
31+
for name in ("require-release-tag-checkout.sh", "verify-release-assets.sh"):
32+
guard = self.scripts / name
33+
guard.write_text("#!/bin/sh\nexit 0\n")
34+
guard.chmod(0o755)
35+
(self.root / "Cargo.toml").write_text(
36+
'[workspace]\nmembers = ["base", "app"]\nresolver = "2"\n'
37+
)
38+
for name in ("base", "app"):
39+
crate = self.root / name
40+
(crate / "src").mkdir(parents=True)
41+
manifest = (
42+
f'[package]\nname = "codewhale-preflight-{name}"\n'
43+
'version = "0.0.0"\nedition = "2021"\nlicense = "MIT"\n'
44+
'exclude = ["src/payload.txt"]\n'
45+
)
46+
if name == "app":
47+
manifest += (
48+
'[dependencies]\ncodewhale-preflight-base = '
49+
'{ path = "../base", version = "=0.0.0" }\n'
50+
)
51+
(crate / "Cargo.toml").write_text(manifest)
52+
(crate / "src/lib.rs").write_text('pub const VALUE: &str = "ok";\n')
53+
(self.root / "app/src/lib.rs").write_text(
54+
'pub const VALUE: &str = include_str!("payload.txt");\n'
55+
)
56+
(self.root / "app/src/payload.txt").write_text("embedded asset\n")
57+
self.uploads = self.root / "uploads"
58+
bin_dir = self.root / "bin"
59+
bin_dir.mkdir()
60+
cargo = bin_dir / "cargo"
61+
cargo.write_text(
62+
'#!/usr/bin/env bash\nset -euo pipefail\n'
63+
'if [[ "${1:-}" == --version && -n "${TEST_CARGO_VERSION:-}" ]]; then\n'
64+
' echo "$TEST_CARGO_VERSION"; exit 0\nfi\n'
65+
# Cargo publish --dry-run still contacts the registry. Keep its real
66+
# tarball build for the old-script regression check, fully offline.
67+
'if [[ "${1:-}" == publish ]]; then\n'
68+
' shift\n if [[ " $* " == *" --dry-run "* ]]; then\n'
69+
' args=()\n for arg in "$@"; do\n'
70+
' [[ "$arg" == --dry-run ]] || args+=("$arg")\n done\n'
71+
' exec "$TEST_REAL_CARGO" package "${args[@]}"\n fi\n'
72+
' echo attempted >> "$TEST_UPLOADS"\n exit 98\nfi\n'
73+
'exec "$TEST_REAL_CARGO" "$@"\n'
74+
)
75+
cargo.chmod(0o755)
76+
curl = bin_dir / "curl"
77+
curl.write_text("#!/bin/sh\nexit 22\n")
78+
curl.chmod(0o755)
79+
self.env = {
80+
**os.environ,
81+
"TEST_REAL_CARGO": real_cargo,
82+
"TEST_UPLOADS": str(self.uploads),
83+
"PATH": str(bin_dir) + os.pathsep + os.environ["PATH"],
84+
"CARGO_NET_OFFLINE": "true",
85+
"CARGO_TARGET_DIR": str(self.root / "target"),
86+
}
87+
self.run_command(["cargo", "generate-lockfile", "--offline"], success=True)
88+
89+
def run_command(self, args, *, success):
90+
result = subprocess.run(
91+
args, cwd=self.root, env=self.env, capture_output=True, text=True
92+
)
93+
output = result.stdout + result.stderr
94+
self.assertEqual(result.returncode == 0, success, output)
95+
return output
96+
97+
def assert_missing_asset_blocks(self, mode):
98+
# The workspace compiles: only the published tarball loses the asset.
99+
self.run_command(["cargo", "check", "--locked"], success=True)
100+
output = self.run_command(
101+
["bash", str(self.scripts / "publish-crates.sh"), mode], success=False
102+
)
103+
self.assertFalse(self.uploads.exists(), "upload reached before all packages passed")
104+
self.assertIn("payload.txt", output)
105+
106+
def test_old_cargo_fails_before_packaging_or_upload(self):
107+
for version in ("cargo 1.88.0 (fixture)", "cargo 1.89.0 (fixture)", "unknown"):
108+
with self.subTest(version=version):
109+
self.env["TEST_CARGO_VERSION"] = version
110+
output = self.run_command(
111+
["bash", str(self.scripts / "publish-crates.sh"), "publish"], success=False
112+
)
113+
self.assertIn("requires Cargo 1.90 or newer", output)
114+
self.assertFalse(self.uploads.exists())
115+
self.assertFalse((self.root / "target/package").exists())
116+
117+
def test_resume_verifies_tarballs_and_skips_existing_versions(self):
118+
manifest = self.root / "app/Cargo.toml"
119+
manifest.write_text(manifest.read_text().replace('exclude = ["src/payload.txt"]\n', ""))
120+
(self.root / "bin/curl").write_text("#!/bin/sh\nexit 0\n")
121+
output = self.run_command(
122+
["bash", str(self.scripts / "publish-crates.sh"), "publish"], success=True
123+
)
124+
self.assertIn("Skipping codewhale-preflight-base", output)
125+
self.assertIn("Skipping codewhale-preflight-app", output)
126+
self.assertTrue((self.root / "target/package/codewhale-preflight-app-0.0.0.crate").exists())
127+
self.assertFalse(self.uploads.exists())
128+
129+
def test_dry_run_builds_dependent_tarball(self):
130+
self.assert_missing_asset_blocks("dry-run")
131+
132+
def test_publish_builds_every_tarball_before_first_upload(self):
133+
self.assert_missing_asset_blocks("publish")
134+
135+
def test_dry_run_accepts_unpublished_workspace_dependencies(self):
136+
manifest = self.root / "app/Cargo.toml"
137+
manifest.write_text(manifest.read_text().replace('exclude = ["src/payload.txt"]\n', ""))
138+
self.run_command(
139+
["bash", str(self.scripts / "publish-crates.sh"), "dry-run"], success=True
140+
)
141+
self.assertFalse(self.uploads.exists())
142+
143+
144+
if __name__ == "__main__":
145+
unittest.main()

0 commit comments

Comments
 (0)