diff --git a/.github/workflows/boatstack-publish.yml b/.github/workflows/boatstack-publish.yml new file mode 100644 index 000000000..3e5a1b546 --- /dev/null +++ b/.github/workflows/boatstack-publish.yml @@ -0,0 +1,42 @@ +name: Publish Boatstack projection + +on: + push: + branches: [main] + paths: + - "examples/12-product-engineering-loop/**" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: publish-boatstack-projection + cancel-in-progress: false + +jobs: + dispatch: + if: github.repository == 'operatorstack/intelligence-flow' + runs-on: ubuntu-latest + steps: + - name: Create cross-repository automation token + id: app-token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.BOATSTACK_APP_CLIENT_ID }} + private-key: ${{ secrets.BOATSTACK_APP_PRIVATE_KEY }} + owner: operatorstack + repositories: boatstack + permission-actions: write + permission-contents: read + + - name: Dispatch Boatstack sync + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + SOURCE_COMMIT: ${{ github.sha }} + shell: bash + run: >- + gh workflow run sync-upstream.yml + --repo operatorstack/boatstack + --ref main + -f source_commit="$SOURCE_COMMIT" diff --git a/examples/12-product-engineering-loop/README.md b/examples/12-product-engineering-loop/README.md index acdacf9ce..f29382fbf 100644 --- a/examples/12-product-engineering-loop/README.md +++ b/examples/12-product-engineering-loop/README.md @@ -27,6 +27,8 @@ This repository remains the source of truth for how the node works. Boatstack po Every change to this example also adds one append-only, release-level Markdown fragment under `boatstack-distribution/release-notes/`. The projector copies those fragments byte-for-byte so the downstream sync PR and tagged Boatstack release can present the same reviewed user-facing message without reconstructing it from commits or diffs. +After a relevant merge reaches Intelligence Flow `main`, the publish workflow uses the repository-scoped Boatstack GitHub App to dispatch the downstream projection immediately. Boatstack keeps the generated PR and cross-platform CI boundaries, merges verified syncs automatically, and publishes the next patch release only when the projected diff changes the installed harness. README, documentation, evidence, examples, tests, provenance, and control-plane-only changes sync without producing a binary release; the scheduled downstream poll remains a recovery fallback. + Preview the exact downstream projection in a local clone. This projection command is maintainer tooling; Boatstack users install a precompiled helper and do not need Python: ```bash diff --git a/examples/12-product-engineering-loop/boatstack-distribution/release-notes/2026-07-18-automated-releases.md b/examples/12-product-engineering-loop/boatstack-distribution/release-notes/2026-07-18-automated-releases.md new file mode 100644 index 000000000..830f52d14 --- /dev/null +++ b/examples/12-product-engineering-loop/boatstack-distribution/release-notes/2026-07-18-automated-releases.md @@ -0,0 +1,3 @@ +### Intelligence Flow merges now publish Boatstack automatically + +Boatstack now receives an immediate projection after relevant Intelligence Flow merges. Generated changes still pass the downstream pull-request and cross-platform test gates, while runtime changes receive the next patch release automatically and documentation-only changes sync without publishing unnecessary binaries. diff --git a/examples/12-product-engineering-loop/product-engineering-loop/cmd/boatstack-helper/main.go b/examples/12-product-engineering-loop/product-engineering-loop/cmd/boatstack-helper/main.go index 96086e8a2..291bd339c 100644 --- a/examples/12-product-engineering-loop/product-engineering-loop/cmd/boatstack-helper/main.go +++ b/examples/12-product-engineering-loop/product-engineering-loop/cmd/boatstack-helper/main.go @@ -63,6 +63,36 @@ func checkUpdateCommand(arguments []string) int { return 0 } +func releaseClassifyCommand(arguments []string) int { + flags := flag.NewFlagSet("release-classify", flag.ContinueOnError) + repo := flags.String("repo", ".", "projected Boatstack repository") + base := flags.String("base", "", "latest released tag or commit") + head := flags.String("head", "HEAD", "candidate release commit") + if err := flags.Parse(arguments); err != nil { + return 2 + } + classification, err := boatstack.ClassifyReleaseDiff(*repo, *base, *head) + if err != nil { + return fail(err) + } + fmt.Printf("release_required=%t\nrelease_paths=%s\n", classification.Required, strings.Join(classification.Paths, ",")) + return 0 +} + +func nextPatchCommand(arguments []string) int { + flags := flag.NewFlagSet("next-patch", flag.ContinueOnError) + version := flags.String("version", "", "current stable vMAJOR.MINOR.PATCH version") + if err := flags.Parse(arguments); err != nil { + return 2 + } + next, err := boatstack.NextPatchVersion(*version) + if err != nil { + return fail(err) + } + fmt.Println(next) + return 0 +} + func exportCommand(arguments []string) int { flags := flag.NewFlagSet("export", flag.ContinueOnError) repo := flags.String("repo", "", "repository to export into") @@ -434,7 +464,7 @@ func publishPRCommand(arguments []string) int { func run() int { if len(os.Args) < 2 { - fmt.Fprintln(os.Stderr, "usage: boatstack-helper ") + fmt.Fprintln(os.Stderr, "usage: boatstack-helper ") return 2 } switch os.Args[1] { @@ -444,6 +474,10 @@ func run() int { return updateCommand(os.Args[2:]) case "check-update": return checkUpdateCommand(os.Args[2:]) + case "release-classify": + return releaseClassifyCommand(os.Args[2:]) + case "next-patch": + return nextPatchCommand(os.Args[2:]) case "export": return exportCommand(os.Args[2:]) case "check-source-plan": diff --git a/examples/12-product-engineering-loop/product-engineering-loop/release.go b/examples/12-product-engineering-loop/product-engineering-loop/release.go new file mode 100644 index 000000000..be0b7511f --- /dev/null +++ b/examples/12-product-engineering-loop/product-engineering-loop/release.go @@ -0,0 +1,94 @@ +package boatstack + +import ( + "fmt" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" +) + +var stableReleaseVersion = regexp.MustCompile(`^v(\d+)\.(\d+)\.(\d+)$`) + +// ReleaseClassification separates a projected documentation sync from a +// change that alters the installed Boatstack delivery harness. +type ReleaseClassification struct { + Required bool + Paths []string +} + +func normalizedReleasePath(value string) string { + return filepath.ToSlash(filepath.Clean(strings.TrimSpace(value))) +} + +func isReleaseBearingPath(value string) bool { + path := normalizedReleasePath(value) + if path == "." || path == "" { + return false + } + for _, exact := range []string{ + ".gitignore", "CONTRIBUTING.md", "README.md", "UPSTREAM.json", + "project.example.json", + } { + if path == exact { + return false + } + } + for _, prefix := range []string{ + ".github/", "assets/", "automation/", "docs/", "examples/", "release-notes/", + } { + if strings.HasPrefix(path, prefix) { + return false + } + } + if strings.HasPrefix(path, "boatstack/testdata/") || strings.HasSuffix(path, "_test.go") { + return false + } + return true +} + +// ClassifyReleasePaths is conservative: unknown projected product paths are +// release-bearing, while known presentation, provenance, test, and control +// plane paths are not. +func ClassifyReleasePaths(paths []string) ReleaseClassification { + releasePaths := make([]string, 0, len(paths)) + seen := map[string]bool{} + for _, value := range paths { + path := normalizedReleasePath(value) + if !seen[path] && isReleaseBearingPath(path) { + seen[path] = true + releasePaths = append(releasePaths, path) + } + } + sort.Strings(releasePaths) + return ReleaseClassification{Required: len(releasePaths) > 0, Paths: releasePaths} +} + +// ClassifyReleaseDiff reads the exact projected Git diff used by the release +// workflow and applies the same deterministic path policy as unit tests. +func ClassifyReleaseDiff(repo, base, head string) (ReleaseClassification, error) { + if strings.TrimSpace(base) == "" || strings.TrimSpace(head) == "" { + return ReleaseClassification{}, fmt.Errorf("release classification requires base and head revisions") + } + command := exec.Command("git", "-C", repo, "diff", "--name-only", "--no-renames", base, head) + output, err := command.CombinedOutput() + if err != nil { + return ReleaseClassification{}, fmt.Errorf("release diff failed: %s", strings.TrimSpace(string(output))) + } + return ClassifyReleasePaths(strings.Split(strings.TrimSpace(string(output)), "\n")), nil +} + +// NextPatchVersion returns the next stable patch version. Minor and major +// releases remain deliberate changes rather than being inferred from commits. +func NextPatchVersion(current string) (string, error) { + matches := stableReleaseVersion.FindStringSubmatch(strings.TrimSpace(current)) + if matches == nil { + return "", fmt.Errorf("release version must match vMAJOR.MINOR.PATCH: %s", current) + } + major, _ := strconv.Atoi(matches[1]) + minor, _ := strconv.Atoi(matches[2]) + patch, _ := strconv.Atoi(matches[3]) + return fmt.Sprintf("v%d.%d.%d", major, minor, patch+1), nil +} diff --git a/examples/12-product-engineering-loop/product-engineering-loop/release_test.go b/examples/12-product-engineering-loop/product-engineering-loop/release_test.go new file mode 100644 index 000000000..3aff5df83 --- /dev/null +++ b/examples/12-product-engineering-loop/product-engineering-loop/release_test.go @@ -0,0 +1,72 @@ +package boatstack + +import ( + "os" + "path/filepath" + "reflect" + "testing" +) + +func TestClassifyReleasePaths(t *testing.T) { + documentation := []string{ + "README.md", "docs/getting-started.md", "assets/boatstack-mark.svg", + "release-notes/2026-07-18-copy.md", "UPSTREAM.json", + "boatstack/export_test.go", "boatstack/testdata/example.txt", + ".github/workflows/sync-upstream.yml", "automation/release-policy.md", + } + if got := ClassifyReleasePaths(documentation); got.Required || len(got.Paths) != 0 { + t.Fatalf("documentation-only projection requested a release: %#v", got) + } + + runtime := append(documentation, + "boatstack/safety.go", "boatstack/SKILL.md", "install.sh", "new-runtime-path", + ) + want := []string{"boatstack/SKILL.md", "boatstack/safety.go", "install.sh", "new-runtime-path"} + got := ClassifyReleasePaths(runtime) + if !got.Required || !reflect.DeepEqual(got.Paths, want) { + t.Fatalf("runtime classification = %#v, want %#v", got, want) + } +} + +func TestClassifyReleaseDiff(t *testing.T) { + repo := t.TempDir() + runGit(t, repo, "init", "-b", "main") + runGit(t, repo, "config", "user.name", "Boatstack Test") + runGit(t, repo, "config", "user.email", "boatstack@example.invalid") + if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("one\n"), 0o644); err != nil { + t.Fatal(err) + } + runGit(t, repo, "add", ".") + runGit(t, repo, "commit", "-m", "base") + base := runGit(t, repo, "rev-parse", "HEAD") + if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("two\n"), 0o644); err != nil { + t.Fatal(err) + } + runGit(t, repo, "add", ".") + runGit(t, repo, "commit", "-m", "docs") + docsHead := runGit(t, repo, "rev-parse", "HEAD") + if got, err := ClassifyReleaseDiff(repo, base, docsHead); err != nil || got.Required { + t.Fatalf("documentation diff = %#v, %v", got, err) + } + if err := os.MkdirAll(filepath.Join(repo, "boatstack"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repo, "boatstack", "runtime.go"), []byte("package boatstack\n"), 0o644); err != nil { + t.Fatal(err) + } + runGit(t, repo, "add", ".") + runGit(t, repo, "commit", "-m", "runtime") + runtimeHead := runGit(t, repo, "rev-parse", "HEAD") + if got, err := ClassifyReleaseDiff(repo, docsHead, runtimeHead); err != nil || !got.Required { + t.Fatalf("runtime diff = %#v, %v", got, err) + } +} + +func TestNextPatchVersion(t *testing.T) { + if got, err := NextPatchVersion("v0.7.0"); err != nil || got != "v0.7.1" { + t.Fatalf("next patch = %q, %v", got, err) + } + if _, err := NextPatchVersion("latest"); err == nil { + t.Fatal("invalid release version was accepted") + } +} diff --git a/examples/12-product-engineering-loop/tests/test_product_loop.py b/examples/12-product-engineering-loop/tests/test_product_loop.py index e56c66cb8..2f6ef6077 100644 --- a/examples/12-product-engineering-loop/tests/test_product_loop.py +++ b/examples/12-product-engineering-loop/tests/test_product_loop.py @@ -94,6 +94,21 @@ def test_upstream_ci_is_scoped_to_the_boatstack_example(self) -> None: self.assertIn("release_notes.py check-policy", workflow) self.assertIn("fetch-depth: 0", workflow) + def test_boatstack_publish_uses_the_repository_github_app(self) -> None: + workflow = ( + EXAMPLE.parents[1] / ".github" / "workflows" / "boatstack-publish.yml" + ).read_text() + self.assertIn("push:\n branches: [main]", workflow) + self.assertIn('"examples/12-product-engineering-loop/**"', workflow) + self.assertIn("actions/create-github-app-token@v3", workflow) + self.assertIn("vars.BOATSTACK_APP_CLIENT_ID", workflow) + self.assertIn("secrets.BOATSTACK_APP_PRIVATE_KEY", workflow) + self.assertIn("permission-actions: write", workflow) + self.assertIn("operatorstack/boatstack", workflow) + self.assertIn('source_commit="$SOURCE_COMMIT"', workflow) + self.assertNotIn("PERSONAL_ACCESS_TOKEN", workflow) + self.assertNotIn("BOATSTACK_SYNC_TOKEN", workflow) + def test_release_note_contract_validation_and_ordering(self) -> None: with tempfile.TemporaryDirectory() as temp: root = Path(temp)