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
42 changes: 42 additions & 0 deletions .github/workflows/boatstack-publish.yml
Original file line number Diff line number Diff line change
@@ -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"
2 changes: 2 additions & 0 deletions examples/12-product-engineering-loop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -434,7 +464,7 @@ func publishPRCommand(arguments []string) int {

func run() int {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "usage: boatstack-helper <init|update|check-update|export|check-source-plan|planning-write|check-plan|record-approval|activate-plan|delivery-status|record-delivery-gate|check-safety|safety-hook|pr-context|check-pr|publish-pr|doctor|version>")
fmt.Fprintln(os.Stderr, "usage: boatstack-helper <init|update|check-update|release-classify|next-patch|export|check-source-plan|planning-write|check-plan|record-approval|activate-plan|delivery-status|record-delivery-gate|check-safety|safety-hook|pr-context|check-pr|publish-pr|doctor|version>")
return 2
}
switch os.Args[1] {
Expand All @@ -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":
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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")
}
}
15 changes: 15 additions & 0 deletions examples/12-product-engineering-loop/tests/test_product_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down