Skip to content

Commit 1555825

Browse files
committed
Require signed release tags for publishing
1 parent 1d53db2 commit 1555825

5 files changed

Lines changed: 88 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@
4848
so `1.1.x` checkpoint tags consistently describe the current state as real
4949
non-dispatchable prototype evidence while active runtime dispatch remains
5050
scalar-only until a future `1.2.0` admission package.
51+
- Hardened the workspace publish helper so real crates.io publishing requires
52+
`HEAD` to match a verified signed `v<version>` tag, and documented the
53+
`git tag -v` release check.
5154

5255
## 1.1.0 - 2026-06-20
5356

docs/RELEASE.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -219,10 +219,11 @@ evidence, SBOM generation, reproducibility checks, and the standard local gate.
219219
If this fails, fix the release candidate before tagging.
220220

221221
After the full release gate passes, push the commit, wait for GitHub to become
222-
green, then create and push the immutable release tag:
222+
green, then create and push the immutable signed release tag:
223223

224224
```sh
225225
git tag -s v1.0.10 -m "base64-ng 1.0.10"
226+
git tag -v v1.0.10
226227
git push origin v1.0.10
227228
```
228229

@@ -236,7 +237,7 @@ scripts/release_crates.py
236237

237238
`scripts/release_crates.py` reads `release-crates.toml`, validates workspace
238239
crate versions and dependency order, refuses real publishing unless `HEAD`
239-
matches the `v<version>` tag, runs the standard local gate and
240+
matches a verified signed `v<version>` tag, runs the standard local gate and
240241
`cargo publish --dry-run` for each selected crate, publishes `base64-ng` first,
241242
waits for crates.io visibility, and then publishes dependent companion crates
242243
such as `base64-ng-sanitization` and `base64-ng-derive`, `base64-ng-serde`,

docs/RELEASE_EVIDENCE.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -443,12 +443,13 @@ scripts/release_crates.py
443443
```
444444

445445
The helper reads `release-crates.toml`, refuses real publishing unless `HEAD`
446-
matches the release tag, runs the standard local gate and `cargo publish
447-
--dry-run` for selected crates, publishes `base64-ng` first, waits for crates.io
448-
visibility, and then publishes dependent companion crates. The default publish
449-
preflight does not rerun Kani because Kani is already part of the pre-tag stable
450-
gate. Use `scripts/release_crates.py --full-gate` only when the release manager
451-
deliberately wants to rerun the expensive gate immediately before upload.
446+
matches a verified signed release tag, runs the standard local gate and
447+
`cargo publish --dry-run` for selected crates, publishes `base64-ng` first,
448+
waits for crates.io visibility, and then publishes dependent companion crates.
449+
The default publish preflight does not rerun Kani because Kani is already part
450+
of the pre-tag stable gate. Use `scripts/release_crates.py --full-gate` only
451+
when the release manager deliberately wants to rerun the expensive gate
452+
immediately before upload.
452453

453454
Manual fallback for companion releases:
454455

scripts/release_crates.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,26 @@ def check_release_tag(version: str, *, require_tag: bool) -> None:
217217
print(f"Warning: {message}.", file=sys.stderr)
218218
return
219219

220-
print(f"Release tag {tag} points at HEAD.")
220+
tag_signature = subprocess.run(
221+
["git", "tag", "-v", tag],
222+
cwd=ROOT,
223+
check=False,
224+
stdout=subprocess.PIPE,
225+
stderr=subprocess.PIPE,
226+
text=True,
227+
)
228+
if tag_signature.returncode != 0:
229+
detail = (tag_signature.stderr or tag_signature.stdout).strip()
230+
message = f"release tag {tag!r} is not signed or could not be verified"
231+
if detail:
232+
message = f"{message}: {detail}"
233+
if require_tag:
234+
print(f"Refusing to publish: {message}.", file=sys.stderr)
235+
sys.exit(1)
236+
print(f"Warning: {message}.", file=sys.stderr)
237+
return
238+
239+
print(f"Release tag {tag} points at HEAD and has a valid signature.")
221240

222241

223242
def confirm_no_verify(args: argparse.Namespace) -> int:

scripts/test-release-crates.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
from __future__ import annotations
55

66
import copy
7+
import contextlib
78
import importlib.util
9+
import io
810
from pathlib import Path
911
from types import SimpleNamespace
1012

@@ -186,6 +188,57 @@ def test_publish_sequence_dry_runs_dependents_after_index_wait() -> None:
186188
]
187189

188190

191+
def test_release_tag_check_requires_valid_signature() -> None:
192+
calls: list[tuple[str, ...]] = []
193+
194+
original_try_capture = release_crates.try_capture
195+
original_run = release_crates.subprocess.run
196+
try:
197+
release_crates.try_capture = lambda command: {
198+
("git", "rev-parse", "HEAD"): "abc",
199+
("git", "rev-list", "-n", "1", "v1.0.10"): "abc",
200+
}.get(tuple(command))
201+
202+
def fake_run(command, **kwargs):
203+
calls.append(tuple(command))
204+
return SimpleNamespace(returncode=0, stdout="Good signature", stderr="")
205+
206+
release_crates.subprocess.run = fake_run
207+
with contextlib.redirect_stdout(io.StringIO()):
208+
release_crates.check_release_tag("1.0.10", require_tag=True)
209+
finally:
210+
release_crates.try_capture = original_try_capture
211+
release_crates.subprocess.run = original_run
212+
213+
assert ("git", "tag", "-v", "v1.0.10") in calls
214+
215+
216+
def test_release_tag_check_rejects_unverified_required_tag() -> None:
217+
original_try_capture = release_crates.try_capture
218+
original_run = release_crates.subprocess.run
219+
try:
220+
release_crates.try_capture = lambda command: {
221+
("git", "rev-parse", "HEAD"): "abc",
222+
("git", "rev-list", "-n", "1", "v1.0.10"): "abc",
223+
}.get(tuple(command))
224+
225+
def fake_run(command, **kwargs):
226+
return SimpleNamespace(returncode=1, stdout="", stderr="no signature")
227+
228+
release_crates.subprocess.run = fake_run
229+
try:
230+
with contextlib.redirect_stderr(io.StringIO()):
231+
release_crates.check_release_tag("1.0.10", require_tag=True)
232+
except SystemExit as exc:
233+
assert exc.code == 1
234+
return
235+
finally:
236+
release_crates.try_capture = original_try_capture
237+
release_crates.subprocess.run = original_run
238+
239+
raise AssertionError("expected release tag check to exit")
240+
241+
189242
def run_tests() -> None:
190243
tests = (
191244
test_current_plan_accepts_unchanged_crates,
@@ -194,6 +247,8 @@ def run_tests() -> None:
194247
test_unchanged_crates_are_not_published,
195248
test_publish_plan_skips_unchanged_crates,
196249
test_publish_sequence_dry_runs_dependents_after_index_wait,
250+
test_release_tag_check_requires_valid_signature,
251+
test_release_tag_check_rejects_unverified_required_tag,
197252
)
198253
for test in tests:
199254
test()

0 commit comments

Comments
 (0)