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
4 changes: 4 additions & 0 deletions .github/reviews/codex-go-module-program-recovery.receipt.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"reviewed_tree": "95b93fceaa40213ef42678c0e46c47207671ff31",
"program_fingerprint": "3ca3397ff275d89bdb6d5c934b86b51d3cbdfab0ee628c47fe94d1d4f5767155"
}
187 changes: 187 additions & 0 deletions .github/scripts/publish_release_tags.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
#!/usr/bin/env python3
"""Publish one stable Boatstack root/module tag pair atomically."""

from __future__ import annotations

import argparse
import json
import re
import subprocess
import sys
from pathlib import Path


STABLE_TAG = re.compile(r"^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$")


class PublicationBlocked(RuntimeError):
"""The selected tag pair cannot be published safely."""


def git(repository: Path, *arguments: str, check: bool = True) -> subprocess.CompletedProcess[str]:
result = subprocess.run(
["git", *arguments],
cwd=repository,
text=True,
capture_output=True,
)
if check and result.returncode != 0:
detail = result.stderr.strip() or result.stdout.strip()
raise PublicationBlocked(f"git {' '.join(arguments)} failed: {detail}")
return result


def git_output(repository: Path, *arguments: str) -> str:
return git(repository, *arguments).stdout.strip()


def remote_ref(repository: Path, remote: str, ref: str) -> tuple[str, str] | None:
result = git(repository, "ls-remote", remote, ref, f"{ref}^{{}}")
refs: dict[str, str] = {}
for line in result.stdout.splitlines():
object_id, name = line.split(maxsplit=1)
refs[name] = object_id
direct = refs.get(ref)
if direct is None:
return None
return direct, refs.get(f"{ref}^{{}}", direct)


def exact_commit(repository: Path, source: str) -> str:
resolved = git_output(repository, "rev-parse", "--verify", f"{source}^{{commit}}")
if resolved != source:
raise PublicationBlocked(f"release source must be an exact commit SHA: {source}")
return resolved


def require_source(repository: Path, remote: str, source: str) -> None:
exact_commit(repository, source)
head = git_output(repository, "rev-parse", "HEAD")
if head != source:
raise PublicationBlocked(f"checked-out source {head} does not match release source {source}")
main = remote_ref(repository, remote, "refs/heads/main")
if main is None:
raise PublicationBlocked("remote main is absent")
if main[0] != source:
raise PublicationBlocked(f"remote main moved from {source} to {main[0]}")


def inspect_pair(
repository: Path,
remote: str,
root_tag: str,
module_tag: str,
) -> tuple[tuple[str, str] | None, tuple[str, str] | None]:
root = remote_ref(repository, remote, f"refs/tags/{root_tag}")
module = remote_ref(repository, remote, f"refs/tags/{module_tag}")
return root, module


def require_pair_absent(
repository: Path,
remote: str,
root_tag: str,
module_tag: str,
) -> None:
root, module = inspect_pair(repository, remote, root_tag, module_tag)
if root is not None and module is not None and root[1] != module[1]:
raise PublicationBlocked(
f"paired tag targets differ: {root_tag}={root[1]}, {module_tag}={module[1]}"
)
existing = [
tag
for tag, target in ((root_tag, root), (module_tag, module))
if target is not None
]
if existing:
raise PublicationBlocked(f"release tag already exists: {', '.join(existing)}")


def publish(
repository: Path,
remote: str,
source: str,
root_tag: str,
module_tag: str,
) -> dict[str, str]:
if STABLE_TAG.fullmatch(root_tag) is None:
raise PublicationBlocked(f"root tag is not a stable vMAJOR.MINOR.PATCH tag: {root_tag}")
expected_module_tag = f"boatstack/{root_tag}"
if module_tag != expected_module_tag:
raise PublicationBlocked(
f"module tag {module_tag} does not match derived tag {expected_module_tag}"
)

require_source(repository, remote, source)
require_pair_absent(repository, remote, root_tag, module_tag)
for tag in (root_tag, module_tag):
local = git(repository, "show-ref", "--verify", "--quiet", f"refs/tags/{tag}", check=False)
if local.returncode == 0:
raise PublicationBlocked(f"local release tag already exists: {tag}")
if local.returncode != 1:
raise PublicationBlocked(f"could not inspect local release tag: {tag}")

git(repository, "tag", "-a", root_tag, "-m", f"Boatstack {root_tag}", source)
git(
repository,
"tag",
"-a",
module_tag,
"-m",
f"Boatstack Go module {root_tag}",
source,
)
for tag in (root_tag, module_tag):
target = git_output(repository, "rev-parse", f"refs/tags/{tag}^{{commit}}")
if target != source:
raise PublicationBlocked(f"local tag {tag} resolves to {target}, expected {source}")

# These checks deliberately run again after local tag creation. A racing
# remote write is then rejected by the final non-force atomic push.
require_source(repository, remote, source)
require_pair_absent(repository, remote, root_tag, module_tag)
git(
repository,
"push",
"--atomic",
remote,
f"refs/tags/{root_tag}",
f"refs/tags/{module_tag}",
)

root, module = inspect_pair(repository, remote, root_tag, module_tag)
if root is None or module is None or root[1] != source or module[1] != source:
raise PublicationBlocked("published tag pair does not resolve to the release source")
return {
"module_tag": module_tag,
"release_source": source,
"root_tag": root_tag,
}


def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--repo", type=Path, default=Path.cwd())
parser.add_argument("--remote", default="origin")
parser.add_argument("--source", required=True)
parser.add_argument("--root-tag", required=True)
parser.add_argument("--module-tag", required=True)
arguments = parser.parse_args()

try:
result = publish(
arguments.repo.resolve(),
arguments.remote,
arguments.source,
arguments.root_tag,
arguments.module_tag,
)
except PublicationBlocked as error:
print(f"BLOCKED: {error}", file=sys.stderr)
return 2
print(json.dumps(result, sort_keys=True))
return 0


if __name__ == "__main__":
raise SystemExit(main())
1 change: 1 addition & 0 deletions .github/scripts/release_candidate.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ def classify(repository: Path, source: str) -> dict[str, str]:
"release_required": "true" if added else "false",
"latest_tag": latest_tag,
"next_tag": next_tag,
"module_tag": f"boatstack/{next_tag}",
"release_source": source,
}

Expand Down
16 changes: 16 additions & 0 deletions .github/tests/test_docs_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,22 @@ def assert_pages_contract(testcase: unittest.TestCase, workflow: str) -> None:


class DocumentationContractTests(unittest.TestCase):
def test_getting_started_distinguishes_cli_and_go_module_installation(self) -> None:
getting_started = (REPO / "docs" / "getting-started.md").read_text()
readme = (REPO / "README.md").read_text()
module = "github.com/operatorstack/boatstack/boatstack"
self.assertIn("## Install the CLI", getting_started)
self.assertIn("checksum-verifying installer", getting_started)
self.assertIn("## Import the Go module", getting_started)
self.assertIn(f"go get {module}@vX.Y.Z", getting_started)
self.assertIn(f'"{module}/kernel"', getting_started)
self.assertIn(f'"{module}/kernel/conformance"', getting_started)
self.assertIn(f"GOWORK=off go list -m {module}@vX.Y.Z", getting_started)
self.assertIn("root and nested module tags", getting_started)
self.assertIn("alpha", getting_started.lower())
self.assertIn("CLI or import the Go module", readme)
self.assertIn(module, readme)

def test_documentation_entrypoint_links_resolve(self) -> None:
for path in (
REPO / "README.md",
Expand Down
68 changes: 68 additions & 0 deletions .github/tests/test_external_go_module.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
from __future__ import annotations

import os
import subprocess
import tempfile
import unittest
from pathlib import Path


REPO = Path(__file__).resolve().parents[2]
MODULE = REPO / "boatstack"
MODULE_PATH = "github.com/operatorstack/boatstack/boatstack"


class ExternalGoModuleTest(unittest.TestCase):
def test_public_kernel_imports_compile_from_clean_module(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
consumer = root / "consumer"
consumer.mkdir()
(consumer / "go.mod").write_text(
"\n".join(
(
"module example.invalid/boatstack-consumer",
"",
"go 1.26",
"",
f"require {MODULE_PATH} v0.0.0",
"",
f"replace {MODULE_PATH} => {MODULE.as_posix()}",
"",
)
)
)
(consumer / "consumer.go").write_text(
f'''package consumer

import (
"{MODULE_PATH}/kernel"
"{MODULE_PATH}/kernel/conformance"
)

func ReferenceFixture() (kernel.Program, conformance.KernelConformance, error) {{
program, err := conformance.IntegerProgram()
return program, conformance.IntegerFixture(), err
}}
'''
)
environment = os.environ.copy()
environment.update(
{
"GOCACHE": str(root / "go-cache"),
"GOMODCACHE": str(root / "go-mod-cache"),
"GOWORK": "off",
}
)
result = subprocess.run(
["go", "test", "./..."],
cwd=consumer,
env=environment,
text=True,
capture_output=True,
)
self.assertEqual(result.returncode, 0, result.stderr or result.stdout)


if __name__ == "__main__":
unittest.main()
Loading
Loading