diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 52f10cf..69b6666 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,7 +2,7 @@ # Contributing -Boatstack is a generated content distribution. Propose changes to workflow semantics, templates, evidence rules, or generated presentation in [Intelligence Flow](https://github.com/operatorstack/intelligence-flow/tree/d37975f8c3960b03c701f029d4815f6d6814ed7c/examples/12-product-engineering-loop). +Boatstack is a generated content distribution. Propose changes to workflow semantics, templates, evidence rules, or generated presentation in [Intelligence Flow](https://github.com/operatorstack/intelligence-flow/tree/4bc614dd6355dcc408e2a49955cb53b868d5dee8/examples/12-product-engineering-loop). The Boatstack repository receives product/runtime changes through a generated pull request. Review the PR's `UPSTREAM.json`, tests, adapter diff, and context-size change; do not hand-edit generated output on `main`. `.github/workflows` is the exception: it is Boatstack's executable control plane, excluded from scheduled projection and changed only through a separate manually reviewed Boatstack PR. diff --git a/README.md b/README.md index efe3e89..7b07172 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,12 @@ Boatstack does not replace your product context or force a new documentation sys These tools may propose content. They do not approve their own proposal or bypass Boatstack's evidence checks. +## Updates stay out of product work + +After a PR is published, Boatstack can quietly report that a new stable release exists. It does not change the feature branch. From a clean default branch, `/boatstack-update` prepares a versioned infrastructure branch, shows the exact diff, and waits for `open update PR` before changing GitHub. It never merges the update. + +[See how updates remain visible and separate](docs/getting-started.md#keeping-boatstack-current). +
Install manually diff --git a/UPSTREAM.json b/UPSTREAM.json index c257a0b..91e75fb 100644 --- a/UPSTREAM.json +++ b/UPSTREAM.json @@ -1,7 +1,7 @@ { "canonical_context": { - "characters": 30577, - "estimated_tokens": 7645, + "characters": 32685, + "estimated_tokens": 8172, "estimator": "ceil(total characters / 4); compactness signal, not provider billing", "files": [ "product-engineering-loop/references/workflow.md", @@ -12,12 +12,12 @@ }, "files": { ".gitignore": "a7079e923a776f14f1bb3a6aa0a11a133a8e1dfb35af020f327623357b7e3957", - "CONTRIBUTING.md": "fe20dd1fbfa7e5b2cca253ce089808b388250ddecec603099688064a23e4e664", - "README.md": "0b817ceabe61d874f21f98362f550cb77f1819bfc3db6df40b69337f50b4641c", + "CONTRIBUTING.md": "c08d6fc79c330a4cb3e8f2a8ab1d4ca2a44c8cb294f0c523fbc6a587a0695875", + "README.md": "9dcc7ba6fd7032b2a11d66891a353508f2f23ffa8fb8969a32a94a5e6db1a3aa", "assets/boatstack-journey.svg": "c1f7fe2741f5e9ca66bb3fe9b103e6364ba5acbca8b7a8054768ffd85cf325ea", "assets/boatstack-mark.svg": "c46e935f06fcfde3b37abfd579c1963b765b2337a0fa993f9538c9b652297e39", "assets/boatstack-model-choice.svg": "979952c2fc6220d41426f9072186fca000fb3f388a4e775cc09cc1e830ebdde4", - "boatstack/SKILL.md": "4d80c39521763831655df0f98224769e0bf3caf535c0751ecf96c33bc33039e5", + "boatstack/SKILL.md": "5c37ec90eb8c3eae60f435f5d62afd1dc826f8bca6916726588379bcb99f3f58", "boatstack/agents/openai.yaml": "68a30a60859556c5a26e16d184594ca243a6043d99c8cf7d66b5dd6d50a93cd1", "boatstack/assets/templates/adr.md": "c577a3c1c1319061f61deb053597e6e853657022185fe28b8f733327e2a78565", "boatstack/assets/templates/approval.md": "74b0b816703a6dce3c96c8f95f981af910b020b6908e7f76cf5630778637e9f5", @@ -31,18 +31,18 @@ "boatstack/assets/templates/test-plan.md": "6db8a9f27dd171fb80222a501cae50eb051e7278c04703fa43b5ff86dd4d2df4", "boatstack/atomic_unix.go": "89f2723361591de2bb8bd22ce7e34ec529d3278509f0df78fd5c4a7d4140fbe9", "boatstack/atomic_windows.go": "cefd775cbe7e7c3bd8a3f5673b11cdd784c6d3ebd6de7dcb8f39406b0bee511f", - "boatstack/cmd/boatstack-helper/main.go": "7e3039dbae40a98666709583c42ca52fc94570b6cfebf16508c100d6f9593144", - "boatstack/export.go": "bfc8971516addb67cbc27cfce44180e4b62db2b3dc588634d7d5b8f8762d9d1c", - "boatstack/export_test.go": "dd3c2ea58f1ba5591ac21c7c161c730a8d0ac11cbca0148d7f6d505a6a8d0e4b", + "boatstack/cmd/boatstack-helper/main.go": "93a3eda3c6216018d83e0e419e201da7a046aab8f36f9dc6cb2f8b0842335242", + "boatstack/export.go": "c53c5ec83dcea392d2e360c6819202b5f7cf9b3ae64510087bd627c4aaee82e3", + "boatstack/export_test.go": "f95be9c458645e9b150f25921a0452c9e10e6a94809567ee5ddbc8e833e4f040", "boatstack/go.mod": "daf262a00abfe961d8ca266d4b26eea09a6aee73e4c53baaa537a809eaef59f6", "boatstack/hooks.go": "3030ca262a39b5bbef8509bb1395b3b9635719dd9ce0b196f72c596922509e92", "boatstack/hooks_test.go": "5b8852e6176d96315c983f261d8503b57298063eb251283088eb103e42d7ec0d", - "boatstack/init.go": "90d38fb666ac8bbdf3c3f17f8367d3542963477a1eb67a4f1ffcc83dad01a7a6", - "boatstack/init_test.go": "5a65a7d8243e615ef47f797680a3b508a15a7b638e43c857ac74fb139cfded58", + "boatstack/init.go": "74e8f1dda5761c36d62c6679ee8e935138115b930f654c9b9d04a153af2642a7", + "boatstack/init_test.go": "b761ada1f5a04c0a27225a6f1eb99baf5477a424c5a9748a3267f07ba5a84605", "boatstack/integrations.go": "75b39ce2e662fccd66bf4b9bff0e097a4db558f23b3aa1d9bc83a5fc6373444c", "boatstack/plan.go": "519fe7a782c0384d62fda228c58145d36e01a3941691b1839a2d1476528c27b7", "boatstack/plan_test.go": "006cdc6681f77e579c5a0f709e30ede759c337132d4f2f5193b7b79b29bd7149", - "boatstack/planning.go": "a4ff58547b6b880742d213c3cebccb2d7288a622b6c392c31f7dba827af71568", + "boatstack/planning.go": "9485eedde503f54141388ea91d6d7a17f5d59663a38db2af9fddcd933f057571", "boatstack/planning_test.go": "4662908c1ec063aa8ef6f91db52247864303d9b91ef2363a8f68b41082fe383f", "boatstack/pr.go": "6fbfd1e673c55e8358090693b20edb9bc6e8efb8913de9ffcaa5143ca24f0947", "boatstack/pr_test.go": "f200a3a860e3da22798ec17a8eaf335724885b09d48b36bf0c350acdd3cc3ab7", @@ -50,45 +50,47 @@ "boatstack/references/failure-moves.md": "5ac4bbc279a1f7c2b420c15b0f9bc73fb15c37c8f2ec08c45e1acd3aae46b75a", "boatstack/references/irreversible-operation-boundary.md": "2a695f2d7de95cfc8750f107bef9c86581712aa1f02e7233b69b850d8c2af42e", "boatstack/references/portability.md": "fb683095991bb0cb06ec56fb8884c49038b283172a7d2f8b203483b7cacb4bae", - "boatstack/references/workflow.md": "113bf1f207eaa019223462b85faac380d7c43c3217494845e8552f7707f5e645", + "boatstack/references/workflow.md": "0a32f00c12ea1d92db2e3b29ce5cfdcdd5a013c50ae67f2fc56f0ebde6951988", "boatstack/runtime.go": "b988d57ec14e15fc6a57949a995879fc0e0d6bfa9a7b62935e7754df0b85d87a", "boatstack/safety.go": "fbf30c34642db6ac18e0e15abbf78cbcd9177cc7aa678b44b4eaabc0202f5bd5", "boatstack/safety_test.go": "62375fd640d543ab8875c7b31fd935ac7f5385830f625123f44508e629b4ff08", "boatstack/testdata/reviewer-pr-body.md": "4c64e3788e5d61a377aeb0f797f7fc8d2316ab6e49572d15636eea7ba9e34ac4", "boatstack/testdata/safety/safe_apply.py.txt": "c9ec7fb932cf21b6aa8df597c4d4c54d6ec65e796240e49118d699f583383975", "boatstack/testdata/safety/unsafe_apply.py.txt": "42db1751865cc15c4dd69a03146b5deca8f21f916d258e433b27bbef5f884ab1", + "boatstack/update.go": "b801318dce2268f9c02fabc71ab783a36aff9b9b110457204d3231bc18382370", + "boatstack/update_test.go": "aa0c2ca97038aad661c46321600034e206e88639f89e0216b3c4cf597312cbae", "docs/account-recovery-walkthrough.md": "acd3558a95f48004f18a0590670de496e1cc9f0cd1d187f924615497f57e1d6f", "docs/benchmark-corpus-audit.md": "f2d206fe8579a514f9da82b2c96c19b343ac004be67617e1bd34f0f8e0e5e6c6", "docs/benchmark-submission-audit.md": "9518abdd17690729c6423f87cab20418ed47b0915b5faa44b9ef975e9e9c3b79", - "docs/evidence-engineered-coding.md": "f14acdbaf6da6a11ad8ce73e5befde8af5f879571452b679d18a71a2c6f18191", - "docs/generated-files.md": "7f4d7cddff80d794361c26962b4c89b4e525eafb53776553be966258c6d867c6", - "docs/getting-started.md": "dccb5895bfbf6ac40309c6779f2a23dcdc27355e62a8a4858bb37c043e8de442", - "docs/public-claims.json": "73e41adf3c8ae7b6946145f65d80950842b16cef2350edca3d0936b9b6b21f93", + "docs/evidence-engineered-coding.md": "db352248a0efe1377741c85efdfde05e98fc35ea6c298475781e85eba0122bff", + "docs/generated-files.md": "040149341dceb192ba240edc250f8d3e4124b9b9444d0628df42dab667192db6", + "docs/getting-started.md": "432b64d3de11ffe56204f6eb12712c714eca31620d51afead482be70c76a2f35", + "docs/public-claims.json": "61180ebde073ff37becb34c7f24067a193d19502daaa84927e9b6107a9c22379", "docs/public-surface.md": "53d741f04b2928a6ee8c006d647a6d675a215e863412e5862cd67d48433bff76", "docs/research-and-design.md": "d65c66e323037bda5d45aacef5d48afa6bf93da55901378891d235aca3a5684f", "docs/safety.md": "7b9b5c515d36e683767ec8d3d9d6d119ac93650b2f629d351deadd4c600ed6a6", - "docs/troubleshooting.md": "67ec380fc24226d6afd1d4fe250a123edd153f88bc1e318ee2ef7635e0ae1520", + "docs/troubleshooting.md": "a9f28e156702a970792421766d38cab3fb99a6a9ce497bd058ee4026608798ff", "docs/validation-and-evidence.md": "a9fe9274f3dc22b152094a307feda5d8c3ab099755100aef77bda13024cc3166", - "docs/why-these-steps.md": "c45092ed7b4c8a913a83b4fcfa22494c76d864fb32ff5943febd6abe1f7de81f", + "docs/why-these-steps.md": "dc633f3edcc0c9d94ee7ea3feb57220987ad63ee8fb08b3df6e438dec866cacd", "examples/diagram-json/README.md": "061b583180e43bbd26618bbd9d3d79af4b75d7c8f37c66475640745a97328fbc", "examples/diagram-json/approval.md": "bc421a825349923512d5cb0ce489310d3a4d7cbac35e661a693b4a32eec263d1", "examples/diagram-json/compiled/evidence.md": "1ba1c989ade070a8ef9a508fbd788d100d7292f2dbacbb2bce895468019f619d", "examples/diagram-json/compiled/tasks.json": "f040696f1f8bcedc4a8ed9816a61a49edbda970ec0cc3b28175ba37b73bbc896", "examples/diagram-json/compiled/test-matrix.json": "6c6895c509271e4337f3c91d9f62ee3a2b34e768e78513784cb012506a328ecf", - "examples/diagram-json/plan.lock.json": "b10ea959364285f9ed551a31283f7690c19a921c25d291afb4fa354ae5c7f0aa", + "examples/diagram-json/plan.lock.json": "abceab49b917f84aabd17a4ae2bd8e31fc12b2ca4ed97e55de992b5e49232f3d", "examples/diagram-json/plan.md": "3ad35cc3cbe48306e7ee401bd9e9047d25e46c8a6fe9679aa1b3f5e96ceea292", "examples/diagram-json/questions.md": "1a0050041cac0a8d53e6ebfe04cbec4a298cdc8c50efeeb6fa15aeb663c5ec76", "examples/diagram-json/request.md": "0808fc41c36779c404f4a3a121167da6e76cac56df526e70f9ed6d3e0d4c02ed", "examples/diagram-json/source-plan.md": "e10593ddaa7522ab80cc991d0a09399257139799e37f737794cd49d68a39985b", "examples/diagram-json/spec.md": "a943c81cf2a88d23d5b300e6b9dc1dafc80923a9b6b9ab5297a67b4e2054b9d5", - "install.ps1": "c81f2f8eb6032ea82c36ebe98bd015ba4dabb8f4825b3c1bb89baea2a808690c", - "install.sh": "c9caf1eb0554715d189e4183478dfb3ae7e5c0a8187ea44229cbf268276652f8", + "install.ps1": "960b2b20b406bb2878a560e9ace53fe7226bc510be6ee8466ce4e608beb5625a", + "install.sh": "939e604aa153b454e7fa1cbbe50a88be17782ea9df35d1bbb5615c737ed6f86e", "project.example.json": "d1f7aa3cff0b55ede79500bd2ca710bb99cb2ae579f0a058dc00934accf03d33" }, "generator": "operatorstack/intelligence-flow:boatstack-distribution", "schema_version": 1, "source": { - "commit": "d37975f8c3960b03c701f029d4815f6d6814ed7c", + "commit": "4bc614dd6355dcc408e2a49955cb53b868d5dee8", "path": "examples/12-product-engineering-loop", "repository": "operatorstack/intelligence-flow" } diff --git a/boatstack/SKILL.md b/boatstack/SKILL.md index 229e196..f850f60 100644 --- a/boatstack/SKILL.md +++ b/boatstack/SKILL.md @@ -1,6 +1,6 @@ --- name: boatstack -description: Turn a product request into a question-led, specification-first implementation with test, review, and ship gates, then learn from the evidence without silently changing project rules. Use when planning or building a feature, creating an implementation PR, reviewing work against product intent, diagnosing repeated coding-agent failures, or exporting the same engineering loop to Cursor, Claude Code, Codex, and GitHub. +description: Turn a product request into a question-led, specification-first implementation with test, review, and ship gates, then learn from the evidence without silently changing project rules. Use when planning or building a feature, creating an implementation PR, reviewing work against product intent, diagnosing repeated coding-agent failures, updating Boatstack itself, or exporting the same engineering loop to Cursor, Claude Code, Codex, and GitHub. --- # Boatstack @@ -18,6 +18,7 @@ Map the request to one operation: - `test-gate`: test requirements and relevant regressions using independent evidence. - `review-gate`: review the diff against the spec, project invariants, risks, and known gaps. - `ship-gate`: preview, then explicitly open or update, a reviewer-ready PR grounded in the approved diff and evidence. +- `boatstack-update`: check for a stable Boatstack release and prepare its infrastructure-only update branch and PR after explicit confirmation. - `retro`: classify failures, propose a harness move, and gate it before promotion. - `export`: generate thin Cursor, Claude Code, Codex, and GitHub adapters. @@ -156,11 +157,25 @@ Do not branch the workflow on model brand, price, or a guessed capability tier. - Show the exact title and rendered body before any GitHub mutation. If no PR exists, make `Reply open PR` the one next action; if one exists, use `Reply update PR`. - After that exact confirmation, commit only the reviewed `pr.md`, rerun the preview check, require the same preview fingerprint, then invoke the internal publisher with the selected open/update action. It rechecks the current committed diff, approval, lock, and evidence and performs only a normal push. Any intervening change invalidates the preview and requires regeneration; never force-push. - Keep model attribution inside collapsed provenance. Create or update the PR, but keep merge and deploy as separate authorized actions. +- Only after successful PR publication, perform the bounded cached release check. If a newer stable Boatstack release should be announced, keep `Review the PR` as the one next action and put the no-mutation update notice in collapsed details. Release lookup failure never changes the ship result. - Never hide failed experiments, skipped checks, or `PASS_WITH_GAPS` behind a green summary. - If a required check also fails on the base branch, record that comparison and recommend a separate repair PR. Do not edit unrelated code in the approved feature branch. A bypass is valid only when repository policy permits it and the human explicitly authorizes it; otherwise return to planning for any scope expansion. Gate statuses are `PASS`, `PASS_WITH_GAPS`, and `BLOCKED`. Critical safety, correctness, or product-acceptance gaps always produce `BLOCKED`. +## Update Boatstack separately + +Treat `boatstack-update` as infrastructure maintenance, never as feature work: + +1. Run the current local helper's `doctor`, then force the cached stable-release check. If Boatstack is current, return **Boatstack is current** with no action required. +2. Fetch the configured default branch without editing product files. Require that branch to be current and clean; otherwise return **Update postponed** and change nothing. +3. Create only `chore/update-boatstack-v`. Run the installer fetched from the exact release tag in update mode with the exact version, repository path, and non-interactive preview acceptance. +4. Preserve `.boatstack-project.json`, all portable adapters, optional integration selections, and unrelated host settings. Block on generated drift, collisions, missing provenance, a failed checksum, a failed `doctor`, or any product-file change. +5. Show the version transition, release notes, integration state, changed infrastructure paths, exact diff, checksums, rollout, and rollback. Respond **Boatstack update ready** with exactly `Reply open update PR` as the next action. +6. Only that exact reply authorizes staging the reviewed infrastructure paths, committing, normally pushing, and opening the update PR. Never merge it. If GitHub publication is unavailable, retain the prepared branch and provide one manual action. + +Natural requests such as “Update Boatstack” use this operation. `doctor` may display a cached notice but must remain offline. Do not perform release discovery during planning, approval, build, test, review, or PR preview. + ## Improve an existing PR without a public command When the user naturally asks Boatstack to prepare, improve, summarize, or update a PR and no managed feature package is available: diff --git a/boatstack/cmd/boatstack-helper/main.go b/boatstack/cmd/boatstack-helper/main.go index 57ebd0d..0a9a151 100644 --- a/boatstack/cmd/boatstack-helper/main.go +++ b/boatstack/cmd/boatstack-helper/main.go @@ -32,6 +32,37 @@ func initCommand(arguments []string) int { return 0 } +func updateCommand(arguments []string) int { + flags := flag.NewFlagSet("update", flag.ContinueOnError) + repo := flags.String("repo", ".", "repository to update") + binary := flags.String("binary", "", "verified replacement helper binary") + yes := flags.Bool("yes", false, "accept the generated-file preview") + if err := flags.Parse(arguments); err != nil { + return 2 + } + err := boatstack.RunUpdate(boatstack.InitOptions{Repo: *repo, BinaryPath: *binary, Yes: *yes}) + if err != nil { + return fail(err) + } + return 0 +} + +func checkUpdateCommand(arguments []string) int { + flags := flag.NewFlagSet("check-update", flag.ContinueOnError) + repo := flags.String("repo", ".", "repository whose Boatstack release should be checked") + force := flags.Bool("force", false, "ignore the 24-hour release cache") + notify := flags.Bool("notify", false, "record a bounded post-ship notification") + if err := flags.Parse(arguments); err != nil { + return 2 + } + result, err := boatstack.CheckForUpdate(boatstack.UpdateCheckOptions{Repo: *repo, Force: *force, Notify: *notify}) + if err != nil { + return fail(err) + } + fmt.Printf("UPDATE_STATUS=%s\nCURRENT_VERSION=%s\nLATEST_VERSION=%s\nRELEASE_NAME=%q\nRELEASE_NOTES=%q\nRELEASE_URL=%s\nUPDATE_NOTIFY=%t\nUPDATE_FROM_CACHE=%t\n", result.Status, result.CurrentVersion, result.LatestVersion, result.ReleaseName, result.ReleaseNotes, result.ReleaseURL, result.ShouldNotify, result.FromCache) + return 0 +} + func exportCommand(arguments []string) int { flags := flag.NewFlagSet("export", flag.ContinueOnError) repo := flags.String("repo", "", "repository to export into") @@ -205,6 +236,9 @@ func doctorCommand(arguments []string) int { return fail(err) } fmt.Printf("PASS: Boatstack %s installation and generated adapters are healthy\n", boatstack.Version) + if update, ok := boatstack.CachedUpdate(*repo); ok { + fmt.Printf("UPDATE_AVAILABLE=%s\nRELEASE_URL=%s\n", update.LatestVersion, update.ReleaseURL) + } return 0 } @@ -324,17 +358,24 @@ func publishPRCommand(arguments []string) int { verb = "updated" } fmt.Printf("PASS: PR %s without merge authorization\nPR_URL=%s\n", verb, url) + if update, ok := boatstack.PostShipUpdateNotice(*repo); ok { + fmt.Printf("UPDATE_AVAILABLE=%s\nUPDATE_RELEASE_URL=%s\n", update.LatestVersion, update.ReleaseURL) + } return 0 } 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] { case "init": return initCommand(os.Args[2:]) + case "update": + return updateCommand(os.Args[2:]) + case "check-update": + return checkUpdateCommand(os.Args[2:]) case "export": return exportCommand(os.Args[2:]) case "check-source-plan": diff --git a/boatstack/export.go b/boatstack/export.go index 3dd2019..634333f 100644 --- a/boatstack/export.go +++ b/boatstack/export.go @@ -161,15 +161,16 @@ func BuildExportBundle(configPath string, config ProjectConfig, rawConfig []byte } operations := map[string]string{ - "auto-plan": "Discover exactly one saved Plan-mode file and refine it into a Markdown-only draft feature package whose canonical structured artifact is plan.md. Run check-plan read-only. Record affected_paths and structured side_effects for external writes; use an immutable target identity, transactional or fix-forward recovery, and destructive=false. Do not implement, create JSON or locks, or imply acceptance. If ready, respond with Plan ready and make Run /plan-gate the one next action. If decisions remain, respond with I need your input and ask only 1-3 material questions.", - "plan-gate": "Run check-plan read-only, present its fingerprint and all open decisions, and require explicit human approval. The normal user action is simply approve. Resolve approved_by from an explicit supplied identity, otherwise from the authenticated GitHub login when available; ask one short identity follow-up only when neither exists, and never infer it from a filesystem username, commit history, or agent identity. On approval invoke record-approval with the resolved human, RFC3339 timestamp, and exact displayed fingerprint so it writes only approval.md. While pending, respond Ready for your approval with Reply approve as the one next action. After recording, respond Approved — ready to build and make entering the host execution mode and running /build the one next action. Remain in Plan mode; do not compile or request an early mode switch.", - "build": "First confirm the host is in an execution-capable mode. If the mode transition is rejected or product-code writes remain unavailable, return READY_FOR_BUILD internally without activating the plan, compiling JSON, or writing a lock. Only then locate plan.md and approval.md and run activate-plan before the first product-code edit. Stop if it reports BLOCKED. Run the internal repository safety check after operational or high-risk edits; a destructive capability blocks execution and gate progression but does not block reviewable source editing. Implementation tactics remain open inside the approved boundary. On success respond Build complete and make Run /test-gate the one next action. When a new product decision blocks work, respond Build needs a decision and ask only that question.", - "test-gate": "Run the internal repository safety check, build a requirement-to-evidence matrix, and treat self-authored tests as evidence rather than the sole oracle. External writes require immutable target identity, transactional or fix-forward failure behavior, and an independent safety oracle. On pass respond Tests passed and make Run /review-gate the one next action. On failure respond Testing found a problem and make the required non-destructive repair the one next action.", - "review-gate": "Run the internal repository safety check and review the actual diff against approved intent, invariants, risks, gaps, and test evidence. Executable destructive capability is blocking even when ordinary tests pass. On pass respond Review passed and make Run /ship-gate the one next action. When blocked respond Changes required and make the highest-priority blocking repair the one next action.", - "ship-gate": "Prepare a reviewer-ready PR only; do not merge or deploy without separate authorization. Require the current managed feature approval, lock, test evidence, review evidence, and a passing repository safety scan, and commit the intentional product/artifact diff before projection. Internally run pr-context --repo . --feature in json and template formats, project the approved intent, actual committed diff, decisions, evidence, gaps, rollout, rollback, safety outcome, and operator-only recovery boundary into its required pr.md path, then run check-pr --repo . --preview . Always include why, what changed, review order, evidence, gaps/risks, rollout/rollback, and collapsed provenance; add UI evidence, security/privacy, migration, or operations sections only when the diff makes them relevant. Show the exact title and rendered body before any GitHub mutation. If PR_ACTION is open, respond PR ready with Reply open PR as the one next action; if update, use Reply update PR; if manual, preserve the preview and give one manual publication action. Only after that exact reply: commit only the reviewed pr.md, rerun check-pr and require the same preview fingerprint (PREVIEW_FINGERPRINT), then run publish-pr with --action open or update and that fingerprint. The publisher performs a non-force push and rechecks context before GitHub mutation. If the diff or evidence changes, regenerate instead. If a required check fails on the base branch too, record the evidence and recommend a separate repair PR. Never edit unrelated code in this approved feature branch; a policy-approved bypass requires explicit human authorization. After publication respond PR opened with the link and make Review the PR the one next action; never imply merge authorization.", - "review": "Alias of review-gate: review the actual diff against approved intent, invariants, risks, gaps, and test evidence. Use Review passed or Changes required and the same single-action routing as review-gate.", - "ship": "Alias of ship-gate: prepare and preview the exact reviewer-ready title and body before any GitHub mutation. Require Reply open PR or Reply update PR before publication, recheck the preview against current evidence, and never merge or deploy. Keep pre-existing unrelated failures out of the approved feature branch. Use PR ready before confirmation or PR opened after publication.", - "retro": "Classify evidence and propose a move; never promote it or change durable rules without a paired gate. Respond Improvement proposed and make reviewing or authorizing the experiment the one next action.", + "auto-plan": "Discover exactly one saved Plan-mode file and refine it into a Markdown-only draft feature package whose canonical structured artifact is plan.md. Run check-plan read-only. Record affected_paths and structured side_effects for external writes; use an immutable target identity, transactional or fix-forward recovery, and destructive=false. Do not implement, create JSON or locks, or imply acceptance. If ready, respond with Plan ready and make Run /plan-gate the one next action. If decisions remain, respond with I need your input and ask only 1-3 material questions.", + "plan-gate": "Run check-plan read-only, present its fingerprint and all open decisions, and require explicit human approval. The normal user action is simply approve. Resolve approved_by from an explicit supplied identity, otherwise from the authenticated GitHub login when available; ask one short identity follow-up only when neither exists, and never infer it from a filesystem username, commit history, or agent identity. On approval invoke record-approval with the resolved human, RFC3339 timestamp, and exact displayed fingerprint so it writes only approval.md. While pending, respond Ready for your approval with Reply approve as the one next action. After recording, respond Approved — ready to build and make entering the host execution mode and running /build the one next action. Remain in Plan mode; do not compile or request an early mode switch.", + "build": "First confirm the host is in an execution-capable mode. If the mode transition is rejected or product-code writes remain unavailable, return READY_FOR_BUILD internally without activating the plan, compiling JSON, or writing a lock. Only then locate plan.md and approval.md and run activate-plan before the first product-code edit. Stop if it reports BLOCKED. Run the internal repository safety check after operational or high-risk edits; a destructive capability blocks execution and gate progression but does not block reviewable source editing. Implementation tactics remain open inside the approved boundary. On success respond Build complete and make Run /test-gate the one next action. When a new product decision blocks work, respond Build needs a decision and ask only that question.", + "test-gate": "Run the internal repository safety check, build a requirement-to-evidence matrix, and treat self-authored tests as evidence rather than the sole oracle. External writes require immutable target identity, transactional or fix-forward failure behavior, and an independent safety oracle. On pass respond Tests passed and make Run /review-gate the one next action. On failure respond Testing found a problem and make the required non-destructive repair the one next action.", + "review-gate": "Run the internal repository safety check and review the actual diff against approved intent, invariants, risks, gaps, and test evidence. Executable destructive capability is blocking even when ordinary tests pass. On pass respond Review passed and make Run /ship-gate the one next action. When blocked respond Changes required and make the highest-priority blocking repair the one next action.", + "ship-gate": "Prepare a reviewer-ready PR only; do not merge or deploy without separate authorization. Require the current managed feature approval, lock, test evidence, review evidence, and a passing repository safety scan, and commit the intentional product/artifact diff before projection. Internally run pr-context --repo . --feature in json and template formats, project the approved intent, actual committed diff, decisions, evidence, gaps, rollout, rollback, safety outcome, and operator-only recovery boundary into its required pr.md path, then run check-pr --repo . --preview . Always include why, what changed, review order, evidence, gaps/risks, rollout/rollback, and collapsed provenance; add UI evidence, security/privacy, migration, or operations sections only when the diff makes them relevant. Show the exact title and rendered body before any GitHub mutation. If PR_ACTION is open, respond PR ready with Reply open PR as the one next action; if update, use Reply update PR; if manual, preserve the preview and give one manual publication action. Only after that exact reply: commit only the reviewed pr.md, rerun check-pr and require the same preview fingerprint (PREVIEW_FINGERPRINT), then run publish-pr with --action open or update and that fingerprint. The publisher performs a non-force push and rechecks context before GitHub mutation. If the diff or evidence changes, regenerate instead. If a required check fails on the base branch too, record the evidence and recommend a separate repair PR. Never edit unrelated code in this approved feature branch; a policy-approved bypass requires explicit human authorization. After publication respond PR opened with the link and make Review the PR the one next action; never imply merge authorization. If publish-pr returns UPDATE_AVAILABLE, keep Review the PR as the only next action and append a collapsed update notice saying no files changed and /boatstack-update may be run from the clean default branch after this feature PR merges. Do not check for releases before successful publication.", + "boatstack-update": "Prepare a visible Boatstack infrastructure update; never mix it into product work or merge it. First run the current helper doctor and force check-update. If current, respond Boatstack is current with No action required. Before mutation fetch the default ref, then require the current clean default branch whose HEAD equals origin/; otherwise respond Update postponed and make finishing the current feature, switching to the clean default branch, and rerunning /boatstack-update the one action. Ensure no update PR or branch already exists, create chore/update-boatstack-v, then run the installer fetched from that exact release tag with BOATSTACK_MODE=update, BOATSTACK_VERSION=, BOATSTACK_REPO=, and BOATSTACK_YES=1. Use install.sh on macOS/Linux and install.ps1 on Windows. The verified update must preserve configuration, adapters, integrations, and user-owned host settings, run doctor, and touch only Boatstack infrastructure. Show the version transition, release notes and link, integration state, exact diff, changed paths, checksums, rollout, and rollback. Respond Boatstack update ready and make Reply open update PR the one next action. Only that exact reply authorizes staging the installer-reported paths, committing chore: update Boatstack to , normal push, and opening a reviewer-ready update PR. If GitHub auth is unavailable, preserve the branch and give one manual publication action. After publication respond Update PR opened with the link and make Review the PR the one next action. On one collision or health failure, respond Update needs attention and make addressing that named problem the one next action. Never merge automatically.", + "review": "Alias of review-gate: review the actual diff against approved intent, invariants, risks, gaps, and test evidence. Use Review passed or Changes required and the same single-action routing as review-gate.", + "ship": "Alias of ship-gate: prepare and preview the exact reviewer-ready title and body before any GitHub mutation. Require Reply open PR or Reply update PR before publication, recheck the preview against current evidence, and never merge or deploy. Keep pre-existing unrelated failures out of the approved feature branch. Use PR ready before confirmation or PR opened after publication.", + "retro": "Classify evidence and propose a move; never promote it or change durable rules without a paired gate. Respond Improvement proposed and make reviewing or authorizing the experiment the one next action.", } if contains(adapters, "cursor") { @@ -185,6 +186,7 @@ Ordinary product intent starts in the host's Plan mode. Save the completed plan Do not start build work until the explicit plan gate has produced approval.md and build activation has produced a valid plan lock. Implementation methods are open. Claims of completion, approval, review, and shipping require evidence. When the user naturally asks Boatstack to prepare, improve, summarize, or update an existing PR without a managed feature package, generate an evidence-limited ad-hoc PR brief. Use the committed branch diff and observed checks, label missing evidence NOT_VERIFIED, and never imply Boatstack approval or passed gates. This is natural-language behavior, not a /pr-brief command. Preview the exact title and body before asking for one open/update confirmation. +When the user asks to update Boatstack itself, use /boatstack-update. Release discovery is read-only and cached; repository mutation begins only from a clean current default branch and is isolated in a versioned chore/update-boatstack branch. Preview the exact infrastructure diff before requiring open update PR. Never mix a Boatstack update into product work or merge it automatically. Do not branch behavior on model name, provider, or price; branch on observed work state and evidence. Boatstack's repository hooks deny high-confidence irreversible operations across every agent call. There is no in-session bypass. Preserve failed external state, use read-only diagnosis and fix-forward recovery, and leave intentional destructive recovery to an operator-owned surface outside Boatstack. ` @@ -204,7 +206,7 @@ description: Run Boatstack's evidence-engineered coding node for question-led pl # Boatstack adapter -Read .product-loop/project.json and .product-loop/workflow.md. The requested operation is supplied by the user; valid operations are auto-plan, plan-gate, build, test-gate, review-gate/review, ship-gate/ship, and retro. +Read .product-loop/project.json and .product-loop/workflow.md. The requested operation is supplied by the user; valid operations are auto-plan, plan-gate, build, test-gate, review-gate/review, ship-gate/ship, boatstack-update, and retro. Follow the User-facing response contract in .product-loop/workflow.md for every operation. Lead with the mapped plain-language outcome, show only decision-relevant content, end with exactly one Next step, and move machine statuses, helper output, fingerprints, artifact paths, receipts, and locks into collapsed Technical details. Internal helper names must not appear in the primary response. @@ -218,6 +220,8 @@ Repository hooks enforce Boatstack's immutable deny policy across every agent ca At ship, prove whether a failing check is pre-existing by checking the base branch. Keep unrelated repairs in a separate PR; do not modify unrelated code under the approved feature lock. A repository-policy bypass requires explicit human authorization and recorded evidence. +When the user asks to update Boatstack, run the boatstack-update operation. Never prepare it on a feature branch or dirty worktree. A successful update is a separate versioned infrastructure branch whose exact diff is shown before requiring the explicit open update PR publication reply. Preserve current adapters, integrations, and project configuration; never merge the update automatically. After successful feature PR publication, surface UPDATE_AVAILABLE only as a collapsed informational notice while Review the PR remains the sole next action. + For a managed ship, use the internal pr-context operation with --feature to project the feature spec, accepted decisions, actual committed diff, evidence ledger, review findings, gaps, rollout, and rollback into the required pr.md artifact. Inspect the returned changed files, diff stat, high-risk matches, and the actual diff before writing claims; commits alone are not authoritative. Always include why, what changed, review order, evidence, gaps/risks, rollout/rollback, and collapsed provenance. Add UI evidence, security/privacy, migration, or operations sections only when relevant. For a natural-language request to improve an existing or ad-hoc PR, run pr-context without --feature and use the same reviewer-first format from observed branch facts, but mark unavailable approval or gate evidence as NOT_VERIFIED. Never create or advertise a /pr-brief command. Validate with check-pr and always show the exact title and rendered body before publication. Ask for exactly Reply open PR or Reply update PR. Only after that reply, commit only pr.md, revalidate the unchanged preview fingerprint, and invoke the internal publish-pr operation with the selected action. It may perform a normal push but never force-push. Any intervening product diff or evidence change invalidates the preview. Keep model attribution inside collapsed provenance. Internal helper names and hashes stay out of the primary response. If gstack is enabled, use only its namespaced /gstack-* specialist lenses inside Boatstack operations. If Spec Kit is enabled, use it to generate or cross-check artifacts; never invoke speckit.implement to bypass Boatstack's plan approval and build gate. diff --git a/boatstack/export_test.go b/boatstack/export_test.go index 64a6a5a..211636c 100644 --- a/boatstack/export_test.go +++ b/boatstack/export_test.go @@ -46,6 +46,7 @@ func TestExportAndDriftCheck(t *testing.T) { t.Fatal(err) } for _, path := range []string{ + ".cursor/commands/boatstack-update.md", ".cursor/commands/plan-gate.md", ".cursor/commands/review.md", ".claude/skills/boatstack/SKILL.md", @@ -71,15 +72,16 @@ func TestExportAndDriftCheck(t *testing.T) { planGate := string(bundle.Files[".cursor/commands/plan-gate.md"]) build := string(bundle.Files[".cursor/commands/build.md"]) responseOutcomes := map[string][]string{ - "auto-plan": {"Plan ready", "I need your input"}, - "plan-gate": {"Ready for your approval", "Approved — ready to build"}, - "build": {"Build complete", "Build needs a decision"}, - "test-gate": {"Tests passed", "Testing found a problem"}, - "review-gate": {"Review passed", "Changes required"}, - "review": {"Review passed", "Changes required"}, - "ship-gate": {"PR ready", "PR opened"}, - "ship": {"PR ready", "PR opened"}, - "retro": {"Improvement proposed"}, + "auto-plan": {"Plan ready", "I need your input"}, + "plan-gate": {"Ready for your approval", "Approved — ready to build"}, + "build": {"Build complete", "Build needs a decision"}, + "test-gate": {"Tests passed", "Testing found a problem"}, + "review-gate": {"Review passed", "Changes required"}, + "review": {"Review passed", "Changes required"}, + "ship-gate": {"PR ready", "PR opened"}, + "ship": {"PR ready", "PR opened"}, + "retro": {"Improvement proposed"}, + "boatstack-update": {"Boatstack is current", "Update postponed", "Boatstack update ready", "Update PR opened", "Update needs attention"}, } for operation, outcomes := range responseOutcomes { command := string(bundle.Files[".cursor/commands/"+operation+".md"]) @@ -117,6 +119,25 @@ func TestExportAndDriftCheck(t *testing.T) { if _, exists := bundle.Files[".cursor/commands/pr-brief.md"]; exists { t.Fatal("PR brief must remain natural-language behavior, not a public command") } + update := string(bundle.Files[".cursor/commands/boatstack-update.md"]) + for _, expected := range []string{"check-update", "chore/update-boatstack-v", "BOATSTACK_MODE=update", "Reply open update PR", "Never merge"} { + if !strings.Contains(update, expected) { + t.Fatalf("update adapter is missing %q", expected) + } + } + for _, operation := range []string{"auto-plan", "plan-gate", "build", "test-gate", "review-gate", "review", "retro"} { + if strings.Contains(string(bundle.Files[".cursor/commands/"+operation+".md"]), "check-update") { + t.Fatalf("%s must not check for Boatstack releases", operation) + } + } + if strings.Contains(ship, "check-update") { + t.Fatal("ship preview must not initiate a release check") + } + for _, expected := range []string{"UPDATE_AVAILABLE", "collapsed update notice", "Review the PR"} { + if !strings.Contains(ship, expected) { + t.Fatalf("ship adapter is missing post-publication update behavior %q", expected) + } + } cursorRule := string(bundle.Files[".cursor/rules/boatstack.mdc"]) for _, expected := range []string{"naturally asks Boatstack", "evidence-limited ad-hoc PR brief", "not a /pr-brief command", "NOT_VERIFIED"} { if !strings.Contains(cursorRule, expected) { @@ -149,7 +170,7 @@ func TestExportAndDriftCheck(t *testing.T) { } for _, path := range []string{".agents/skills/boatstack/SKILL.md", ".claude/skills/boatstack/SKILL.md"} { adapter := string(bundle.Files[path]) - for _, expected := range []string{"User-facing response contract", "exactly one Next step", "Normal approval is simply approve", "filesystem username", "Never create or advertise a /pr-brief command", "Reply open PR", "Reply update PR"} { + for _, expected := range []string{"User-facing response contract", "exactly one Next step", "Normal approval is simply approve", "filesystem username", "Never create or advertise a /pr-brief command", "Reply open PR", "Reply update PR", "boatstack-update", "open update PR"} { if !strings.Contains(adapter, expected) { t.Fatalf("%s is missing response-DX rule %q", path, expected) } diff --git a/boatstack/init.go b/boatstack/init.go index 7767dc9..3472235 100644 --- a/boatstack/init.go +++ b/boatstack/init.go @@ -17,6 +17,7 @@ type InitOptions struct { BinaryPath string IntegrationChoice string Yes bool + Update bool Input io.Reader Output io.Writer } @@ -218,6 +219,66 @@ func writeInstallLock(repo, binaryPath, binaryHash string, integrations map[stri return writeFile(filepath.Join(repo, ".product-loop", "bin", "install.lock.json"), value, 0o644) } +func readInstalledIntegrations(repo string, config ProjectConfig) (map[string]IntegrationState, error) { + value, err := os.ReadFile(filepath.Join(repo, ".product-loop", "bin", "install.lock.json")) + if err != nil { + return nil, fmt.Errorf("missing previous local install lock: %w", err) + } + var lock struct { + Integrations map[string]IntegrationState `json:"integrations"` + } + if err := json.Unmarshal(value, &lock); err != nil { + return nil, fmt.Errorf("invalid previous local install lock: %w", err) + } + if len(lock.Integrations) > 0 { + return lock.Integrations, nil + } + states := map[string]IntegrationState{} + for name, configured := range config.Integrations { + configured.Status = "preserved" + configured.Detail = "selection preserved during Boatstack core update" + states[name] = configured + } + return states, nil +} + +func updateChangedPaths(repo string) []string { + seen := map[string]bool{} + for _, arguments := range [][]string{{"diff", "--name-only"}, {"ls-files", "--others", "--exclude-standard"}} { + for _, path := range strings.Split(gitOutput(repo, arguments...), "\n") { + path = strings.TrimSpace(path) + if path != "" { + seen[filepath.ToSlash(path)] = true + } + } + } + return sortedKeys(seen) +} + +func checkUpdateDiffScope(repo string, currentFiles map[string][]byte, previous map[string]string, hookPaths []string) ([]string, error) { + allowed := map[string]bool{".boatstack-project.json": true} + for path := range currentFiles { + allowed[filepath.ToSlash(path)] = true + } + for path := range previous { + allowed[filepath.ToSlash(path)] = true + } + for _, path := range hookPaths { + allowed[filepath.ToSlash(path)] = true + } + changed := updateChangedPaths(repo) + unexpected := []string{} + for _, path := range changed { + if !allowed[path] { + unexpected = append(unexpected, path) + } + } + if len(unexpected) > 0 { + return changed, fmt.Errorf("update touched non-Boatstack paths: %s", strings.Join(unexpected, ", ")) + } + return changed, nil +} + func RunInit(options InitOptions) error { if options.Input == nil { options.Input = os.Stdin @@ -231,9 +292,13 @@ func RunInit(options InitOptions) error { } reader := bufio.NewReader(options.Input) configPath := filepath.Join(repo, ".boatstack-project.json") + configExists := fileExists(configPath) + if options.Update && !configExists { + return fmt.Errorf("Boatstack update requires an existing .boatstack-project.json") + } var config ProjectConfig var rawConfig []byte - if fileExists(configPath) { + if configExists { config, rawConfig, err = LoadConfig(configPath) if err != nil { return fmt.Errorf("existing Boatstack config is invalid: %w", err) @@ -251,6 +316,18 @@ func RunInit(options InitOptions) error { } config = defaultConfig(repo, testCommand) } + if options.Update { + if err := ValidateUpdateWorkspace(repo, config); err != nil { + return err + } + } + var preservedStates map[string]IntegrationState + if options.Update { + preservedStates, err = readInstalledIntegrations(repo, config) + if err != nil { + return err + } + } detected := DetectHosts(repo) if len(detected) == 0 { @@ -260,7 +337,10 @@ func RunInit(options InitOptions) error { } choice := options.IntegrationChoice - if choice == "" { + if options.Update && choice != "" { + return fmt.Errorf("Boatstack update preserves existing integrations; change integrations separately") + } + if !options.Update && choice == "" { if options.Yes { choice = "core" } else { @@ -278,18 +358,21 @@ func RunInit(options InitOptions) error { } } } - wantGStack, wantSpecKit, err := RequestedIntegrations(choice) - if err != nil { - return err - } - config.Integrations = map[string]IntegrationState{ - "gstack": {Requested: wantGStack, Version: GStackRef}, - "spec-kit": {Requested: wantSpecKit, Version: SpecKitVersion}, - } - rawConfig, err = MarshalJSON(config) - if err != nil { - return err + if !options.Update { + wantGStack, wantSpecKit, choiceErr := RequestedIntegrations(choice) + if choiceErr != nil { + return choiceErr + } + config.Integrations = map[string]IntegrationState{ + "gstack": {Requested: wantGStack, Version: GStackRef}, + "spec-kit": {Requested: wantSpecKit, Version: SpecKitVersion}, + } + rawConfig, err = MarshalJSON(config) + if err != nil { + return err + } } + previousGenerated := previousFiles(repo) bundle, err := BuildExportBundle(configPath, config, rawConfig, "boatstack") if err != nil { return err @@ -305,7 +388,7 @@ func RunInit(options InitOptions) error { for _, path := range HostHookPaths(config.Adapters) { fmt.Fprintln(options.Output, " "+path+" (merge Boatstack safety hook; preserve existing settings)") } - if !fileExists(configPath) { + if !configExists { fmt.Fprintln(options.Output, " .boatstack-project.json (editable repository facts)") } if !options.Yes { @@ -330,7 +413,12 @@ func RunInit(options InitOptions) error { if err != nil { return err } - states, err := InstallIntegrations(choice, repo, config.Adapters) + var states map[string]IntegrationState + if options.Update { + states = preservedStates + } else { + states, err = InstallIntegrations(choice, repo, config.Adapters) + } if err != nil { return err } @@ -346,7 +434,20 @@ func RunInit(options InitOptions) error { if err := Doctor(repo); err != nil { return fmt.Errorf("post-install smoke check failed: %w", err) } - fmt.Fprintln(options.Output, "\nPASS: Boatstack core installed without a language runtime.") + if options.Update { + changed, scopeErr := checkUpdateDiffScope(repo, bundle.Files, previousGenerated, HostHookPaths(config.Adapters)) + if scopeErr != nil { + return scopeErr + } + fmt.Fprintf(options.Output, "\nPASS: Boatstack updated to %s on a dedicated infrastructure branch.\n", Version) + fmt.Fprintln(options.Output, "PASS: no product files changed.") + fmt.Fprintln(options.Output, "Changed Boatstack paths:") + for _, path := range changed { + fmt.Fprintln(options.Output, " "+path) + } + } else { + fmt.Fprintln(options.Output, "\nPASS: Boatstack core installed without a language runtime.") + } fmt.Fprintln(options.Output, "PASS: fail-closed irreversible-operation hooks verified for installed hosts.") fmt.Fprintln(options.Output, "Hooks are defense in depth; keep least-privilege credentials and service-side destructive approval.") keys := sortedKeys(states) @@ -354,16 +455,45 @@ func RunInit(options InitOptions) error { state := states[name] fmt.Fprintf(options.Output, " %s: %s — %s\n", name, state.Status, state.Detail) } - fmt.Fprintln(options.Output, "\nBefore product work, commit Boatstack infrastructure in its own PR:") + if options.Update { + fmt.Fprintln(options.Output, "\nReview the generated diff before publishing the update PR:") + } else { + fmt.Fprintln(options.Output, "\nBefore product work, commit Boatstack infrastructure in its own PR:") + } stagePaths := append([]string{".boatstack-project.json"}, paths...) + if options.Update { + for path := range previousGenerated { + stagePaths = append(stagePaths, path) + } + } stagePaths = append(stagePaths, HostHookPaths(config.Adapters)...) + stageSet := map[string]bool{} + for _, path := range stagePaths { + stageSet[path] = true + } + stagePaths = sortedKeys(stageSet) fmt.Fprintln(options.Output, " git status --short") fmt.Fprintln(options.Output, " git add -- "+strings.Join(stagePaths, " ")) - fmt.Fprintln(options.Output, " git commit -m \"chore: install Boatstack\"") - fmt.Fprintln(options.Output, " git push -u origin chore/install-boatstack") + if options.Update { + fmt.Fprintf(options.Output, " git commit -m \"chore: update Boatstack to %s\"\n", Version) + fmt.Fprintf(options.Output, " git push -u origin chore/update-boatstack-%s\n", Version) + fmt.Fprintln(options.Output, "Do not publish until the human replies `open update PR`; never merge automatically.") + } else { + fmt.Fprintln(options.Output, " git commit -m \"chore: install Boatstack\"") + fmt.Fprintln(options.Output, " git push -u origin chore/install-boatstack") + } fmt.Fprintln(options.Output, "The platform helper and local install lock under .product-loop/bin/ are ignored; rerun the installer on a fresh clone.") - fmt.Fprintln(options.Output, "\nAfter that PR is merged, reload Cursor, Codex, or Claude and start in Plan mode:") - fmt.Fprintln(options.Output, " 1. Describe the product change and save the host plan (use .product-loop/intake/ if the host exposes no path).") - fmt.Fprintln(options.Output, " 2. Run /auto-plan") + if options.Update { + fmt.Fprintln(options.Output, "\nAfter the update PR is merged, reload Cursor, Codex, or Claude.") + } else { + fmt.Fprintln(options.Output, "\nAfter that PR is merged, reload Cursor, Codex, or Claude and start in Plan mode:") + fmt.Fprintln(options.Output, " 1. Describe the product change and save the host plan (use .product-loop/intake/ if the host exposes no path).") + fmt.Fprintln(options.Output, " 2. Run /auto-plan") + } return nil } + +func RunUpdate(options InitOptions) error { + options.Update = true + return RunInit(options) +} diff --git a/boatstack/init_test.go b/boatstack/init_test.go index 00c3163..922bcd8 100644 --- a/boatstack/init_test.go +++ b/boatstack/init_test.go @@ -49,7 +49,7 @@ func TestRuntimeFreeInit(t *testing.T) { t.Fatalf("init output is missing safety guidance %q: %s", expected, output.String()) } } - for _, expected := range []string{"commit Boatstack infrastructure in its own PR", "git add -- .boatstack-project.json", "git push -u origin chore/install-boatstack", "reload Cursor, Codex, or Claude"} { + for _, expected := range []string{"commit Boatstack infrastructure in its own PR", ".boatstack-project.json", "git push -u origin chore/install-boatstack", "reload Cursor, Codex, or Claude"} { if !strings.Contains(output.String(), expected) { t.Fatalf("init output is missing %q: %s", expected, output.String()) } diff --git a/boatstack/planning.go b/boatstack/planning.go index 4f5bc78..283d015 100644 --- a/boatstack/planning.go +++ b/boatstack/planning.go @@ -172,10 +172,11 @@ func RecordApproval(options ApprovalRecordOptions) error { } type installLock struct { - BoatstackVersion string `json:"boatstack_version"` - SourceCommit string `json:"source_commit"` - BinaryPath string `json:"binary_path"` - BinarySHA256 string `json:"binary_sha256"` + BoatstackVersion string `json:"boatstack_version"` + SourceCommit string `json:"source_commit"` + BinaryPath string `json:"binary_path"` + BinarySHA256 string `json:"binary_sha256"` + Integrations map[string]IntegrationState `json:"integrations,omitempty"` } func Doctor(repoPath string) error { diff --git a/boatstack/references/workflow.md b/boatstack/references/workflow.md index 5341eed..9a3d394 100644 --- a/boatstack/references/workflow.md +++ b/boatstack/references/workflow.md @@ -66,6 +66,7 @@ Lead with a plain outcome, never a machine code such as `PASS`, `PLAN_APPROVED`, | `test-gate` pass / blocked | **Tests passed** -> run `/review-gate`; **Testing found a problem** -> perform or authorize the repair | | `review-gate` pass / blocked | **Review passed** -> run `/ship-gate`; **Changes required** -> address the blocking finding | | `ship-gate` preview / published | **PR ready** -> reply `open PR` or `update PR`; **PR opened** -> review the PR; never imply merge authorization | +| `boatstack-update` current / postponed / prepared / published / blocked | **Boatstack is current** -> no action required; **Update postponed** -> finish feature work and rerun from the clean default branch; **Boatstack update ready** -> reply `open update PR`; **Update PR opened** -> review the PR; **Update needs attention** -> address the one reported collision or health failure | | `retro` | **Improvement proposed** -> review or authorize the experiment | Normal approval is `approve`. Resolve `approved_by` from (1) an identity supplied with approval, (2) the authenticated GitHub login from `gh api user --jq .login` when available, or (3) one short identity follow-up. Never infer the approver from a filesystem username, commit history, or the coding agent. If identity is missing after approval, preserve the current fingerprint and approval intent, create no receipt, and ask only for identity; once resolved against the unchanged plan, do not require another `approve`. Keep identity and receipt data inside **Technical details**. @@ -247,6 +248,16 @@ Before publication, show the exact title and rendered body. Use **PR ready** and Opening or updating a PR does not authorize merge or deployment. +After successful publication only, the publisher may use the ignored 24-hour release cache to report an available stable Boatstack version. The primary response and next action remain **PR opened -> Review the PR**. Put the maintenance notice in collapsed details, state that no files changed, and direct the user to run `/boatstack-update` from the clean default branch after the feature PR merges. Suppress repeated notices for seven days unless a different release appears. Release lookup failure never changes the ship result. + +## Boatstack updates + +`boatstack-update` is an infrastructure operation, not part of a feature plan. It first forces release discovery and proves the current installation is healthy. If the repository is not on its clean, current default branch, it changes nothing and returns **Update postponed**. + +For an available version, create `chore/update-boatstack-v`, run the installer pinned to that release in update mode, preserve the repository configuration, adapters, integrations, and unrelated host settings, then run `doctor`. Show the release notes and link, exact generated diff, checksums, changed paths, integration state, rollout, and rollback. Product paths or generated-state drift are blocking. + +Use **Boatstack update ready** and exactly one action: `Reply open update PR`. Only that reply authorizes staging the reported infrastructure paths, committing, pushing normally, and opening the update PR. The PR body records old/new versions, release provenance, changed generated files, doctor result, integration state, rollout, and revert instructions. If publication is unavailable, retain the prepared branch and provide one manual action. Never merge automatically. + ## Existing and ad-hoc PRs There is no public `/pr-brief` operation. When the user asks in natural language for Boatstack to prepare, improve, summarize, or update an existing PR without a managed feature package: diff --git a/boatstack/update.go b/boatstack/update.go new file mode 100644 index 0000000..8cced31 --- /dev/null +++ b/boatstack/update.go @@ -0,0 +1,352 @@ +package boatstack + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "time" +) + +const ( + latestReleaseEndpoint = "https://api.github.com/repos/operatorstack/boatstack/releases/latest" + updateCacheTTL = 24 * time.Hour + updateReminderWindow = 7 * 24 * time.Hour +) + +var ( + updateNow = time.Now + fetchLatestRelease = defaultFetchLatestRelease + stableVersionPattern = regexp.MustCompile(`^v?(\d+)\.(\d+)\.(\d+)$`) +) + +type ReleaseInfo struct { + Version string `json:"version"` + Name string `json:"name,omitempty"` + Notes string `json:"notes,omitempty"` + URL string `json:"url"` +} + +type UpdateState struct { + SchemaVersion int `json:"schema_version"` + CurrentVersion string `json:"current_version"` + LatestVersion string `json:"latest_version"` + ReleaseName string `json:"release_name,omitempty"` + ReleaseNotes string `json:"release_notes,omitempty"` + ReleaseURL string `json:"release_url"` + CheckedAt time.Time `json:"checked_at"` + LastNotifiedVersion string `json:"last_notified_version,omitempty"` + LastNotifiedAt time.Time `json:"last_notified_at,omitempty"` +} + +type UpdateCheckOptions struct { + Repo string + Force bool + Notify bool +} + +type UpdateCheckResult struct { + Status string + CurrentVersion string + LatestVersion string + ReleaseName string + ReleaseNotes string + ReleaseURL string + ShouldNotify bool + FromCache bool +} + +func parseStableVersion(value string) ([3]int, error) { + match := stableVersionPattern.FindStringSubmatch(strings.TrimSpace(value)) + if match == nil { + return [3]int{}, fmt.Errorf("version must be a stable semantic version: %s", value) + } + parsed := [3]int{} + for index := 0; index < 3; index++ { + number, err := strconv.Atoi(match[index+1]) + if err != nil { + return [3]int{}, fmt.Errorf("invalid semantic version: %s", value) + } + parsed[index] = number + } + return parsed, nil +} + +func compareVersions(left, right string) (int, error) { + a, err := parseStableVersion(left) + if err != nil { + return 0, err + } + b, err := parseStableVersion(right) + if err != nil { + return 0, err + } + for index := 0; index < 3; index++ { + if a[index] < b[index] { + return -1, nil + } + if a[index] > b[index] { + return 1, nil + } + } + return 0, nil +} + +func normalizedVersion(value string) (string, error) { + if _, err := parseStableVersion(value); err != nil { + return "", err + } + return "v" + strings.TrimPrefix(strings.TrimSpace(value), "v"), nil +} + +func defaultFetchLatestRelease() (ReleaseInfo, error) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + request, err := http.NewRequestWithContext(ctx, http.MethodGet, latestReleaseEndpoint, nil) + if err != nil { + return ReleaseInfo{}, err + } + request.Header.Set("Accept", "application/vnd.github+json") + request.Header.Set("User-Agent", "operatorstack-boatstack-update-check") + response, err := http.DefaultClient.Do(request) + if err != nil { + return ReleaseInfo{}, err + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return ReleaseInfo{}, fmt.Errorf("latest release lookup returned HTTP %d", response.StatusCode) + } + var payload struct { + TagName string `json:"tag_name"` + Name string `json:"name"` + Body string `json:"body"` + HTMLURL string `json:"html_url"` + Draft bool `json:"draft"` + Prerelease bool `json:"prerelease"` + } + if err := json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&payload); err != nil { + return ReleaseInfo{}, fmt.Errorf("invalid latest release response: %w", err) + } + if payload.Draft || payload.Prerelease { + return ReleaseInfo{}, fmt.Errorf("latest release response is not a stable published release") + } + version, err := normalizedVersion(payload.TagName) + if err != nil { + return ReleaseInfo{}, err + } + if strings.TrimSpace(payload.HTMLURL) == "" { + return ReleaseInfo{}, fmt.Errorf("latest release response is missing its URL") + } + return ReleaseInfo{ + Version: version, Name: strings.TrimSpace(payload.Name), Notes: strings.TrimSpace(payload.Body), + URL: strings.TrimSpace(payload.HTMLURL), + }, nil +} + +func updateStatePath(repo string) string { + return filepath.Join(repo, ".product-loop", "bin", "update-state.json") +} + +func loadUpdateState(repo string) (UpdateState, error) { + value, err := os.ReadFile(updateStatePath(repo)) + if err != nil { + return UpdateState{}, err + } + var state UpdateState + if err := json.Unmarshal(value, &state); err != nil { + return UpdateState{}, err + } + if state.SchemaVersion != 1 { + return UpdateState{}, fmt.Errorf("unsupported update-state schema") + } + return state, nil +} + +func writeUpdateState(repo string, state UpdateState) error { + path := updateStatePath(repo) + if err := rejectSymlinkComponents(repo, path); err != nil { + return err + } + value, err := MarshalJSON(state) + if err != nil { + return err + } + return atomicWrite(path, value) +} + +func resultFromState(state UpdateState, fromCache bool) (UpdateCheckResult, error) { + comparison, err := compareVersions(state.CurrentVersion, state.LatestVersion) + if err != nil { + return UpdateCheckResult{}, err + } + status := "current" + if comparison < 0 { + status = "available" + } + return UpdateCheckResult{ + Status: status, CurrentVersion: state.CurrentVersion, LatestVersion: state.LatestVersion, + ReleaseName: state.ReleaseName, ReleaseNotes: state.ReleaseNotes, + ReleaseURL: state.ReleaseURL, FromCache: fromCache, + }, nil +} + +func CheckForUpdate(options UpdateCheckOptions) (UpdateCheckResult, error) { + repo, err := ResolveRepository(options.Repo) + if err != nil { + return UpdateCheckResult{}, err + } + current, err := normalizedVersion(Version) + if err != nil { + return UpdateCheckResult{}, fmt.Errorf("cannot check updates for Boatstack %s: %w", Version, err) + } + now := updateNow().UTC() + state, stateErr := loadUpdateState(repo) + previousState := state + useCache := stateErr == nil && state.CurrentVersion == current && !options.Force && now.Sub(state.CheckedAt) >= 0 && now.Sub(state.CheckedAt) < updateCacheTTL + if !useCache { + release, fetchErr := fetchLatestRelease() + if fetchErr != nil { + return UpdateCheckResult{}, fetchErr + } + state = UpdateState{ + SchemaVersion: 1, CurrentVersion: current, LatestVersion: release.Version, + ReleaseName: release.Name, ReleaseNotes: release.Notes, + ReleaseURL: release.URL, CheckedAt: now, + } + if stateErr == nil { + state.LastNotifiedVersion = previousState.LastNotifiedVersion + state.LastNotifiedAt = previousState.LastNotifiedAt + } + } + result, err := resultFromState(state, useCache) + if err != nil { + return UpdateCheckResult{}, err + } + if options.Notify && result.Status == "available" { + newVersion := state.LastNotifiedVersion != state.LatestVersion + reminderDue := state.LastNotifiedAt.IsZero() || now.Sub(state.LastNotifiedAt) >= updateReminderWindow + result.ShouldNotify = newVersion || reminderDue + if result.ShouldNotify { + state.LastNotifiedVersion = state.LatestVersion + state.LastNotifiedAt = now + } + } + if !useCache || result.ShouldNotify { + if err := writeUpdateState(repo, state); err != nil { + return UpdateCheckResult{}, err + } + } + return result, nil +} + +func CachedUpdate(repoPath string) (UpdateCheckResult, bool) { + repo, err := ResolveRepository(repoPath) + if err != nil { + return UpdateCheckResult{}, false + } + state, err := loadUpdateState(repo) + if err != nil { + return UpdateCheckResult{}, false + } + result, err := resultFromState(state, true) + if err != nil || result.Status != "available" { + return UpdateCheckResult{}, false + } + return result, true +} + +// PostShipUpdateNotice is deliberately best-effort: release discovery can add +// information after a successful publication, but it cannot change that result. +func PostShipUpdateNotice(repo string) (UpdateCheckResult, bool) { + result, err := CheckForUpdate(UpdateCheckOptions{Repo: repo, Notify: true}) + if err != nil || result.Status != "available" || !result.ShouldNotify { + return UpdateCheckResult{}, false + } + return result, true +} + +func CheckPreviousGeneratedState(repo string) error { + value, err := os.ReadFile(filepath.Join(repo, ".product-loop", "generated.lock.json")) + if err != nil { + return fmt.Errorf("missing generated provenance: %w", err) + } + var lock struct { + Files map[string]string `json:"files"` + } + if err := json.Unmarshal(value, &lock); err != nil || len(lock.Files) == 0 { + return fmt.Errorf("invalid generated provenance") + } + problems := []string{} + for relative, expected := range lock.Files { + current, readErr := os.ReadFile(filepath.Join(repo, filepath.FromSlash(relative))) + if readErr != nil || SHA256Bytes(current) != expected { + problems = append(problems, relative) + } + } + if len(problems) > 0 { + sort.Strings(problems) + return fmt.Errorf("generated files changed since installation: %s", strings.Join(problems, ", ")) + } + return nil +} + +func CheckExistingInstallProvenance(repo string) error { + value, err := os.ReadFile(filepath.Join(repo, ".product-loop", "bin", "install.lock.json")) + if err != nil { + return fmt.Errorf("missing previous local install lock: %w", err) + } + var lock installLock + if err := json.Unmarshal(value, &lock); err != nil { + return fmt.Errorf("invalid previous local install lock: %w", err) + } + if _, err := parseStableVersion(lock.BoatstackVersion); err != nil || strings.TrimSpace(lock.SourceCommit) == "" { + return fmt.Errorf("previous local install lock has invalid release provenance") + } + binaryPath, err := resolveRepositoryRelativePath(repo, lock.BinaryPath) + if err != nil { + return fmt.Errorf("invalid previous helper path: %w", err) + } + actual, err := SHA256File(binaryPath) + if err != nil || actual != lock.BinarySHA256 { + return fmt.Errorf("previous Boatstack helper does not match its install lock") + } + return nil +} + +func ValidateUpdateWorkspace(repo string, config ProjectConfig) error { + version, err := normalizedVersion(Version) + if err != nil { + return err + } + wantBranch := "chore/update-boatstack-" + version + branch := gitOutput(repo, "branch", "--show-current") + if branch != wantBranch { + return fmt.Errorf("update must run on %s; current branch is %s", wantBranch, branch) + } + if gitOutput(repo, "status", "--porcelain") != "" { + return fmt.Errorf("update branch must start with a clean worktree") + } + defaultBranch := strings.TrimSpace(config.Project.DefaultBranch) + if defaultBranch == "" { + return fmt.Errorf("project.default_branch is required for updates") + } + head := gitOutput(repo, "rev-parse", "HEAD") + remoteHead := gitOutput(repo, "rev-parse", "origin/"+defaultBranch) + if head == "" || remoteHead == "" || head != remoteHead { + return fmt.Errorf("update branch must start from the current origin/%s", defaultBranch) + } + if err := CheckPreviousGeneratedState(repo); err != nil { + return err + } + if err := CheckHostHooks(repo, config.Adapters); err != nil { + return fmt.Errorf("host-hook drift blocks update: %w", err) + } + return CheckExistingInstallProvenance(repo) +} diff --git a/boatstack/update_test.go b/boatstack/update_test.go new file mode 100644 index 0000000..91914ef --- /dev/null +++ b/boatstack/update_test.go @@ -0,0 +1,390 @@ +package boatstack + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +type updateRoundTripFunc func(*http.Request) (*http.Response, error) + +func (function updateRoundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { + return function(request) +} + +func withUpdateGlobals(t *testing.T, version string, now time.Time, fetch func() (ReleaseInfo, error)) { + t.Helper() + oldVersion := Version + oldCommit := SourceCommit + oldChecksums := ChecksumsSHA256 + oldNow := updateNow + oldFetch := fetchLatestRelease + Version = version + SourceCommit = "update-test-" + strings.TrimPrefix(version, "v") + ChecksumsSHA256 = "update-test-checksums" + updateNow = func() time.Time { return now } + fetchLatestRelease = fetch + t.Cleanup(func() { + Version = oldVersion + SourceCommit = oldCommit + ChecksumsSHA256 = oldChecksums + updateNow = oldNow + fetchLatestRelease = oldFetch + }) +} + +func updateCacheRepo(t *testing.T) string { + t.Helper() + repo := t.TempDir() + runGit(t, repo, "init", "-b", "main") + return repo +} + +func TestStableVersionComparison(t *testing.T) { + for _, test := range []struct { + left, right string + want int + }{ + {"v0.4.0", "v0.5.0", -1}, + {"0.5.0", "v0.5.0", 0}, + {"v1.0.0", "v0.9.9", 1}, + } { + got, err := compareVersions(test.left, test.right) + if err != nil || got != test.want { + t.Fatalf("compareVersions(%q, %q) = %d, %v; want %d", test.left, test.right, got, err, test.want) + } + } + for _, invalid := range []string{"", "latest", "v0.5.0-rc.1", "v0.5", "v1.2.3.4"} { + if _, err := parseStableVersion(invalid); err == nil { + t.Fatalf("parseStableVersion accepted %q", invalid) + } + } +} + +func TestLatestReleaseResponseValidation(t *testing.T) { + oldClient := http.DefaultClient + t.Cleanup(func() { http.DefaultClient = oldClient }) + for _, test := range []struct { + name, body string + status int + transport error + wantErr bool + }{ + {"stable", `{"tag_name":"v0.5.0","name":"v0.5.0","body":"Release notes","html_url":"https://example.invalid/v0.5.0"}`, 200, nil, false}, + {"prerelease", `{"tag_name":"v0.5.0-rc.1","prerelease":true,"html_url":"https://example.invalid/rc"}`, 200, nil, true}, + {"malformed", `{`, 200, nil, true}, + {"rate limit", `{}`, 429, nil, true}, + {"timeout", ``, 0, errors.New("request timed out"), true}, + } { + t.Run(test.name, func(t *testing.T) { + http.DefaultClient = &http.Client{Transport: updateRoundTripFunc(func(*http.Request) (*http.Response, error) { + if test.transport != nil { + return nil, test.transport + } + return &http.Response{ + StatusCode: test.status, + Body: io.NopCloser(strings.NewReader(test.body)), + Header: make(http.Header), + }, nil + })} + release, err := defaultFetchLatestRelease() + if test.wantErr && err == nil { + t.Fatalf("defaultFetchLatestRelease accepted %s: %#v", test.name, release) + } + if !test.wantErr && (err != nil || release.Version != "v0.5.0" || release.Notes != "Release notes") { + t.Fatalf("stable release = %#v, %v", release, err) + } + }) + } +} + +func TestUpdateCheckCachesAndBoundsNotifications(t *testing.T) { + repo := updateCacheRepo(t) + now := time.Date(2026, 7, 17, 12, 0, 0, 0, time.UTC) + latest := ReleaseInfo{Version: "v0.5.0", Name: "Boatstack v0.5.0", URL: "https://github.com/operatorstack/boatstack/releases/tag/v0.5.0"} + fetches := 0 + withUpdateGlobals(t, "v0.4.0", now, func() (ReleaseInfo, error) { + fetches++ + return latest, nil + }) + + first, err := CheckForUpdate(UpdateCheckOptions{Repo: repo, Notify: true}) + if err != nil || first.Status != "available" || !first.ShouldNotify || first.FromCache { + t.Fatalf("first check = %#v, %v", first, err) + } + if fetches != 1 { + t.Fatalf("first check fetched %d times", fetches) + } + + updateNow = func() time.Time { return now.Add(2 * time.Hour) } + second, err := CheckForUpdate(UpdateCheckOptions{Repo: repo, Notify: true}) + if err != nil || second.ShouldNotify || !second.FromCache { + t.Fatalf("cached check = %#v, %v", second, err) + } + if fetches != 1 { + t.Fatalf("cached check fetched %d times", fetches) + } + + updateNow = func() time.Time { return now.Add(8 * 24 * time.Hour) } + reminder, err := CheckForUpdate(UpdateCheckOptions{Repo: repo, Notify: true}) + if err != nil || !reminder.ShouldNotify { + t.Fatalf("weekly reminder = %#v, %v", reminder, err) + } + if fetches != 2 { + t.Fatalf("expired cache fetched %d times", fetches) + } + + latest.Version = "v0.6.0" + latest.Name = "Boatstack v0.6.0" + latest.URL = "https://github.com/operatorstack/boatstack/releases/tag/v0.6.0" + updateNow = func() time.Time { return now.Add(8*24*time.Hour + time.Hour) } + newRelease, err := CheckForUpdate(UpdateCheckOptions{Repo: repo, Force: true, Notify: true}) + if err != nil || newRelease.LatestVersion != "v0.6.0" || !newRelease.ShouldNotify { + t.Fatalf("new release = %#v, %v", newRelease, err) + } +} + +func TestUpdateCheckCurrentAndFailures(t *testing.T) { + repo := updateCacheRepo(t) + now := time.Date(2026, 7, 17, 12, 0, 0, 0, time.UTC) + withUpdateGlobals(t, "v0.5.0", now, func() (ReleaseInfo, error) { + return ReleaseInfo{Version: "v0.5.0", URL: "https://example.invalid/v0.5.0"}, nil + }) + result, err := CheckForUpdate(UpdateCheckOptions{Repo: repo, Force: true, Notify: true}) + if err != nil || result.Status != "current" || result.ShouldNotify { + t.Fatalf("current check = %#v, %v", result, err) + } + if _, ok := CachedUpdate(repo); ok { + t.Fatal("doctor cache exposed a current release as an update") + } + + fetchLatestRelease = func() (ReleaseInfo, error) { return ReleaseInfo{}, errors.New("rate limited") } + if _, err := CheckForUpdate(UpdateCheckOptions{Repo: repo, Force: true}); err == nil { + t.Fatal("forced check hid its network failure") + } + if err := os.Remove(updateStatePath(repo)); err != nil { + t.Fatal(err) + } + if notice, ok := PostShipUpdateNotice(repo); ok { + t.Fatalf("release lookup failure changed post-ship behavior: %#v", notice) + } + if cached, ok := CachedUpdate(repo); ok { + t.Fatalf("failed release discovery wrote a misleading cache: %#v", cached) + } +} + +func updateInstalledRepo(t *testing.T) (string, map[string]IntegrationState) { + t.Helper() + 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, "package.json"), []byte(`{"scripts":{"test":"node --test"}}`), 0o644); err != nil { + t.Fatal(err) + } + if err := RunInit(InitOptions{Repo: repo, IntegrationChoice: "core", Yes: true, Output: io.Discard}); err != nil { + t.Fatal(err) + } + configPath := filepath.Join(repo, ".boatstack-project.json") + config, _, err := LoadConfig(configPath) + if err != nil { + t.Fatal(err) + } + gstack := config.Integrations["gstack"] + gstack.Requested = true + config.Integrations["gstack"] = gstack + rawConfig, err := MarshalJSON(config) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(configPath, rawConfig, 0o644); err != nil { + t.Fatal(err) + } + bundle, err := BuildExportBundle(configPath, config, rawConfig, "boatstack") + if err != nil { + t.Fatal(err) + } + if err := WriteExport(repo, bundle.Files); err != nil { + t.Fatal(err) + } + states := map[string]IntegrationState{ + "gstack": {Requested: true, Status: "installed", Version: GStackRef, Detail: "fixture installation"}, + "spec-kit": {Requested: false, Status: "not_selected", Version: SpecKitVersion}, + } + var prior installLock + lockValue, err := os.ReadFile(filepath.Join(repo, ".product-loop", "bin", "install.lock.json")) + if err != nil || json.Unmarshal(lockValue, &prior) != nil { + t.Fatalf("read install lock: %v", err) + } + binaryPath, err := resolveRepositoryRelativePath(repo, prior.BinaryPath) + if err != nil { + t.Fatal(err) + } + binaryHash, err := SHA256File(binaryPath) + if err != nil { + t.Fatal(err) + } + if err := writeInstallLock(repo, binaryPath, binaryHash, states); err != nil { + t.Fatal(err) + } + cursorHooks := filepath.Join(repo, ".cursor", "hooks.json") + hooks := map[string]any{} + hooksValue, err := os.ReadFile(cursorHooks) + if err != nil || json.Unmarshal(hooksValue, &hooks) != nil { + t.Fatalf("read Cursor hooks: %v", err) + } + hooks["user_setting"] = "preserve-me" + updatedHooks, err := MarshalJSON(hooks) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(cursorHooks, updatedHooks, 0o644); err != nil { + t.Fatal(err) + } + runGit(t, repo, "add", ".") + runGit(t, repo, "commit", "-m", "install Boatstack") + remote := filepath.Join(t.TempDir(), "origin.git") + if output, err := exec.Command("git", "init", "--bare", remote).CombinedOutput(); err != nil { + t.Fatalf("git init --bare: %v: %s", err, output) + } + runGit(t, repo, "remote", "add", "origin", remote) + runGit(t, repo, "push", "--set-upstream", "origin", "main") + return repo, states +} + +func TestUpdateRequiresCleanCurrentDedicatedBranch(t *testing.T) { + now := time.Date(2026, 7, 17, 12, 0, 0, 0, time.UTC) + withUpdateGlobals(t, "v0.4.0", now, func() (ReleaseInfo, error) { return ReleaseInfo{}, nil }) + repo, _ := updateInstalledRepo(t) + config, _, err := LoadConfig(filepath.Join(repo, ".boatstack-project.json")) + if err != nil { + t.Fatal(err) + } + Version = "v0.5.0" + SourceCommit = "update-test-0.5.0" + + if err := ValidateUpdateWorkspace(repo, config); err == nil || !strings.Contains(err.Error(), "chore/update-boatstack-v0.5.0") { + t.Fatalf("default branch was not blocked: %v", err) + } + runGit(t, repo, "switch", "-c", "chore/update-boatstack-v0.5.0") + if err := ValidateUpdateWorkspace(repo, config); err != nil { + t.Fatalf("healthy update workspace was rejected: %v", err) + } + head := runGit(t, repo, "rev-parse", "HEAD") + tree := runGit(t, repo, "write-tree") + remoteAdvance := runGit(t, repo, "commit-tree", tree, "-p", head, "-m", "remote advance") + runGit(t, repo, "update-ref", "refs/remotes/origin/main", remoteAdvance) + if err := ValidateUpdateWorkspace(repo, config); err == nil || !strings.Contains(err.Error(), "current origin/main") { + t.Fatalf("stale default branch was not blocked: %v", err) + } + runGit(t, repo, "update-ref", "refs/remotes/origin/main", head) + if err := os.WriteFile(filepath.Join(repo, "dirty.txt"), []byte("dirty\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := ValidateUpdateWorkspace(repo, config); err == nil || !strings.Contains(err.Error(), "clean worktree") { + t.Fatalf("dirty update was not blocked: %v", err) + } + if err := os.Remove(filepath.Join(repo, "dirty.txt")); err != nil { + t.Fatal(err) + } + generated := filepath.Join(repo, ".product-loop", "workflow.md") + value, err := os.ReadFile(generated) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(generated, append(value, []byte("\ndrift\n")...), 0o644); err != nil { + t.Fatal(err) + } + if err := CheckPreviousGeneratedState(repo); err == nil || !strings.Contains(err.Error(), "workflow.md") { + t.Fatalf("generated drift was not detected: %v", err) + } + if err := ValidateUpdateWorkspace(repo, config); err == nil || !strings.Contains(err.Error(), "clean worktree") { + t.Fatalf("tracked generated drift was not blocked before mutation: %v", err) + } +} + +func TestDoctorReadsCachedUpdateWithoutNetwork(t *testing.T) { + now := time.Date(2026, 7, 17, 12, 0, 0, 0, time.UTC) + withUpdateGlobals(t, "v0.4.0", now, func() (ReleaseInfo, error) { + panic("doctor initiated release traffic") + }) + repo, _ := updateInstalledRepo(t) + if err := writeUpdateState(repo, UpdateState{ + SchemaVersion: 1, + CurrentVersion: "v0.4.0", + LatestVersion: "v0.5.0", + ReleaseURL: "https://example.invalid/v0.5.0", + CheckedAt: now, + }); err != nil { + t.Fatal(err) + } + if err := Doctor(repo); err != nil { + t.Fatal(err) + } + if cached, ok := CachedUpdate(repo); !ok || cached.LatestVersion != "v0.5.0" { + t.Fatalf("cached update missing after offline doctor: %#v, %t", cached, ok) + } +} + +func TestRunUpdatePreservesConfigurationAndIntegrations(t *testing.T) { + now := time.Date(2026, 7, 17, 12, 0, 0, 0, time.UTC) + withUpdateGlobals(t, "v0.4.0", now, func() (ReleaseInfo, error) { return ReleaseInfo{}, nil }) + repo, beforeStates := updateInstalledRepo(t) + configPath := filepath.Join(repo, ".boatstack-project.json") + beforeConfig, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + runGit(t, repo, "switch", "-c", "chore/update-boatstack-v0.5.0") + Version = "v0.5.0" + SourceCommit = "update-test-0.5.0" + var output bytes.Buffer + if err := RunUpdate(InitOptions{Repo: repo, Yes: true, Output: &output}); err != nil { + t.Fatal(err) + } + if got := runGit(t, repo, "rev-parse", "HEAD"); got != runGit(t, repo, "rev-parse", "origin/main") { + t.Fatal("update preparation committed before open update PR") + } + afterConfig, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(beforeConfig, afterConfig) { + t.Fatal("update rewrote project configuration") + } + config, _, err := LoadConfig(configPath) + if err != nil { + t.Fatal(err) + } + afterStates, err := readInstalledIntegrations(repo, config) + if err != nil { + t.Fatal(err) + } + for name, before := range beforeStates { + if afterStates[name] != before { + t.Fatalf("integration %s changed: %#v -> %#v", name, before, afterStates[name]) + } + } + hooksValue, err := os.ReadFile(filepath.Join(repo, ".cursor", "hooks.json")) + if err != nil || !strings.Contains(string(hooksValue), "preserve-me") { + t.Fatalf("update removed user-owned host settings: %v", err) + } + for _, expected := range []string{"updated to v0.5.0", "no product files changed", "open update PR", "never merge automatically"} { + if !strings.Contains(output.String(), expected) { + t.Fatalf("update output is missing %q: %s", expected, output.String()) + } + } + for _, changed := range updateChangedPaths(repo) { + if changed == "package.json" || strings.HasSuffix(changed, ".go") { + t.Fatalf("update touched product path %s", changed) + } + } +} diff --git a/docs/evidence-engineered-coding.md b/docs/evidence-engineered-coding.md index 3d9ee8a..a35158c 100644 --- a/docs/evidence-engineered-coding.md +++ b/docs/evidence-engineered-coding.md @@ -90,7 +90,7 @@ subject to acceptance criteria pass approval is current ``` -That is why context trimming is not automatically an optimization. If removing state increases rework or false acceptance, total cost rises. The canonical runtime references are approximately **7645 estimated tokens**, while host adapters point to one operation at a time. +That is why context trimming is not automatically an optimization. If removing state increases rework or false acceptance, total cost rises. The canonical runtime references are approximately **8172 estimated tokens**, while host adapters point to one operation at a time. ## Control appears at transitions @@ -140,6 +140,6 @@ Delivery and system improvement also remain separate. A failed task may suggest ## What is evidence-backed -The current moves were derived from the Intelligence Flow benchmark corpus and product-repository studies. The generated source commit is [`d37975f8c3960b03c701f029d4815f6d6814ed7c`](https://github.com/operatorstack/intelligence-flow/tree/d37975f8c3960b03c701f029d4815f6d6814ed7c/examples/12-product-engineering-loop). +The current moves were derived from the Intelligence Flow benchmark corpus and product-repository studies. The generated source commit is [`4bc614dd6355dcc408e2a49955cb53b868d5dee8`](https://github.com/operatorstack/intelligence-flow/tree/4bc614dd6355dcc408e2a49955cb53b868d5dee8/examples/12-product-engineering-loop). The evidence supports specific failure mechanisms and guardrails. It does not establish that Boatstack is optimal, that control-theory notation proves software quality, or that one workflow dominates every team. Those are evaluation questions, so the distribution preserves measurements, provenance, gaps, and negative results. diff --git a/docs/generated-files.md b/docs/generated-files.md index 2b58ba0..cce61cc 100644 --- a/docs/generated-files.md +++ b/docs/generated-files.md @@ -47,7 +47,11 @@ The preview's frontmatter is publication metadata; the remaining Markdown is the ## Fresh clones and updates -Committed adapters survive a clone; the ignored helper does not. Rerun the installer to restore it. For an update, use a separate `chore/update-boatstack` branch, inspect the generated diff and version provenance, and merge it before unrelated product work. +Committed adapters survive a clone; the ignored helper does not. Rerun the installer to restore it. + +For an update, run `/boatstack-update` from a clean, current default branch. Boatstack creates `chore/update-boatstack-v`, verifies the tagged release and checksum, preserves integrations, and shows the exact generated diff before asking for `open update PR`. Release-check state in `.product-loop/bin/update-state.json` and the platform helper remain ignored; the adapters, generated lock, hook fragments, and merged host settings belong in the update PR. + +An update refuses feature branches, dirty worktrees, stale default branches, changed generated files, and user-owned collisions. It never merges its own PR. If generated state looks wrong, run: diff --git a/docs/getting-started.md b/docs/getting-started.md index 65c390b..adaa3ba 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -146,6 +146,8 @@ Run the remaining gates: Boatstack shows the exact PR preview before changing GitHub. Reply `open PR` for a new PR. Reply `update PR` for an existing one. Any changed commit or evidence makes the preview stale and forces regeneration. Merge and deploy remain separate human decisions. +After successful publication, Boatstack may show a collapsed notice when a newer stable release is available. The check is cached, never changes the feature branch, and never blocks shipping. + For an existing branch, ask naturally: ```text @@ -154,6 +156,40 @@ Use Boatstack to improve this PR. Boatstack summarizes what it can observe and labels unavailable approval or gate evidence `NOT_VERIFIED`; it does not invent a history the branch never had. +## Keeping Boatstack current + +After the feature PR is merged, switch to a clean, current default branch and run: + +```text +/boatstack-update +``` + +You may also ask, “Update Boatstack.” Boatstack checks the latest stable release, creates `chore/update-boatstack-v`, preserves the current configuration and integrations, runs `doctor`, and shows the exact infrastructure diff. Product files are outside the allowed update scope. + +When the preview is correct, reply: + +```text +open update PR +``` + +Only that reply authorizes the update commit, push, and PR. Review and merge remain normal human decisions. If the command is run during feature work, Boatstack changes nothing and asks you to rerun it from the clean default branch after the feature PR merges. + +Users on `v0.4.0` do not have this command yet. After `v0.5.0` is released, make the clean update branch yourself and run the installer pinned to that tag once: + +```bash +git switch -c chore/update-boatstack-v0.5.0 +BOATSTACK_MODE=update BOATSTACK_VERSION=v0.5.0 BOATSTACK_REPO="$PWD" BOATSTACK_YES=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/operatorstack/boatstack/v0.5.0/install.sh)" +``` + +Windows PowerShell: + +```powershell +git switch -c chore/update-boatstack-v0.5.0 +$env:BOATSTACK_MODE="update"; $env:BOATSTACK_VERSION="v0.5.0"; $env:BOATSTACK_REPO=(Get-Location).Path; $env:BOATSTACK_YES="1"; irm https://raw.githubusercontent.com/operatorstack/boatstack/v0.5.0/install.ps1 | iex +``` + +Review the diff and open the update PR normally. After that bootstrap, future releases use `/boatstack-update`. + ## When something blocks - A product decision returns to you rather than being guessed. diff --git a/docs/public-claims.json b/docs/public-claims.json index d51cf33..b98f044 100644 --- a/docs/public-claims.json +++ b/docs/public-claims.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "source_commit": "d37975f8c3960b03c701f029d4815f6d6814ed7c", + "source_commit": "4bc614dd6355dcc408e2a49955cb53b868d5dee8", "statuses": ["verified", "observed", "still_being_evaluated"], "claims": [ { @@ -12,7 +12,7 @@ "readable_evidence": "why-these-steps.md#human-decisions", "implementation": ["../boatstack/references/workflow.md", "../boatstack/plan.go"], "verification": ["../boatstack/plan_test.go", "../boatstack/planning_test.go"], - "last_verified_version": "source:d37975f8c3960b03c701f029d4815f6d6814ed7c" + "last_verified_version": "source:4bc614dd6355dcc408e2a49955cb53b868d5dee8" }, { "id": "validation-provenance", @@ -23,7 +23,7 @@ "readable_evidence": "why-these-steps.md#validation-provenance", "implementation": ["validation-and-evidence.md", "../boatstack/plan.go"], "verification": ["../boatstack/plan_test.go"], - "last_verified_version": "source:d37975f8c3960b03c701f029d4815f6d6814ed7c" + "last_verified_version": "source:4bc614dd6355dcc408e2a49955cb53b868d5dee8" }, { "id": "irreversible-operations", @@ -35,7 +35,7 @@ "readable_evidence": "why-these-steps.md#irreversible-operations", "implementation": ["safety.md", "../boatstack/safety.go", "../boatstack/hooks.go"], "verification": ["../boatstack/safety_test.go", "../boatstack/hooks_test.go"], - "last_verified_version": "source:d37975f8c3960b03c701f029d4815f6d6814ed7c" + "last_verified_version": "source:4bc614dd6355dcc408e2a49955cb53b868d5dee8" }, { "id": "reviewer-ready-pr", @@ -46,7 +46,7 @@ "readable_evidence": "why-these-steps.md#reviewer-ready-pr", "implementation": ["../boatstack/pr.go", "getting-started.md"], "verification": ["../boatstack/pr_test.go"], - "last_verified_version": "source:d37975f8c3960b03c701f029d4815f6d6814ed7c" + "last_verified_version": "source:4bc614dd6355dcc408e2a49955cb53b868d5dee8" }, { "id": "model-neutral-contract", @@ -57,7 +57,7 @@ "readable_evidence": "why-these-steps.md#model-choice-and-budget", "implementation": ["research-and-design.md", "../boatstack/references/workflow.md"], "verification": ["../boatstack/export_test.go", "../boatstack/planning_test.go"], - "last_verified_version": "source:d37975f8c3960b03c701f029d4815f6d6814ed7c" + "last_verified_version": "source:4bc614dd6355dcc408e2a49955cb53b868d5dee8" }, { "id": "cross-model-failures", @@ -68,7 +68,7 @@ "readable_evidence": "why-these-steps.md#model-choice-and-budget", "implementation": ["research-and-design.md"], "verification": ["benchmark-corpus-audit.md", "benchmark-submission-audit.md"], - "last_verified_version": "source:d37975f8c3960b03c701f029d4815f6d6814ed7c" + "last_verified_version": "source:4bc614dd6355dcc408e2a49955cb53b868d5dee8" }, { "id": "lower-cost-outcomes", @@ -79,7 +79,18 @@ "readable_evidence": "why-these-steps.md#model-choice-and-budget", "implementation": ["research-and-design.md"], "verification": ["benchmark-corpus-audit.md", "benchmark-submission-audit.md"], - "last_verified_version": "source:d37975f8c3960b03c701f029d4815f6d6814ed7c" + "last_verified_version": "source:4bc614dd6355dcc408e2a49955cb53b868d5dee8" + }, + { + "id": "visible-updates", + "public_claim": "Boatstack reports available stable releases after successful PR publication and prepares accepted updates in a separate reviewable infrastructure branch without merging them.", + "status": "verified", + "originating_observation": "Installer-based updates depended on remembered commands and could be mixed into feature work or reset optional integration choices.", + "safeguard": "Cached post-ship discovery, clean-default-branch enforcement, exact release verification, integration preservation, diff preview, and explicit update-PR publication.", + "readable_evidence": "why-these-steps.md#visible-updates", + "implementation": ["../boatstack/update.go", "../boatstack/init.go"], + "verification": ["../boatstack/update_test.go", "../boatstack/init_test.go", "../boatstack/export_test.go"], + "last_verified_version": "source:4bc614dd6355dcc408e2a49955cb53b868d5dee8" } ] } diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index e333229..8435919 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -64,6 +64,18 @@ Boatstack detects common package-manager tests, `scripts/check.sh`, Go, Rust, Ma This is expected: `.product-loop/bin/` is machine-local and ignored. Rerun the installer from the repository root. A matching version and configuration should restore the helper without changing committed adapters. +## `/boatstack-update` is postponed + +Updates never share a feature branch. Finish and merge the current feature PR, switch to the configured default branch, pull its current remote state, confirm the worktree is clean, and rerun `/boatstack-update`. Boatstack does not stash, switch away from, or modify active product work. + +## The update check is unavailable + +Release discovery uses a short, unauthenticated request to GitHub and a 24-hour ignored cache. A timeout, rate limit, or malformed response never blocks `/ship-gate`. Retry `/boatstack-update` later; do not bypass checksum verification or install from an unverified asset. + +## The update reports generated drift + +Boatstack found an installed generated file that no longer matches its previous lock. Review the named path and move durable project-owned content into `.boatstack-project.json` or repository documentation. Do not overwrite the drift merely to make the update pass. + ## The PR preview is stale A new commit, changed evidence, changed approval artifact, or base-branch update invalidated the preview. Ask Boatstack to regenerate it. Do not copy the old body forward. diff --git a/docs/why-these-steps.md b/docs/why-these-steps.md index edd3785..d8d96bf 100644 --- a/docs/why-these-steps.md +++ b/docs/why-these-steps.md @@ -65,6 +65,16 @@ Those labels prevent an implementation test from being presented as proof that t The paired product evaluation will use the same feature, lower-cost model, budget, environment, and coding host with and without Boatstack. An independent evaluator will compare correctness, regressions, review findings, cost, completion time, and the evidence available to support completion claims. +## Visible updates + +**What happened.** Boatstack updates were possible by rerunning the installer, but users had to remember the command and could accidentally mix regenerated infrastructure into product work. A non-interactive rerun could also silently select core instead of preserving optional integrations. + +**What Boatstack does.** Release discovery occurs only after successful PR publication and is cached outside Git. An available release is informational; it never mutates the feature branch. `/boatstack-update` requires a clean current default branch, prepares a versioned infrastructure branch, preserves integration choices, verifies the release, runs `doctor`, and shows the exact diff before a separate `open update PR` confirmation. + +**How we check it.** Update tests cover release parsing, bounded caching and reminders, failed-network isolation, branch and drift rejection, integration preservation, generated-file scope, checksums, and the publication confirmation boundary. + +**Status:** release notification and update preparation behavior are verified in automated tests. This is not a claim that updates install themselves or may be merged without review. + ## What the experiments do and do not support The current research covers thousands of locally available benchmark result records, preregistered comparisons, product-repository studies, and targeted trajectory inspection. It supports the mechanisms that Boatstack is designed to address. It does **not** yet support a claim that Boatstack improves feature success, cost, or delivery speed. diff --git a/examples/diagram-json/plan.lock.json b/examples/diagram-json/plan.lock.json index ca303df..59b3bbb 100644 --- a/examples/diagram-json/plan.lock.json +++ b/examples/diagram-json/plan.lock.json @@ -6,7 +6,7 @@ "plan_path": "examples/diagram-json/plan.md", "plan_sha256": "3ad35cc3cbe48306e7ee401bd9e9047d25e46c8a6fe9679aa1b3f5e96ceea292", "schema_version": 1, - "source_commit": "d37975f8c3960b03c701f029d4815f6d6814ed7c", + "source_commit": "4bc614dd6355dcc408e2a49955cb53b868d5dee8", "source_plan_path": "examples/diagram-json/source-plan.md", "source_plan_sha256": "e10593ddaa7522ab80cc991d0a09399257139799e37f737794cd49d68a39985b", "spec_path": "examples/diagram-json/spec.md", diff --git a/install.ps1 b/install.ps1 index 53c137d..caa4dfa 100644 --- a/install.ps1 +++ b/install.ps1 @@ -4,6 +4,10 @@ $ErrorActionPreference = "Stop" $repository = "operatorstack/boatstack" $version = if ($env:BOATSTACK_VERSION) { $env:BOATSTACK_VERSION } else { "latest" } $targetRepo = if ($env:BOATSTACK_REPO) { $env:BOATSTACK_REPO } else { (Get-Location).Path } +$mode = if ($env:BOATSTACK_MODE) { $env:BOATSTACK_MODE } else { "install" } +if ($mode -notin @("install", "update")) { + throw "BLOCKED: BOATSTACK_MODE must be install or update" +} if (-not (Get-Command git -ErrorAction SilentlyContinue)) { throw "BLOCKED: Git is required because Boatstack operates on reviewable repository state" @@ -17,6 +21,16 @@ $arch = switch ($architecture) { } $asset = "boatstack-helper_windows_${arch}.exe" +if ($mode -eq "update") { + $currentHelper = Join-Path $targetRepo ".product-loop/bin/boatstack-helper.exe" + if (-not (Test-Path -PathType Leaf $currentHelper)) { + throw "BLOCKED: current Boatstack helper is missing; repair the installation before updating" + } + & $currentHelper doctor --repo $targetRepo + if ($LASTEXITCODE -ne 0) { + throw "Current Boatstack installation must pass doctor before updating" + } +} $base = if ($version -eq "latest") { "https://github.com/$repository/releases/latest/download" } else { @@ -37,8 +51,9 @@ try { throw "BLOCKED: Boatstack binary checksum mismatch" } - $arguments = @("init", "--repo", $targetRepo, "--binary", $binary) - if ($env:BOATSTACK_INTEGRATIONS) { + $commandName = if ($mode -eq "update") { "update" } else { "init" } + $arguments = @($commandName, "--repo", $targetRepo, "--binary", $binary) + if ($mode -eq "install" -and $env:BOATSTACK_INTEGRATIONS) { $arguments += @("--integrations", $env:BOATSTACK_INTEGRATIONS) } if ($env:BOATSTACK_YES -eq "1") { diff --git a/install.sh b/install.sh index ab5ccff..9c7f56b 100644 --- a/install.sh +++ b/install.sh @@ -5,6 +5,12 @@ set -euo pipefail repository="operatorstack/boatstack" version="${BOATSTACK_VERSION:-latest}" target_repo="${BOATSTACK_REPO:-$PWD}" +mode="${BOATSTACK_MODE:-install}" + +case "$mode" in + install|update) ;; + *) echo "BLOCKED: BOATSTACK_MODE must be install or update" >&2; exit 1 ;; +esac case "$(uname -s)" in Darwin) os_name="darwin" ;; @@ -25,6 +31,11 @@ command -v git >/dev/null 2>&1 || { echo "BLOCKED: Git is required because Boats extension="" [ "$os_name" = "windows" ] && extension=".exe" asset="boatstack-helper_${os_name}_${arch}${extension}" +if [ "$mode" = "update" ]; then + current_helper="$target_repo/.product-loop/bin/boatstack-helper${extension}" + [ -x "$current_helper" ] || { echo "BLOCKED: current Boatstack helper is missing; repair the installation before updating" >&2; exit 1; } + "$current_helper" doctor --repo "$target_repo" +fi if [ "$version" = "latest" ]; then base="https://github.com/${repository}/releases/latest/download" else @@ -51,8 +62,10 @@ fi [ "$expected" = "$actual" ] || { echo "BLOCKED: Boatstack binary checksum mismatch" >&2; exit 1; } chmod +x "$binary" -arguments=(init --repo "$target_repo" --binary "$binary") -if [ -n "${BOATSTACK_INTEGRATIONS:-}" ]; then +command_name="init" +[ "$mode" = "update" ] && command_name="update" +arguments=("$command_name" --repo "$target_repo" --binary "$binary") +if [ "$mode" = "install" ] && [ -n "${BOATSTACK_INTEGRATIONS:-}" ]; then arguments+=(--integrations "$BOATSTACK_INTEGRATIONS") fi if [ "${BOATSTACK_YES:-0}" = "1" ]; then