Skip to content

Commit 669f042

Browse files
authored
Publish paired Go tags and add recovery conformance (#246)
* feat: publish paired Go module tags * Seal converged self-review attestation
1 parent f861099 commit 669f042

15 files changed

Lines changed: 655 additions & 19 deletions
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"reviewed_tree": "95b93fceaa40213ef42678c0e46c47207671ff31",
3+
"program_fingerprint": "3ca3397ff275d89bdb6d5c934b86b51d3cbdfab0ee628c47fe94d1d4f5767155"
4+
}
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
#!/usr/bin/env python3
2+
"""Publish one stable Boatstack root/module tag pair atomically."""
3+
4+
from __future__ import annotations
5+
6+
import argparse
7+
import json
8+
import re
9+
import subprocess
10+
import sys
11+
from pathlib import Path
12+
13+
14+
STABLE_TAG = re.compile(r"^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$")
15+
16+
17+
class PublicationBlocked(RuntimeError):
18+
"""The selected tag pair cannot be published safely."""
19+
20+
21+
def git(repository: Path, *arguments: str, check: bool = True) -> subprocess.CompletedProcess[str]:
22+
result = subprocess.run(
23+
["git", *arguments],
24+
cwd=repository,
25+
text=True,
26+
capture_output=True,
27+
)
28+
if check and result.returncode != 0:
29+
detail = result.stderr.strip() or result.stdout.strip()
30+
raise PublicationBlocked(f"git {' '.join(arguments)} failed: {detail}")
31+
return result
32+
33+
34+
def git_output(repository: Path, *arguments: str) -> str:
35+
return git(repository, *arguments).stdout.strip()
36+
37+
38+
def remote_ref(repository: Path, remote: str, ref: str) -> tuple[str, str] | None:
39+
result = git(repository, "ls-remote", remote, ref, f"{ref}^{{}}")
40+
refs: dict[str, str] = {}
41+
for line in result.stdout.splitlines():
42+
object_id, name = line.split(maxsplit=1)
43+
refs[name] = object_id
44+
direct = refs.get(ref)
45+
if direct is None:
46+
return None
47+
return direct, refs.get(f"{ref}^{{}}", direct)
48+
49+
50+
def exact_commit(repository: Path, source: str) -> str:
51+
resolved = git_output(repository, "rev-parse", "--verify", f"{source}^{{commit}}")
52+
if resolved != source:
53+
raise PublicationBlocked(f"release source must be an exact commit SHA: {source}")
54+
return resolved
55+
56+
57+
def require_source(repository: Path, remote: str, source: str) -> None:
58+
exact_commit(repository, source)
59+
head = git_output(repository, "rev-parse", "HEAD")
60+
if head != source:
61+
raise PublicationBlocked(f"checked-out source {head} does not match release source {source}")
62+
main = remote_ref(repository, remote, "refs/heads/main")
63+
if main is None:
64+
raise PublicationBlocked("remote main is absent")
65+
if main[0] != source:
66+
raise PublicationBlocked(f"remote main moved from {source} to {main[0]}")
67+
68+
69+
def inspect_pair(
70+
repository: Path,
71+
remote: str,
72+
root_tag: str,
73+
module_tag: str,
74+
) -> tuple[tuple[str, str] | None, tuple[str, str] | None]:
75+
root = remote_ref(repository, remote, f"refs/tags/{root_tag}")
76+
module = remote_ref(repository, remote, f"refs/tags/{module_tag}")
77+
return root, module
78+
79+
80+
def require_pair_absent(
81+
repository: Path,
82+
remote: str,
83+
root_tag: str,
84+
module_tag: str,
85+
) -> None:
86+
root, module = inspect_pair(repository, remote, root_tag, module_tag)
87+
if root is not None and module is not None and root[1] != module[1]:
88+
raise PublicationBlocked(
89+
f"paired tag targets differ: {root_tag}={root[1]}, {module_tag}={module[1]}"
90+
)
91+
existing = [
92+
tag
93+
for tag, target in ((root_tag, root), (module_tag, module))
94+
if target is not None
95+
]
96+
if existing:
97+
raise PublicationBlocked(f"release tag already exists: {', '.join(existing)}")
98+
99+
100+
def publish(
101+
repository: Path,
102+
remote: str,
103+
source: str,
104+
root_tag: str,
105+
module_tag: str,
106+
) -> dict[str, str]:
107+
if STABLE_TAG.fullmatch(root_tag) is None:
108+
raise PublicationBlocked(f"root tag is not a stable vMAJOR.MINOR.PATCH tag: {root_tag}")
109+
expected_module_tag = f"boatstack/{root_tag}"
110+
if module_tag != expected_module_tag:
111+
raise PublicationBlocked(
112+
f"module tag {module_tag} does not match derived tag {expected_module_tag}"
113+
)
114+
115+
require_source(repository, remote, source)
116+
require_pair_absent(repository, remote, root_tag, module_tag)
117+
for tag in (root_tag, module_tag):
118+
local = git(repository, "show-ref", "--verify", "--quiet", f"refs/tags/{tag}", check=False)
119+
if local.returncode == 0:
120+
raise PublicationBlocked(f"local release tag already exists: {tag}")
121+
if local.returncode != 1:
122+
raise PublicationBlocked(f"could not inspect local release tag: {tag}")
123+
124+
git(repository, "tag", "-a", root_tag, "-m", f"Boatstack {root_tag}", source)
125+
git(
126+
repository,
127+
"tag",
128+
"-a",
129+
module_tag,
130+
"-m",
131+
f"Boatstack Go module {root_tag}",
132+
source,
133+
)
134+
for tag in (root_tag, module_tag):
135+
target = git_output(repository, "rev-parse", f"refs/tags/{tag}^{{commit}}")
136+
if target != source:
137+
raise PublicationBlocked(f"local tag {tag} resolves to {target}, expected {source}")
138+
139+
# These checks deliberately run again after local tag creation. A racing
140+
# remote write is then rejected by the final non-force atomic push.
141+
require_source(repository, remote, source)
142+
require_pair_absent(repository, remote, root_tag, module_tag)
143+
git(
144+
repository,
145+
"push",
146+
"--atomic",
147+
remote,
148+
f"refs/tags/{root_tag}",
149+
f"refs/tags/{module_tag}",
150+
)
151+
152+
root, module = inspect_pair(repository, remote, root_tag, module_tag)
153+
if root is None or module is None or root[1] != source or module[1] != source:
154+
raise PublicationBlocked("published tag pair does not resolve to the release source")
155+
return {
156+
"module_tag": module_tag,
157+
"release_source": source,
158+
"root_tag": root_tag,
159+
}
160+
161+
162+
def main() -> int:
163+
parser = argparse.ArgumentParser()
164+
parser.add_argument("--repo", type=Path, default=Path.cwd())
165+
parser.add_argument("--remote", default="origin")
166+
parser.add_argument("--source", required=True)
167+
parser.add_argument("--root-tag", required=True)
168+
parser.add_argument("--module-tag", required=True)
169+
arguments = parser.parse_args()
170+
171+
try:
172+
result = publish(
173+
arguments.repo.resolve(),
174+
arguments.remote,
175+
arguments.source,
176+
arguments.root_tag,
177+
arguments.module_tag,
178+
)
179+
except PublicationBlocked as error:
180+
print(f"BLOCKED: {error}", file=sys.stderr)
181+
return 2
182+
print(json.dumps(result, sort_keys=True))
183+
return 0
184+
185+
186+
if __name__ == "__main__":
187+
raise SystemExit(main())

.github/scripts/release_candidate.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ def classify(repository: Path, source: str) -> dict[str, str]:
8686
"release_required": "true" if added else "false",
8787
"latest_tag": latest_tag,
8888
"next_tag": next_tag,
89+
"module_tag": f"boatstack/{next_tag}",
8990
"release_source": source,
9091
}
9192

.github/tests/test_docs_contract.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,22 @@ def assert_pages_contract(testcase: unittest.TestCase, workflow: str) -> None:
147147

148148

149149
class DocumentationContractTests(unittest.TestCase):
150+
def test_getting_started_distinguishes_cli_and_go_module_installation(self) -> None:
151+
getting_started = (REPO / "docs" / "getting-started.md").read_text()
152+
readme = (REPO / "README.md").read_text()
153+
module = "github.com/operatorstack/boatstack/boatstack"
154+
self.assertIn("## Install the CLI", getting_started)
155+
self.assertIn("checksum-verifying installer", getting_started)
156+
self.assertIn("## Import the Go module", getting_started)
157+
self.assertIn(f"go get {module}@vX.Y.Z", getting_started)
158+
self.assertIn(f'"{module}/kernel"', getting_started)
159+
self.assertIn(f'"{module}/kernel/conformance"', getting_started)
160+
self.assertIn(f"GOWORK=off go list -m {module}@vX.Y.Z", getting_started)
161+
self.assertIn("root and nested module tags", getting_started)
162+
self.assertIn("alpha", getting_started.lower())
163+
self.assertIn("CLI or import the Go module", readme)
164+
self.assertIn(module, readme)
165+
150166
def test_documentation_entrypoint_links_resolve(self) -> None:
151167
for path in (
152168
REPO / "README.md",
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
from __future__ import annotations
2+
3+
import os
4+
import subprocess
5+
import tempfile
6+
import unittest
7+
from pathlib import Path
8+
9+
10+
REPO = Path(__file__).resolve().parents[2]
11+
MODULE = REPO / "boatstack"
12+
MODULE_PATH = "github.com/operatorstack/boatstack/boatstack"
13+
14+
15+
class ExternalGoModuleTest(unittest.TestCase):
16+
def test_public_kernel_imports_compile_from_clean_module(self) -> None:
17+
with tempfile.TemporaryDirectory() as temporary:
18+
root = Path(temporary)
19+
consumer = root / "consumer"
20+
consumer.mkdir()
21+
(consumer / "go.mod").write_text(
22+
"\n".join(
23+
(
24+
"module example.invalid/boatstack-consumer",
25+
"",
26+
"go 1.26",
27+
"",
28+
f"require {MODULE_PATH} v0.0.0",
29+
"",
30+
f"replace {MODULE_PATH} => {MODULE.as_posix()}",
31+
"",
32+
)
33+
)
34+
)
35+
(consumer / "consumer.go").write_text(
36+
f'''package consumer
37+
38+
import (
39+
"{MODULE_PATH}/kernel"
40+
"{MODULE_PATH}/kernel/conformance"
41+
)
42+
43+
func ReferenceFixture() (kernel.Program, conformance.KernelConformance, error) {{
44+
program, err := conformance.IntegerProgram()
45+
return program, conformance.IntegerFixture(), err
46+
}}
47+
'''
48+
)
49+
environment = os.environ.copy()
50+
environment.update(
51+
{
52+
"GOCACHE": str(root / "go-cache"),
53+
"GOMODCACHE": str(root / "go-mod-cache"),
54+
"GOWORK": "off",
55+
}
56+
)
57+
result = subprocess.run(
58+
["go", "test", "./..."],
59+
cwd=consumer,
60+
env=environment,
61+
text=True,
62+
capture_output=True,
63+
)
64+
self.assertEqual(result.returncode, 0, result.stderr or result.stdout)
65+
66+
67+
if __name__ == "__main__":
68+
unittest.main()

0 commit comments

Comments
 (0)