diff --git a/.github/tests/go_source_metadata.go b/.github/tests/go_source_metadata.go new file mode 100644 index 0000000..2bd0ab1 --- /dev/null +++ b/.github/tests/go_source_metadata.go @@ -0,0 +1,85 @@ +package main + +import ( + "encoding/json" + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" + "path" + "sort" + "strconv" +) + +const kernelPackage = "github.com/operatorstack/boatstack/boatstack/kernel" + +var runtimeSymbols = map[string]bool{ + "NewRuntime": true, + "Runtime": true, + "Store": true, + "Domain": true, + "Operator": true, + "Receipt": true, +} + +type metadata struct { + Path string `json:"path"` + Imports []string `json:"imports"` + RuntimeConsumers []string `json:"runtime_consumers"` +} + +func main() { + results := make([]metadata, 0, len(os.Args)-1) + for _, filename := range os.Args[1:] { + if filename == "--" { + continue + } + parsed, err := parser.ParseFile(token.NewFileSet(), filename, nil, parser.AllErrors) + if err != nil { + fatalf("parse %s: %v", filename, err) + } + item := metadata{Path: filename, Imports: []string{}, RuntimeConsumers: []string{}} + kernelAliases := map[string]bool{} + for _, spec := range parsed.Imports { + importPath, err := strconv.Unquote(spec.Path.Value) + if err != nil { + fatalf("decode import in %s: %v", filename, err) + } + item.Imports = append(item.Imports, importPath) + if importPath != kernelPackage || (spec.Name != nil && spec.Name.Name == "_") { + continue + } + alias := path.Base(importPath) + if spec.Name != nil { + alias = spec.Name.Name + } + kernelAliases[alias] = true + } + ast.Inspect(parsed, func(node ast.Node) bool { + switch value := node.(type) { + case *ast.SelectorExpr: + alias, ok := value.X.(*ast.Ident) + if ok && kernelAliases[alias.Name] && runtimeSymbols[value.Sel.Name] { + item.RuntimeConsumers = append(item.RuntimeConsumers, value.Sel.Name) + } + case *ast.Ident: + if kernelAliases["."] && runtimeSymbols[value.Name] { + item.RuntimeConsumers = append(item.RuntimeConsumers, value.Name) + } + } + return true + }) + sort.Strings(item.Imports) + sort.Strings(item.RuntimeConsumers) + results = append(results, item) + } + if err := json.NewEncoder(os.Stdout).Encode(results); err != nil { + fatalf("encode metadata: %v", err) + } +} + +func fatalf(format string, args ...any) { + fmt.Fprintf(os.Stderr, format+"\n", args...) + os.Exit(1) +} diff --git a/.github/tests/test_repository_contract.py b/.github/tests/test_repository_contract.py index da90725..f479add 100644 --- a/.github/tests/test_repository_contract.py +++ b/.github/tests/test_repository_contract.py @@ -18,6 +18,24 @@ CONFIG = REPO / "project.example.json" +def go_source_metadata(paths: list[Path]) -> list[dict[str, object]]: + result = subprocess.run( + [ + "go", + "run", + str(REPO / ".github" / "tests" / "go_source_metadata.go"), + "--", + *map(str, paths), + ], + cwd=REPO, + text=True, + capture_output=True, + ) + if result.returncode != 0: + raise RuntimeError(result.stdout + result.stderr) + return json.loads(result.stdout) + + class RepositoryContract(unittest.TestCase): @classmethod def setUpClass(cls) -> None: @@ -174,6 +192,7 @@ def test_codex_review_is_secret_scoped_read_only_and_structured(self) -> None: self.assertFalse((REPO / "UPSTREAM.json").exists()) def test_release_builds_six_checksum_bound_v2_runtimes(self) -> None: + ci = (REPO / ".github" / "workflows" / "ci.yml").read_text() release = (REPO / ".github" / "workflows" / "release.yml").read_text() automatic = (REPO / ".github" / "workflows" / "auto-release.yml").read_text() for asset in ( @@ -189,7 +208,10 @@ def test_release_builds_six_checksum_bound_v2_runtimes(self) -> None: self.assertIn(symbol, release) self.assertIn('source_commit="$(git rev-parse HEAD)"', release) self.assertIn("sha256sum", release) - self.assertIn('workflows: ["Verify Boatstack distribution"]', automatic) + ci_name = re.search(r"(?m)^name:\s*(.+?)\s*$", ci) + self.assertIsNotNone(ci_name) + self.assertEqual(ci_name.group(1), "CI") + self.assertIn(f'workflows: ["{ci_name.group(1)}"]', automatic) def test_manual_release_is_prerelease_only_and_exact_source_bound(self) -> None: # control-law: branch-prerelease-publishes-only-an-exact-new-rc-source @@ -387,13 +409,45 @@ def test_public_tree_excludes_private_context_and_v1_operating_guidance(self) -> def test_general_kernel_is_domain_neutral_and_owns_shared_control_laws(self) -> None: kernel = REPO / "boatstack" / "kernel" - source = "\n".join(path.read_text() for path in sorted(kernel.glob("*.go"))) + kernel_files = sorted(kernel.glob("*.go")) + source = "\n".join(path.read_text() for path in kernel_files) for token in ( "git", "repository", "worktree", "branch", "pull request", "coding agent", "publication", ): self.assertNotIn(token, source.lower(), token) + boatstack_packages = "github.com/operatorstack/boatstack/boatstack/" + kernel_package = boatstack_packages + "kernel" + kernel_metadata = go_source_metadata(kernel_files) + for path, metadata in zip(kernel_files, kernel_metadata, strict=True): + invalid = [ + import_path + for import_path in metadata["imports"] + if import_path.startswith(boatstack_packages) + and import_path != kernel_package + ] + self.assertEqual([], invalid, f"kernel dependency direction: {path}") + + kernel_tests = sorted(kernel.glob("*_test.go")) + test_source = "\n".join(path.read_text() for path in kernel_tests) + self.assertNotRegex(test_source, r'\bexec\.Command\(\s*"git"') + self.assertNotRegex( + test_source, + r'\bexec\.CommandContext\([^,\n]+,\s*"git"', + ) + self.assertNotIn("testRepository", test_source) + self.assertNotIn("softwaredelivery", test_source.lower()) + test_metadata = go_source_metadata(kernel_tests) + for path, metadata in zip(kernel_tests, test_metadata, strict=True): + self.assertFalse( + any( + "/softwaredelivery" in import_path + for import_path in metadata["imports"] + ), + f"kernel test fixture imports software delivery: {path}", + ) + runtime = (kernel / "runtime.go").read_text() software_relation = ( REPO / "boatstack" / "internal" / "softwaredelivery" / @@ -431,6 +485,58 @@ def test_general_kernel_is_domain_neutral_and_owns_shared_control_laws(self) -> "./internal/effects", "./internal/surfaces", ): self.assertNotIn(retired, component_ci) + self.assertIn("run: go test -race ./...", component_ci) + self.assertIn( + "run: go test -race ./kernel ./internal/softwaredelivery/effects", + component_ci, + ) + + def test_go_import_parser_accepts_legal_import_forms(self) -> None: + with tempfile.TemporaryDirectory() as directory: + commented = Path(directory) / "commented.go" + commented.write_text( + 'package consumer\n\nimport /* migration T5 */ ' + '"github.com/operatorstack/boatstack/boatstack/kernel"\n' + ) + line_broken = Path(directory) / "line_broken.go" + line_broken.write_text( + 'package consumer\n\nimport\n b "\\u0067ithub.com/operatorstack/' + 'boatstack/boatstack/internal/buildinfo"\n' + ) + self.assertEqual( + [ + "github.com/operatorstack/boatstack/boatstack/kernel", + "github.com/operatorstack/boatstack/boatstack/internal/buildinfo", + ], + [ + metadata["imports"][0] + for metadata in go_source_metadata([commented, line_broken]) + ], + ) + + @unittest.expectedFailure + def test_kernel_runtime_has_no_production_consumer_yet(self) -> None: + # Migration task T5 must replace this marker with a permanent positive + # production-reachability assertion when the generic runtime is adopted. + production_files = [ + path + for path in sorted((REPO / "boatstack").rglob("*.go")) + if not path.name.endswith("_test.go") + and path.parent != REPO / "boatstack" / "kernel" + ] + consumers = [ + str(path.relative_to(REPO)) + for path, metadata in zip( + production_files, + go_source_metadata(production_files), + strict=True, + ) + if metadata["runtime_consumers"] + ] + self.assertTrue( + consumers, + "migration T5 has not connected the generic kernel runtime to production code", + ) def test_documented_cli_verbs_are_registered_v2_surfaces(self) -> None: documents = [ diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml index 5326de4..5d79420 100644 --- a/.github/workflows/auto-release.yml +++ b/.github/workflows/auto-release.yml @@ -3,7 +3,7 @@ name: Publish verified Boatstack release on: workflow_run: - workflows: ["Verify Boatstack distribution"] + workflows: ["CI"] types: [completed] permissions: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8397274..cd136c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,5 @@ # Boatstack-owned control plane. -name: Verify Boatstack distribution +name: CI on: pull_request: @@ -60,7 +60,12 @@ jobs: - name: Show toolchain run: go version working-directory: boatstack + - name: Test runtime with race detector + if: matrix.os == 'ubuntu-latest' + run: go test -race ./... + working-directory: boatstack - name: Test runtime + if: matrix.os == 'macos-latest' run: go test ./... working-directory: boatstack - name: Build helper @@ -70,6 +75,19 @@ jobs: shell: bash run: bash -n install.sh + race-critical: + name: race-critical + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v7 + with: + go-version-file: boatstack/go.mod + cache-dependency-path: boatstack/go.sum + - name: Test concurrency-bearing packages + working-directory: boatstack + run: go test -race ./kernel ./internal/softwaredelivery/effects + # Windows `go test` is dominated by per-process spawn latency (each test spawns # several `git` processes; the suite is ~330 tests run serially), so the full # suite takes ~15 min on Windows vs ~1 min on Unix. In-process t.Parallel() is diff --git a/release-notes/2026-08-12-kernel-architecture-guards.md b/release-notes/2026-08-12-kernel-architecture-guards.md new file mode 100644 index 0000000..4323db7 --- /dev/null +++ b/release-notes/2026-08-12-kernel-architecture-guards.md @@ -0,0 +1,3 @@ +### Guard the kernel architecture boundary + +Repository checks now prevent software-delivery dependencies and fixtures from entering the general kernel, exercise race detection on Linux, and keep the pending production-runtime migration visible.