diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ee3cb37..7f93131 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/e896983570aa5117a6518003a69a4bb7b1f2ad11/labs/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/c5f3e330d6167247446e1915deaf1bc593cf2e0d/labs/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/UPSTREAM.json b/UPSTREAM.json index 385bc44..f590f9c 100644 --- a/UPSTREAM.json +++ b/UPSTREAM.json @@ -12,7 +12,7 @@ }, "files": { ".gitignore": "a7079e923a776f14f1bb3a6aa0a11a133a8e1dfb35af020f327623357b7e3957", - "CONTRIBUTING.md": "353be923ee17b17d4dcdd1924fd074775399bb98a98fbef3b0f747877ef09fb3", + "CONTRIBUTING.md": "87a58a3120f70e719a3471f4ad2be5673242ef29150a65a5fd6d2b1079ee69f4", "README.md": "125b47671a68556df382f19756fb61fa18925606cbbaf54d6bc9df8872b36870", "assets/boatstack-journey.svg": "e465befc50c8ce30f3e07e8fd97012931beeb053392c8fbf38ad645023b3cc63", "assets/boatstack-mark.svg": "be1f984da1bfa69fa5d1f986d8343d21f7e20921b71db888c928b4d2e54b09b5", @@ -40,17 +40,17 @@ "boatstack/capture_test.go": "63fa1177738081f1e862364d7a4257f5e259f8e9c36276ba1775b8085b277105", "boatstack/changelog.go": "c5e1f31440b44d61e6037ad27af0333540af3545d655e35819a0241cbbebd8ec", "boatstack/changelog_test.go": "ce792f23a7fe1e09fb3096cd1314130a6ab69321d4877b12a8e994027541baf7", - "boatstack/cmd/boatstack-helper/main.go": "5aff755d60b2f5c06dab3ed5d30df026d28c6fb90d891b3be38e955aa200621d", + "boatstack/cmd/boatstack-helper/main.go": "545c2926f0d9ae59ca884c99880ae30188a7ee8456a0253a10c83651f385f47c", "boatstack/cmd/boatstack-helper/main_test.go": "ff73003b6a5157202fa09ddf1129fb13c3d79702b2e05a8721ce5a11bf5ab779", - "boatstack/command.go": "94d2117c6e390d5a644afc5cd90f7e712e3f8b1032f8c9e3b253cc134524c28a", + "boatstack/command.go": "4726ac515dedab4947be7eb48f88c6cb8b53d674124504b69f03e6396b080ee8", "boatstack/command_test.go": "9f707abba3640add81c3e97ba7e72fedbf98f3394b1c060a9ca4b4a28e919968", - "boatstack/config_documentation_test.go": "1fa56768409824afaff61bbe4ae2733ca854a4cbe87254a7954c6aaa2a6d75ac", + "boatstack/config_documentation_test.go": "0632366edc5e88145bb080083ea03c6515da07b0162ce404d63e51bb5bc0774e", "boatstack/decision.go": "257ca328da6ae19ab252f10ee5d06bd7daf49dd8141d083ab1b32f106ea7a94c", "boatstack/decision_test.go": "1a92ff832610f9559bd47ccac7fc1755a8b4f8261c35bc72a092830dff05f7c0", "boatstack/delivery.go": "ea53af0e702ec3668a563a5f786dcac2e095362285ca6093b7ed71ec495a0a48", "boatstack/delivery_test.go": "564ad2029a8412de7953967b1acdf377e6c87b6f6e3465d1f8741a4813f2949f", "boatstack/evidence.go": "497a31e6ff632cb1d7c3adfc9f269af3f6aa84e948dd5d417c162767542a27df", - "boatstack/export.go": "388c676e4fb25442a15e51e31bfc5931b6264d7a95a3422ecec0c1f78ab613d9", + "boatstack/export.go": "f4393643206eaa503dadb1a3f58069e2a3acc9525466334ce674bcffcabc7cc7", "boatstack/export_test.go": "67eb890728d20630925d6e4e90d2a97ec025195ba5c54994c1b098ab72721dca", "boatstack/go.mod": "6086ef1b2a83f5696190dca692c653925f27b61f652f659fd3fca43ed54a1641", "boatstack/go.sum": "26c315c867b11b886f3c9402fce7f341f6a9115a5d61f54afbb5e1b1fb5f6017", @@ -81,7 +81,7 @@ "boatstack/recovery.go": "dd816b18b54a0085b8d8276a93ee98d2b1e90099059a0d85cf6e24edf6f37d5b", "boatstack/recovery_test.go": "29490e7477ba602491330036a491289dd9117b99ff862f66dae421ba17e04c9f", "boatstack/references/artifacts.md": "5fa888ac519085d65cee1d04df5902761651bcf2d7af81711fa0f8ecd1fc0f59", - "boatstack/references/config-schema.md": "4df91e9769125bf21654595a749483b8db7d2a27399db346e9e194d525a328f7", + "boatstack/references/config-schema.md": "0170b90f1d0a592f58e255ffeff642fa037676042443f74a0f1b6e39be5dbbb8", "boatstack/references/failure-moves.md": "34a39aefb282b1b5d9ea387f8536bdc5e0145a240f102ae3d01c7ada6d4abebc", "boatstack/references/host-hook-contracts.md": "d68ae1556e7b1e29e9ac7cb4db767809d510aabf0be52e60e44665ea7abb980e", "boatstack/references/irreversible-operation-boundary.md": "e0076f0fea3bf729b2e9bdf353eaeaaf7cdafabfaf26b8d9b27287e5414c2441", @@ -91,7 +91,7 @@ "boatstack/release_test.go": "5cf2d76fe9b836a91ca68eba53d5585e2c4be5b9421aaf939ea0723063a24690", "boatstack/run.go": "74967ad5b3ed3847baffec1231aae69a81a70f9cce3a9412fa05bcfdc4eca6d1", "boatstack/run_test.go": "5b291510fa90cefdc26eb89e18a3443385456a6ebc73408325ac1945b7c084d6", - "boatstack/runtime.go": "007f38f0631200b448f27f79b8cefb9874dd767b500dc389f2c9a663d0d0f9b0", + "boatstack/runtime.go": "d1e95895002ea2b27199b6e05b33c4c6e20f63455a44f63ca3cfeedecfc23420", "boatstack/runtime_cache.go": "60c4eb0c7dde91d40d6ef3f05adc1a1282d17ff1ca12470d0a008454f7ca7489", "boatstack/runtime_cache_test.go": "b981467ddc9f0f562da6bff5de7a80a9fe5a433a0317541d1e48df268546ac85", "boatstack/safety.go": "e5d91bf219838f5e0c80daaf5b18a2cca9661f211fa60e2c1fab9e5835082b36", @@ -107,6 +107,8 @@ "boatstack/update_test.go": "bf5f19f8499db6dd7356917d867b113b790548ab89bfdea59e2adf4999a82a6a", "boatstack/visual_evidence.go": "4d69f98adb6087d4830e6703273ae6e03b28ec8b3c496a6e432fd5e0e54d8a2f", "boatstack/visual_evidence_test.go": "0fe8f5154ef4398dfeba5e7f7b387b2d279ed75635ea35d93ff392269f2cc6d0", + "boatstack/visual_publisher.go": "330511d000e712fa37c52af17d59c8ec9cb41f49c773a517f856651e66a60d25", + "boatstack/visual_publisher_test.go": "979f600edae00c77569ae753b80530e8cbf2e3b342995efcd992d351ff382eaf", "boatstack/workspace.go": "91b343400b3506a6f516c28fabc3f1575f22024a5b19f934a020be660a20482e", "boatstack/workspace_sync.go": "0cc2f03fd1aa57c66b3d603d5ef30aa820f14ab60771a795cf10c46f3c9a71b8", "boatstack/workspace_sync_test.go": "a5fd532d23a6675c96fc5eb29a149c812717f21237a4050ae5f41afb34601273", @@ -114,11 +116,11 @@ "docs/account-recovery-walkthrough.md": "676034974594a7d1a559b24dbed31d7ccc429eb81404b203ca07bbdaa19ec3d3", "docs/benchmark-corpus-audit.md": "f2d206fe8579a514f9da82b2c96c19b343ac004be67617e1bd34f0f8e0e5e6c6", "docs/benchmark-submission-audit.md": "9518abdd17690729c6423f87cab20418ed47b0915b5faa44b9ef975e9e9c3b79", - "docs/configuration.md": "4d8f207b415a5a1e3b9b1698ee7bb1221aa0e5496a061bb8054294df2f347ad1", - "docs/evidence-engineered-coding.md": "0b362fa9462b207203a985930545d4312d282d80becfa2bb6642afbd4b378cb5", + "docs/configuration.md": "df054f49d532c8b1b7d94184810d1b3b5bf18cdc30eb985b4b6d0639162e341a", + "docs/evidence-engineered-coding.md": "091930ef2e129debd56a13f9465364fb1b30d2bdc09d28cafb99f8bdf372a899", "docs/generated-files.md": "437791765b0a4015032ae21d1a6618563cad92b7402819e4f963bf5ae16284a3", "docs/getting-started.md": "f314270c5ed1a55bbef5f3ddbcb5596693dbee9374e5f0d3df8838cefbd68052", - "docs/public-claims.json": "2777b425b9ee7cef373d178fa97a9aa9558aae95eef40e27ef9bf5e88e091184", + "docs/public-claims.json": "1cd0ea8ce15058bf69e18bef9a8eabcc57d77bf7fb6c05d0ff46b5ba78ca0ff2", "docs/public-surface.md": "713f7a050b5f339cf948299103ef3800417dccfecf2cc1a4166397ea6f978907", "docs/research-and-design.md": "8d78678108f0a6c924e1ff9b32c0f81aae9d1f779e0082843b6f99ad993ae2b6", "docs/safety.md": "7b9b5c515d36e683767ec8d3d9d6d119ac93650b2f629d351deadd4c600ed6a6", @@ -132,7 +134,7 @@ "labs/diagram-json/compiled/evidence.md": "1ba1c989ade070a8ef9a508fbd788d100d7292f2dbacbb2bce895468019f619d", "labs/diagram-json/compiled/tasks.json": "88f60851abf79d851e9fccc754ff3040034ae595306bc87d64784c19eb403e71", "labs/diagram-json/compiled/test-matrix.json": "424657ff505768e50fa113801fd8363364a18269d5297480907a993d44063a39", - "labs/diagram-json/plan.lock.json": "e80e6a008dcba346d40af08f6d4be2bf5fe15241173bac6170f958e0690c7f12", + "labs/diagram-json/plan.lock.json": "1dfe1ea29c712d61940cc77f259f8bb0ecdcd073d6426eb29eda6612cbb645b5", "labs/diagram-json/plan.md": "3cc4f533b8d69386deff16b3a594a3ba09d4c0c3db636cccd8c4380084ce6a51", "labs/diagram-json/questions.md": "74733b015002c8a6777c558e7e997fa48c94850b9bd39054fe9366c97ecf728d", "labs/diagram-json/request.md": "0808fc41c36779c404f4a3a121167da6e76cac56df526e70f9ed6d3e0d4c02ed", @@ -200,14 +202,16 @@ "release-notes/2026-07-23-labkit-publishing-pipeline.md": "b3bfc5f28baf3961fb187380ff66e5383186c05a933bc6ca162416ce61149218", "release-notes/2026-07-23-labkit-standalone-package.md": "50ae9ba89fdada6e04a9200ea26d53a2a04484a1759cdea663687cc9b42fb93c", "release-notes/2026-07-23-managed-pr-task-graph-layout.md": "e2d67f15cc6a1eb200d6f13f81d891b30eae1bd51182d768dda561fcfdc69435", + "release-notes/2026-07-23-programmatic-visual-publisher.md": "d84e94e6c62fb45aff94fd467582a7c1d940c4542cce0021564074c23383fe91", "release-notes/2026-07-23-recoverable-repository-sync.md": "3afc4f6220ae76df3bd6dcd15fc180135274c808729e9c512a2c53462ec690c2", "release-notes/2026-07-23-shipped-feature-candidate-resolution.md": "bd8ee8e7f3f216b356b121a83ef10cb0c8131a90b9ab803edebf23b882d9cf89", - "release-notes/2026-07-23-sync-title-contract.md": "2869d6d084ea60402e57ffe985d0fc4cd83ef9bb09958cc53e349157d3383202" + "release-notes/2026-07-23-sync-title-contract.md": "2869d6d084ea60402e57ffe985d0fc4cd83ef9bb09958cc53e349157d3383202", + "release-notes/2026-07-23-visual-evidence-external-host.md": "09edbe5e6e1bfc866cf5ee744a5001d678f7a67f0f330cf43bd5157eedf04276" }, "generator": "operatorstack/intelligence-flow:boatstack-distribution", "schema_version": 1, "source": { - "commit": "e896983570aa5117a6518003a69a4bb7b1f2ad11", + "commit": "c5f3e330d6167247446e1915deaf1bc593cf2e0d", "path": "labs/12-product-engineering-loop", "repository": "operatorstack/intelligence-flow" } diff --git a/boatstack/cmd/boatstack-helper/main.go b/boatstack/cmd/boatstack-helper/main.go index 863a275..146e01c 100644 --- a/boatstack/cmd/boatstack-helper/main.go +++ b/boatstack/cmd/boatstack-helper/main.go @@ -931,6 +931,7 @@ func publishPRCommand(arguments []string) int { } url, err := boatstack.PublishPR(boatstack.PRPublishOptions{ Repo: *repo, PreviewPath: *previewPath, ExpectedFingerprint: *fingerprint, Action: *action, + VisualPublisher: boatstack.SelectVisualPublisher(*repo), }) if err != nil { return fail(err) diff --git a/boatstack/command.go b/boatstack/command.go index d2e8804..b14128c 100644 --- a/boatstack/command.go +++ b/boatstack/command.go @@ -3,6 +3,7 @@ package boatstack import ( "bytes" "fmt" + "os" "os/exec" "strings" ) @@ -47,3 +48,17 @@ func commandOutput(repo string, name string, arguments ...string) (string, error } return strings.TrimSpace(string(channels.Stdout)), nil } + +// commandOutputEnv is commandOutput with additional environment variables appended +// to the inherited environment. It keeps the same stdout-is-the-only-authority +// contract; extra entries are ordinary NAME=VALUE strings. +func commandOutputEnv(repo string, extraEnv []string, name string, arguments ...string) (string, error) { + command := exec.Command(name, arguments...) + command.Dir = repo + command.Env = append(os.Environ(), extraEnv...) + channels, err := runCommandChannels(command) + if err != nil { + return "", commandFailure(channels, err) + } + return strings.TrimSpace(string(channels.Stdout)), nil +} diff --git a/boatstack/config_documentation_test.go b/boatstack/config_documentation_test.go index 59f3547..d8838bd 100644 --- a/boatstack/config_documentation_test.go +++ b/boatstack/config_documentation_test.go @@ -124,6 +124,9 @@ func TestPublicConfigurationGuideContainsOnlySupportedUserControls(t *testing.T) "workflow.independent_review_for_high_risk", "workflow.maintain_changelog", "workflow.pr_visual_evidence", + "workflow.visual_evidence_publish.expiry", + "workflow.visual_evidence_publish.host", + "workflow.visual_evidence_publish.mode", "workspace.cleanup", "workspace.cleanup_after", "workspace.enabled", diff --git a/boatstack/export.go b/boatstack/export.go index 80f924c..8bf4671 100644 --- a/boatstack/export.go +++ b/boatstack/export.go @@ -126,6 +126,30 @@ func ValidateConfig(config ProjectConfig) error { if policy := strings.TrimSpace(config.Workflow.PRVisualEvidence); policy != "" && policy != "off" && policy != "suggest" && policy != "require" { return fmt.Errorf("workflow.pr_visual_evidence must be \"off\", \"suggest\", or \"require\"") } + if err := validateVisualEvidencePublish(config.Workflow.VisualEvidencePublish); err != nil { + return err + } + return nil +} + +// validateVisualEvidencePublish rejects only explicit invalid enum values. A nil +// block or empty fields are legal and resolve to defaults at use, so configs written +// before this block existed remain valid. +func validateVisualEvidencePublish(publish *VisualEvidencePublish) error { + if publish == nil { + return nil + } + if mode := publish.Mode; mode != "" && mode != "external-host" { + return fmt.Errorf("workflow.visual_evidence_publish.mode must be \"external-host\" when set") + } + if host := publish.Host; host != "" && host != "litterbox" && host != "catbox" { + return fmt.Errorf("workflow.visual_evidence_publish.host must be \"litterbox\" or \"catbox\"") + } + switch publish.Expiry { + case "", "1h", "12h", "24h", "72h": + default: + return fmt.Errorf("workflow.visual_evidence_publish.expiry must be one of \"1h\", \"12h\", \"24h\", \"72h\"") + } return nil } diff --git a/boatstack/references/config-schema.md b/boatstack/references/config-schema.md index f443a55..8de2e54 100644 --- a/boatstack/references/config-schema.md +++ b/boatstack/references/config-schema.md @@ -15,6 +15,10 @@ boatstack-config-field:workflow.allow_pass_with_gaps boatstack-config-field:workflow.maintain_changelog boatstack-config-field:workflow.boundary_analysis boatstack-config-field:workflow.pr_visual_evidence +boatstack-config-field:workflow.visual_evidence_publish +boatstack-config-field:workflow.visual_evidence_publish.mode +boatstack-config-field:workflow.visual_evidence_publish.host +boatstack-config-field:workflow.visual_evidence_publish.expiry boatstack-config-field:workflow.ignored_deliveries boatstack-config-field:workspace boatstack-config-field:workspace.enabled @@ -66,6 +70,10 @@ This is the exhaustive serialization contract, not a list of recommended user ed - `maintain_changelog` (boolean, optional): Whether a reader-visible `CHANGELOG.md` entry is required for each delivery slice. - `boundary_analysis` (boolean, optional): Agent-mediated planning guidance that presents local repair versus programmatic enforcement as a material product decision. - `pr_visual_evidence` (string, optional): `off`, `suggest`, or `require`. Omission is `off`. Relevant PRs use machine-local PNG evidence without committing media to Git; `suggest` records missing evidence as a visible gap and `require` blocks completed publication. +- `visual_evidence_publish` (object, optional): Agent-mediated publish control for how captured PNG bytes reach the pull-request comment. Omission keeps the default: commit the bytes to a public Boatstack-owned evidence branch and render them inline, but only for a **public** GitHub origin (a private origin falls back to manual attachment). Fields: + - `mode` (string, optional): `external-host` opts the repository — including a **private** one — into uploading the exact PNG bytes to an anonymous expiring host so the comment renders inline anywhere. It is **never auto-selected** because it publishes screenshot bytes to a third party; only this explicit value turns it on. Empty keeps the default public-branch behavior. + - `host` (string, optional): `litterbox` (default) or `catbox`. Only meaningful when `mode` is `external-host`. `litterbox` auto-expires uploads; `catbox` is permanent. + - `expiry` (string, optional): `1h`, `12h`, `24h`, or `72h` (default `72h`). Only meaningful for an expiring host; the PR comment reminds reviewers of the host and this window. - `ignored_deliveries` (array of strings, optional): Deterministic ambiguity control. Feature slugs of past deliveries to exclude from delivery-ambiguity resolution so historical work no longer blocks new work. New, unlisted ambiguous deliveries still pause the workflow. ### workspace Fields diff --git a/boatstack/runtime.go b/boatstack/runtime.go index b1a2947..30db7e7 100644 --- a/boatstack/runtime.go +++ b/boatstack/runtime.go @@ -53,12 +53,30 @@ type Workflow struct { MaintainChangelog bool `json:"maintain_changelog"` BoundaryAnalysis bool `json:"boundary_analysis,omitempty"` PRVisualEvidence string `json:"pr_visual_evidence,omitempty"` + // VisualEvidencePublish selects how programmatic visual evidence reaches a PR. + // The nil zero value keeps Boatstack's default: commit the exact PNG bytes to a + // public Boatstack-owned evidence branch and render them inline, but only for a + // PUBLIC GitHub origin (a private origin falls back to manual attachment). + // Setting mode to "external-host" opts a repository — including a private one — + // into uploading the bytes to an anonymous expiring host so the comment renders + // inline anywhere; it is never auto-selected because it publishes screenshot + // bytes to a third party. + VisualEvidencePublish *VisualEvidencePublish `json:"visual_evidence_publish,omitempty"` // IgnoredDeliveries lists feature slugs of past deliveries to exclude from // delivery-ambiguity resolution. New, unlisted ambiguous deliveries still // pause the workflow. Persisted via the LoadConfig -> GeneratedJSON round-trip. IgnoredDeliveries []string `json:"ignored_deliveries,omitempty"` } +// VisualEvidencePublish configures the opt-in external-host publish mode. The empty +// zero value of each field resolves to a default at use, so a partially-specified +// block (mode only) still works. +type VisualEvidencePublish struct { + Mode string `json:"mode,omitempty"` // "" (default public-branch) | "external-host" + Host string `json:"host,omitempty"` // external-host only: "litterbox" (default) | "catbox" + Expiry string `json:"expiry,omitempty"` // expiring host only: "1h" | "12h" | "24h" | "72h" (default "72h") +} + type IntegrationState struct { Requested bool `json:"requested"` Status string `json:"status,omitempty"` diff --git a/boatstack/visual_publisher.go b/boatstack/visual_publisher.go new file mode 100644 index 0000000..fb9dd2e --- /dev/null +++ b/boatstack/visual_publisher.go @@ -0,0 +1,448 @@ +package boatstack + +import ( + "bytes" + "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" +) + +// GitVisualEvidencePublisher publishes fingerprinted PNG bytes to a pull request +// without a signed-in host browser or a manual drag-drop. GitHub exposes no public +// API that mints user-attachments CDN URLs, so instead of uploading through the web +// UI this publisher commits the exact bytes to a dedicated, Boatstack-owned evidence +// branch on origin and references them from one Boatstack-owned PR comment via +// immutable raw.githubusercontent.com URLs pinned to the commit SHA. +// +// The approach only renders for public repositories: raw.githubusercontent.com does +// not serve private content to anonymous markdown renderers. SelectVisualPublisher +// therefore declines to return this publisher for a non-public origin, leaving the +// existing manual-attachment fallback in place rather than emitting broken images. +type GitVisualEvidencePublisher struct{} + +var ( + prNumberPattern = regexp.MustCompile(`/pull/(\d+)`) + commentIDPattern = regexp.MustCompile(`issuecomment-(\d+)`) + originSlugSSH = regexp.MustCompile(`^git@github\.com:([^/]+)/(.+?)(?:\.git)?$`) + originSlugHTTP = regexp.MustCompile(`^https?://github\.com/([^/]+)/(.+?)(?:\.git)?$`) +) + +// SelectVisualPublisher returns a programmatic publisher when the repository can +// actually render committed evidence — a GitHub origin with gh available and +// authenticated. The default publisher renders inline only for a PUBLIC repository +// (raw.githubusercontent.com does not serve private content to anonymous markdown +// renderers). A repository may opt into the external-host publisher via +// workflow.visual_evidence_publish.mode="external-host" to render inline even when +// private. Otherwise it returns nil so the caller's manual-attachment fallback stays +// in force (which never blocks a suggest-policy PR). +func SelectVisualPublisher(repo string) PRVisualEvidencePublisher { + resolved, err := ResolveRepository(repo) + if err != nil { + return nil + } + if err := ghAvailable(resolved); err != nil { + return nil + } + if _, _, err := originRepoSlug(resolved); err != nil { + return nil + } + // External-host mode is opt-in only: it publishes screenshot bytes to a + // third-party anonymous host, so it is never auto-selected — only this explicit + // config value turns it on, and it works for a private origin too. + if publish := visualPublishConfig(resolved); publish != nil && publish.Mode == "external-host" { + return ExternalHostVisualEvidencePublisher{Host: publish.Host, Expiry: publish.Expiry} + } + visibility, err := commandOutput(resolved, "gh", "repo", "view", "--json", "visibility", "--jq", ".visibility") + if err != nil || !strings.EqualFold(strings.TrimSpace(visibility), "public") { + return nil + } + return GitVisualEvidencePublisher{} +} + +// visualPublishConfig reads the repository's visual-evidence publish preferences from +// the generated project config, returning nil when the config is absent, unreadable, +// or leaves the block unset so the caller falls back to the default public-branch +// behavior. +func visualPublishConfig(repo string) *VisualEvidencePublish { + config, _, err := LoadConfig(filepath.Join(repo, ".product-loop", "project.json")) + if err != nil { + return nil + } + return config.Workflow.VisualEvidencePublish +} + +// PublishVisualEvidence commits the manifest's exact PNG bytes to the evidence +// branch, then posts or updates the single Boatstack-owned comment on the PR. +func (GitVisualEvidencePublisher) PublishVisualEvidence(repo, prURL, existingCommentURL string, manifest PRVisualEvidenceManifest) (string, error) { + resolved, err := ResolveRepository(repo) + if err != nil { + return "", err + } + owner, name, err := originRepoSlug(resolved) + if err != nil { + return "", err + } + prNumber, err := prNumberFromURL(prURL) + if err != nil { + return "", err + } + if len(manifest.Items) == 0 { + return "", fmt.Errorf("visual evidence has no screenshots to publish") + } + commitSHA, err := pushEvidenceCommit(resolved, manifest) + if err != nil { + return "", err + } + body := composeVisualEvidenceComment(owner, name, commitSHA, manifest) + return upsertEvidenceComment(resolved, owner, name, prNumber, existingCommentURL, manifest.Key, body) +} + +// pushEvidenceCommit builds a commit carrying the exact PNG bytes with Git plumbing +// against a temporary index — never touching the working tree — and pushes it to the +// Boatstack-owned evidence branch, accumulating onto the branch's prior tip when one +// exists. It returns the commit SHA, which pins immutable raw content URLs. +func pushEvidenceCommit(repo string, manifest PRVisualEvidenceManifest) (string, error) { + branch, err := evidenceBranchName(manifest.Key) + if err != nil { + return "", err + } + indexFile, err := os.CreateTemp("", "boatstack-evidence-index-*") + if err != nil { + return "", err + } + indexPath := indexFile.Name() + indexFile.Close() + os.Remove(indexPath) // git wants to create the index itself + defer os.Remove(indexPath) + git := func(arguments ...string) (string, error) { + return gitIndexedCommand(repo, indexPath, arguments...) + } + + var parent string + if tip := strings.TrimSpace(gitOutput(repo, "ls-remote", "origin", "refs/heads/"+branch)); tip != "" { + parent = strings.Fields(tip)[0] + if _, err := commandOutput(repo, "git", "-C", repo, "fetch", "origin", branch); err != nil { + return "", fmt.Errorf("cannot fetch the visual-evidence branch to extend it: %w", err) + } + if _, err := git("read-tree", parent); err != nil { + return "", err + } + } + for _, item := range manifest.Items { + blob, err := git("hash-object", "-w", "--", item.Path) + if err != nil { + return "", err + } + path := evidenceBlobPath(manifest.Key, item.SHA256) + if _, err := git("update-index", "--add", "--cacheinfo", "100644,"+strings.TrimSpace(blob)+","+path); err != nil { + return "", err + } + } + tree, err := git("write-tree") + if err != nil { + return "", err + } + tree = strings.TrimSpace(tree) + message := "boatstack: visual evidence for " + manifest.Key + " (" + manifest.Fingerprint + ")" + commitArgs := []string{"commit-tree", tree, "-m", message} + if parent != "" { + commitArgs = append(commitArgs, "-p", parent) + } + commit, err := git(commitArgs...) + if err != nil { + return "", err + } + commit = strings.TrimSpace(commit) + if _, err := commandOutput(repo, "git", "-C", repo, "push", "origin", commit+":refs/heads/"+branch); err != nil { + return "", fmt.Errorf("cannot push the visual-evidence commit without rewriting history: %w", err) + } + return commit, nil +} + +// upsertEvidenceComment posts the composed body to exactly one Boatstack-owned +// comment: it reuses the recorded comment when known, otherwise finds the prior +// comment by its hidden marker so a lost URL never orphans a duplicate. +func upsertEvidenceComment(repo, owner, name, prNumber, existingCommentURL, key, body string) (string, error) { + commentID := commentIDFromURL(existingCommentURL) + if commentID == "" { + marker := visualEvidenceCommentMarker(key) + found, err := commandOutput(repo, "gh", "api", "--paginate", + fmt.Sprintf("repos/%s/%s/issues/%s/comments", owner, name, prNumber), + "--jq", `.[] | select(.body | contains("`+marker+`")) | .id`) + if err != nil { + return "", err + } + if lines := strings.Fields(found); len(lines) > 0 { + commentID = lines[0] + } + } + bodyFile, err := os.CreateTemp("", "boatstack-evidence-comment-*.md") + if err != nil { + return "", err + } + bodyPath := bodyFile.Name() + defer os.Remove(bodyPath) + if _, err := bodyFile.WriteString(body); err != nil { + bodyFile.Close() + return "", err + } + if err := bodyFile.Close(); err != nil { + return "", err + } + if commentID != "" { + return commandOutput(repo, "gh", "api", "--method", "PATCH", + fmt.Sprintf("repos/%s/%s/issues/comments/%s", owner, name, commentID), + "-F", "body=@"+bodyPath, "--jq", ".html_url") + } + return commandOutput(repo, "gh", "api", "--method", "POST", + fmt.Sprintf("repos/%s/%s/issues/%s/comments", owner, name, prNumber), + "-F", "body=@"+bodyPath, "--jq", ".html_url") +} + +// gitIndexedCommand runs git against a scoped, temporary index so evidence commits +// never disturb the repository's real index or working tree. +func gitIndexedCommand(repo, indexPath string, arguments ...string) (string, error) { + return commandOutputEnv(repo, []string{"GIT_INDEX_FILE=" + indexPath}, "git", append([]string{"-C", repo}, arguments...)...) +} + +func originRepoSlug(repo string) (string, string, error) { + remote, err := commandOutput(repo, "git", "-C", repo, "remote", "get-url", "origin") + if err != nil { + return "", "", err + } + remote = strings.TrimSpace(remote) + for _, pattern := range []*regexp.Regexp{originSlugSSH, originSlugHTTP} { + if match := pattern.FindStringSubmatch(remote); match != nil { + return match[1], strings.TrimSuffix(match[2], "/"), nil + } + } + return "", "", fmt.Errorf("origin %q is not a recognizable GitHub repository", remote) +} + +func prNumberFromURL(prURL string) (string, error) { + if match := prNumberPattern.FindStringSubmatch(prURL); match != nil { + return match[1], nil + } + return "", fmt.Errorf("cannot determine the pull-request number from %q", prURL) +} + +func commentIDFromURL(commentURL string) string { + if match := commentIDPattern.FindStringSubmatch(commentURL); match != nil { + return match[1] + } + return "" +} + +func evidenceBranchName(key string) (string, error) { + safe, err := safeCacheSegment(key, "visual evidence key") + if err != nil { + return "", err + } + return "boatstack-visual-evidence/" + safe, nil +} + +func evidenceBlobPath(key, sha string) string { + return key + "/" + sha + ".png" +} + +func rawContentURL(owner, name, commitSHA, path string) string { + return "https://raw.githubusercontent.com/" + owner + "/" + name + "/" + commitSHA + "/" + path +} + +func visualEvidenceCommentMarker(key string) string { + return "" +} + +// composeVisualEvidenceComment renders the single Boatstack-owned comment: a hidden +// marker for idempotent reuse, the trust fingerprints, the standing public-repository +// privacy warning, and one image per scenario pinned to the evidence commit. +func composeVisualEvidenceComment(owner, name, commitSHA string, manifest PRVisualEvidenceManifest) string { + itemsByScenario := make(map[string]PRVisualEvidenceItem, len(manifest.Items)) + for _, item := range manifest.Items { + itemsByScenario[item.ScenarioID] = item + } + var builder strings.Builder + builder.WriteString(visualEvidenceCommentMarker(manifest.Key) + "\n") + builder.WriteString("### Visual evidence\n\n") + builder.WriteString("Screenshots are human-review evidence, not mechanical proof. These images are committed to a public branch and are publicly accessible.\n\n") + builder.WriteString(fmt.Sprintf("Source commit `%s` · product diff `%s` · fingerprint `%s`\n\n", manifest.SourceCommit, manifest.ProductDiffSHA256, manifest.Fingerprint)) + rendered := 0 + for _, scenario := range manifest.Scenarios { + item, ok := itemsByScenario[scenario.ID] + if !ok { + continue + } + caption := strings.Join(scenario.Expected, "; ") + builder.WriteString(fmt.Sprintf("**%s** — %s (`%s`)\n\n", scenario.ID, caption, scenario.Viewport)) + url := rawContentURL(owner, name, commitSHA, evidenceBlobPath(manifest.Key, item.SHA256)) + builder.WriteString(fmt.Sprintf("![%s](%s)\n\n", scenario.ID, url)) + rendered++ + } + if rendered == 0 { + builder.WriteString("_No captured scenarios to display._\n") + } + return builder.String() +} + +// externalHostSpec describes an anonymous image host used by external-host mode. +type externalHostSpec struct { + endpoint string + label string + withExpiry bool // the host auto-deletes uploads after a caller-chosen window +} + +// visualExternalHosts enumerates the supported anonymous hosts. Both accept the same +// multipart form (reqtype=fileupload, fileToUpload=) and return the hosted URL +// as plain text — a URL GitHub's camo proxy can fetch unauthenticated so the comment +// renders inline. It is a var, not a const table, so tests can point an endpoint at a +// local httptest server. +var visualExternalHosts = map[string]externalHostSpec{ + "litterbox": {endpoint: "https://litterbox.catbox.moe/resources/internals/api.php", label: "litter.catbox.moe", withExpiry: true}, + "catbox": {endpoint: "https://catbox.moe/user/api.php", label: "files.catbox.moe", withExpiry: false}, +} + +const ( + defaultExternalHost = "litterbox" + defaultExternalExpiry = "72h" +) + +// ExternalHostVisualEvidencePublisher renders visual evidence inline on ANY repo — +// including a private one — by uploading the exact PNG bytes to an anonymous expiring +// host whose returned URL GitHub's camo proxy fetches unauthenticated. It is opt-in +// only (workflow.visual_evidence_publish.mode="external-host") because it publishes +// screenshot bytes to a third party; the comment carries a standing reminder naming +// the host and its expiry so reviewers know the images are external and temporary. +type ExternalHostVisualEvidencePublisher struct { + Host string + Expiry string +} + +// PublishVisualEvidence uploads the manifest's exact PNG bytes to the configured +// anonymous host, then posts or updates the single Boatstack-owned comment on the PR +// with inline images and the standing hosting reminder. +func (p ExternalHostVisualEvidencePublisher) PublishVisualEvidence(repo, prURL, existingCommentURL string, manifest PRVisualEvidenceManifest) (string, error) { + resolved, err := ResolveRepository(repo) + if err != nil { + return "", err + } + owner, name, err := originRepoSlug(resolved) + if err != nil { + return "", err + } + prNumber, err := prNumberFromURL(prURL) + if err != nil { + return "", err + } + if len(manifest.Items) == 0 { + return "", fmt.Errorf("visual evidence has no screenshots to publish") + } + host := strings.TrimSpace(p.Host) + if host == "" { + host = defaultExternalHost + } + spec, ok := visualExternalHosts[host] + if !ok { + return "", fmt.Errorf("unknown external evidence host %q", host) + } + expiry := strings.TrimSpace(p.Expiry) + if expiry == "" { + expiry = defaultExternalExpiry + } + urls := make(map[string]string, len(manifest.Items)) + for _, item := range manifest.Items { + url, err := uploadToExternalHost(spec, expiry, item.Path) + if err != nil { + return "", err + } + urls[item.ScenarioID] = url + } + body := composeExternalHostComment(spec, expiry, urls, manifest) + return upsertEvidenceComment(resolved, owner, name, prNumber, existingCommentURL, manifest.Key, body) +} + +// uploadToExternalHost POSTs one PNG to an anonymous host and returns the hosted URL. +// The host answers with the URL as plain text; any non-200 or non-URL body is a +// failure so the caller can fix forward without emitting a broken image. +func uploadToExternalHost(spec externalHostSpec, expiry, pngPath string) (string, error) { + contents, err := os.ReadFile(pngPath) + if err != nil { + return "", err + } + var payload bytes.Buffer + form := multipart.NewWriter(&payload) + if err := form.WriteField("reqtype", "fileupload"); err != nil { + return "", err + } + if spec.withExpiry { + if err := form.WriteField("time", expiry); err != nil { + return "", err + } + } + part, err := form.CreateFormFile("fileToUpload", filepath.Base(pngPath)) + if err != nil { + return "", err + } + if _, err := part.Write(contents); err != nil { + return "", err + } + if err := form.Close(); err != nil { + return "", err + } + request, err := http.NewRequest(http.MethodPost, spec.endpoint, &payload) + if err != nil { + return "", err + } + request.Header.Set("Content-Type", form.FormDataContentType()) + response, err := http.DefaultClient.Do(request) + if err != nil { + return "", err + } + defer response.Body.Close() + raw, err := io.ReadAll(response.Body) + if err != nil { + return "", err + } + url := strings.TrimSpace(string(raw)) + if response.StatusCode != http.StatusOK || !strings.HasPrefix(url, "http") { + return "", fmt.Errorf("upload to %s failed (HTTP %d): %s", spec.label, response.StatusCode, boundedObservation(url)) + } + return url, nil +} + +// composeExternalHostComment renders the single Boatstack-owned comment for +// external-host mode: the idempotency marker, the trust fingerprints, one inline +// image per scenario pinned to its hosted URL, and a standing reminder naming the +// third-party host and (when the host expires uploads) the expiry window. +func composeExternalHostComment(spec externalHostSpec, expiry string, urls map[string]string, manifest PRVisualEvidenceManifest) string { + var builder strings.Builder + builder.WriteString(visualEvidenceCommentMarker(manifest.Key) + "\n") + builder.WriteString("### Visual evidence\n\n") + builder.WriteString("Screenshots are human-review evidence, not mechanical proof.\n\n") + builder.WriteString(fmt.Sprintf("Source commit `%s` · product diff `%s` · fingerprint `%s`\n\n", manifest.SourceCommit, manifest.ProductDiffSHA256, manifest.Fingerprint)) + rendered := 0 + for _, scenario := range manifest.Scenarios { + url, ok := urls[scenario.ID] + if !ok { + continue + } + caption := strings.Join(scenario.Expected, "; ") + builder.WriteString(fmt.Sprintf("**%s** — %s (`%s`)\n\n", scenario.ID, caption, scenario.Viewport)) + builder.WriteString(fmt.Sprintf("![%s](%s)\n\n", scenario.ID, url)) + rendered++ + } + if rendered == 0 { + builder.WriteString("_No captured scenarios to display._\n") + } + builder.WriteString("---\n\n") + if spec.withExpiry { + builder.WriteString(fmt.Sprintf("📌 These images are hosted on **%s** and auto-expire in **%s** — merge or re-run before then. They are uploaded to a third-party anonymous host, so do not use this mode for sensitive screenshots.\n", spec.label, expiry)) + } else { + builder.WriteString(fmt.Sprintf("📌 These images are hosted on **%s** (permanent, public). They are uploaded to a third-party anonymous host, so do not use this mode for sensitive screenshots.\n", spec.label)) + } + return builder.String() +} diff --git a/boatstack/visual_publisher_test.go b/boatstack/visual_publisher_test.go new file mode 100644 index 0000000..8b1fa4e --- /dev/null +++ b/boatstack/visual_publisher_test.go @@ -0,0 +1,329 @@ +package boatstack + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestOriginRepoSlugParsesSSHAndHTTPS(t *testing.T) { + repo := t.TempDir() + runGit(t, repo, "init", "-b", "main") + cases := map[string]struct{ owner, name string }{ + "git@github.com:millennialcpa/taxweave.git": {"millennialcpa", "taxweave"}, + "https://github.com/operatorstack/boatstack.git": {"operatorstack", "boatstack"}, + "https://github.com/operatorstack/boatstack": {"operatorstack", "boatstack"}, + } + runGit(t, repo, "remote", "add", "origin", "https://github.com/placeholder/placeholder.git") + for remote, want := range cases { + runGit(t, repo, "remote", "set-url", "origin", remote) + owner, name, err := originRepoSlug(repo) + if err != nil { + t.Fatalf("%s: %v", remote, err) + } + if owner != want.owner || name != want.name { + t.Fatalf("%s: got %s/%s, want %s/%s", remote, owner, name, want.owner, want.name) + } + } +} + +func TestOriginRepoSlugRejectsNonGitHub(t *testing.T) { + repo := t.TempDir() + runGit(t, repo, "init", "-b", "main") + runGit(t, repo, "remote", "add", "origin", "https://gitlab.com/acme/widget.git") + if _, _, err := originRepoSlug(repo); err == nil { + t.Fatal("non-GitHub origin should be rejected") + } +} + +func TestPRNumberFromURL(t *testing.T) { + got, err := prNumberFromURL("https://github.com/operatorstack/boatstack/pull/143") + if err != nil || got != "143" { + t.Fatalf("got %q, %v", got, err) + } + if _, err := prNumberFromURL("https://github.com/operatorstack/boatstack"); err == nil { + t.Fatal("a URL without a pull number should error") + } +} + +func TestCommentIDFromURL(t *testing.T) { + if got := commentIDFromURL("https://github.com/o/n/pull/143#issuecomment-987654"); got != "987654" { + t.Fatalf("got %q", got) + } + if got := commentIDFromURL(""); got != "" { + t.Fatalf("empty URL should yield no id, got %q", got) + } +} + +func TestEvidenceBranchNameRejectsUnsafeKey(t *testing.T) { + if _, err := evidenceBranchName("../escape"); err == nil { + t.Fatal("path-traversal key must be rejected") + } + branch, err := evidenceBranchName("firm-status") + if err != nil || branch != "boatstack-visual-evidence/firm-status" { + t.Fatalf("got %q, %v", branch, err) + } +} + +func TestRawContentURLPinsCommit(t *testing.T) { + url := rawContentURL("o", "n", "abc123", evidenceBlobPath("firm-status", "deadbeef")) + want := "https://raw.githubusercontent.com/o/n/abc123/firm-status/deadbeef.png" + if url != want { + t.Fatalf("got %q, want %q", url, want) + } +} + +func TestComposeVisualEvidenceCommentRendersScenariosAndWarnings(t *testing.T) { + manifest := PRVisualEvidenceManifest{ + Key: "firm-status", + SourceCommit: "src123", + ProductDiffSHA256: "diff456", + Fingerprint: "fp789", + Scenarios: []PRVisualScenario{ + {ID: "VS-1", Entry: "/clients", State: "hover", Viewport: "1440x900", Expected: []string{"portal card is blue"}}, + {ID: "VS-2", Entry: "/clients", State: "default", Viewport: "1440x900", Expected: []string{"amber badge"}}, + }, + Items: []PRVisualEvidenceItem{ + {ScenarioID: "VS-1", SHA256: "hash1"}, + {ScenarioID: "VS-2", SHA256: "hash2"}, + }, + } + body := composeVisualEvidenceComment("o", "n", "commitSHA", manifest) + + if !strings.HasPrefix(body, visualEvidenceCommentMarker("firm-status")) { + t.Fatal("comment must open with the idempotency marker") + } + for _, want := range []string{ + "publicly accessible", // standing privacy warning + "human-review evidence", // not-mechanical-proof warning + "src123", "diff456", "fp789", // trust fingerprints + "VS-1", "VS-2", // both scenarios + "portal card is blue", "amber badge", + rawContentURL("o", "n", "commitSHA", "firm-status/hash1.png"), + rawContentURL("o", "n", "commitSHA", "firm-status/hash2.png"), + } { + if !strings.Contains(body, want) { + t.Fatalf("comment body missing %q\n---\n%s", want, body) + } + } +} + +func TestComposeVisualEvidenceCommentSkipsUncapturedScenarios(t *testing.T) { + manifest := PRVisualEvidenceManifest{ + Key: "firm-status", + Scenarios: []PRVisualScenario{ + {ID: "VS-1", Viewport: "1440x900", Expected: []string{"x"}}, + }, + // No items → nothing captured. + } + body := composeVisualEvidenceComment("o", "n", "c", manifest) + if strings.Contains(body, "![VS-1]") { + t.Fatal("uncaptured scenario must not render an image") + } + if !strings.Contains(body, "No captured scenarios") { + t.Fatal("expected an explicit empty-state note") + } +} + +func TestUploadToExternalHostPostsFormAndReturnsURL(t *testing.T) { + dir := t.TempDir() + png := filepath.Join(dir, "VS-1.png") + writeTestPNG(t, png) + + var gotReqtype, gotTime, gotFilename string + var gotBytes int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotReqtype = r.FormValue("reqtype") + gotTime = r.FormValue("time") + file, header, err := r.FormFile("fileToUpload") + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + defer file.Close() + gotFilename = header.Filename + contents := make([]byte, 4096) + n, _ := file.Read(contents) + gotBytes = n + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("https://litter.catbox.moe/abc123.png\n")) + })) + defer server.Close() + + spec := externalHostSpec{endpoint: server.URL, label: "litter.catbox.moe", withExpiry: true} + url, err := uploadToExternalHost(spec, "24h", png) + if err != nil { + t.Fatalf("upload: %v", err) + } + if url != "https://litter.catbox.moe/abc123.png" { + t.Fatalf("unexpected URL (trailing newline not trimmed?): %q", url) + } + if gotReqtype != "fileupload" { + t.Fatalf("reqtype = %q, want fileupload", gotReqtype) + } + if gotTime != "24h" { + t.Fatalf("expiry field = %q, want 24h", gotTime) + } + if gotFilename != "VS-1.png" { + t.Fatalf("filename = %q, want VS-1.png", gotFilename) + } + if gotBytes == 0 { + t.Fatal("no PNG bytes reached the host") + } +} + +func TestUploadToExternalHostOmitsTimeForPermanentHost(t *testing.T) { + dir := t.TempDir() + png := filepath.Join(dir, "VS-1.png") + writeTestPNG(t, png) + + sawTimeField := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.FormValue("time") != "" { + sawTimeField = true + } + _, _ = w.Write([]byte("https://files.catbox.moe/xyz.png")) + })) + defer server.Close() + + spec := externalHostSpec{endpoint: server.URL, label: "files.catbox.moe", withExpiry: false} + if _, err := uploadToExternalHost(spec, "72h", png); err != nil { + t.Fatalf("upload: %v", err) + } + if sawTimeField { + t.Fatal("a permanent host must not receive an expiry time field") + } +} + +func TestUploadToExternalHostRejectsNonURLResponse(t *testing.T) { + dir := t.TempDir() + png := filepath.Join(dir, "VS-1.png") + writeTestPNG(t, png) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("something broke")) + })) + defer server.Close() + + spec := externalHostSpec{endpoint: server.URL, label: "litter.catbox.moe", withExpiry: true} + if _, err := uploadToExternalHost(spec, "24h", png); err == nil { + t.Fatal("a non-200 / non-URL response must be a failure, not a broken image") + } +} + +func TestComposeExternalHostCommentRendersInlineWithExpiryReminder(t *testing.T) { + manifest := PRVisualEvidenceManifest{ + Key: "firm-status", + SourceCommit: "src123", + ProductDiffSHA256: "diff456", + Fingerprint: "fp789", + Scenarios: []PRVisualScenario{ + {ID: "VS-1", Viewport: "1440x900", Expected: []string{"portal card is blue"}}, + {ID: "VS-2", Viewport: "1440x900", Expected: []string{"amber badge"}}, + }, + Items: []PRVisualEvidenceItem{{ScenarioID: "VS-1"}, {ScenarioID: "VS-2"}}, + } + urls := map[string]string{ + "VS-1": "https://litter.catbox.moe/a.png", + "VS-2": "https://litter.catbox.moe/b.png", + } + spec := externalHostSpec{label: "litter.catbox.moe", withExpiry: true} + body := composeExternalHostComment(spec, "24h", urls, manifest) + + if !strings.HasPrefix(body, visualEvidenceCommentMarker("firm-status")) { + t.Fatal("comment must open with the idempotency marker") + } + for _, want := range []string{ + "src123", "diff456", "fp789", // trust fingerprints + "![VS-1](https://litter.catbox.moe/a.png)", // inline, not a click-through link + "![VS-2](https://litter.catbox.moe/b.png)", + "litter.catbox.moe", // host named + "24h", // expiry window named + "third-party", // standing privacy reminder + "human-review evidence", // not-mechanical-proof caveat + } { + if !strings.Contains(body, want) { + t.Fatalf("comment body missing %q\n---\n%s", want, body) + } + } +} + +func TestComposeExternalHostCommentPermanentHostOmitsExpiry(t *testing.T) { + manifest := PRVisualEvidenceManifest{ + Key: "firm-status", + Scenarios: []PRVisualScenario{{ID: "VS-1", Viewport: "1440x900", Expected: []string{"x"}}}, + Items: []PRVisualEvidenceItem{{ScenarioID: "VS-1"}}, + } + urls := map[string]string{"VS-1": "https://files.catbox.moe/a.png"} + spec := externalHostSpec{label: "files.catbox.moe", withExpiry: false} + body := composeExternalHostComment(spec, "72h", urls, manifest) + if strings.Contains(body, "auto-expire") { + t.Fatal("a permanent host must not claim an expiry window") + } + if !strings.Contains(body, "permanent") || !strings.Contains(body, "third-party") { + t.Fatalf("permanent host still needs its standing reminder\n---\n%s", body) + } +} + +func TestExternalHostPublishVisualEvidenceUploadsAndUpserts(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("fake gh relies on a POSIX shell script on PATH") + } + repo := visualTestRepo(t) + runGit(t, repo, "remote", "add", "origin", "https://github.com/o/n.git") + + png := filepath.Join(repo, "VS-1.png") + writeTestPNG(t, png) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if _, _, err := r.FormFile("fileToUpload"); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + _, _ = w.Write([]byte("https://litter.catbox.moe/hosted.png")) + })) + defer server.Close() + + original := visualExternalHosts["litterbox"] + visualExternalHosts["litterbox"] = externalHostSpec{endpoint: server.URL, label: "litter.catbox.moe", withExpiry: true} + defer func() { visualExternalHosts["litterbox"] = original }() + + fakeDir := t.TempDir() + script := filepath.Join(fakeDir, "gh") + scriptBody := `#!/bin/sh +if [ "$1" = "api" ]; then + for a in "$@"; do + if [ "$a" = "POST" ]; then echo "https://github.com/o/n/pull/9#issuecomment-555"; exit 0; fi + if [ "$a" = "PATCH" ]; then echo "https://github.com/o/n/pull/9#issuecomment-555"; exit 0; fi + done + exit 0 +fi +exit 1 +` + if err := os.WriteFile(script, []byte(scriptBody), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", fakeDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + manifest := PRVisualEvidenceManifest{ + Key: "firm-status", + SourceCommit: "src", + ProductDiffSHA256: "diff", + Fingerprint: "fp", + Scenarios: []PRVisualScenario{{ID: "VS-1", Viewport: "1440x900", Expected: []string{"blue"}}}, + Items: []PRVisualEvidenceItem{{ScenarioID: "VS-1", Path: png}}, + } + publisher := ExternalHostVisualEvidencePublisher{Host: "litterbox", Expiry: "24h"} + commentURL, err := publisher.PublishVisualEvidence(repo, "https://github.com/o/n/pull/9", "", manifest) + if err != nil { + t.Fatalf("publish: %v", err) + } + if commentURL != "https://github.com/o/n/pull/9#issuecomment-555" { + t.Fatalf("unexpected comment URL: %s", commentURL) + } +} diff --git a/docs/configuration.md b/docs/configuration.md index 1af7698..309898f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -11,6 +11,9 @@ boatstack-user-config-field:workflow.allow_pass_with_gaps boatstack-user-config-field:workflow.maintain_changelog boatstack-user-config-field:workflow.boundary_analysis boatstack-user-config-field:workflow.pr_visual_evidence +boatstack-user-config-field:workflow.visual_evidence_publish.mode +boatstack-user-config-field:workflow.visual_evidence_publish.host +boatstack-user-config-field:workflow.visual_evidence_publish.expiry boatstack-user-config-field:workflow.ignored_deliveries boatstack-user-config-field:workspace.enabled boatstack-user-config-field:workspace.mode @@ -35,6 +38,7 @@ Boatstack's installer owns the complete `.boatstack-project.json` shape. Edit on | Maintain reader-facing history | `workflow.maintain_changelog` | Managed delivery and Boatstack-prepared PRs require a categorized `CHANGELOG.md` entry. | | Check for a systemic boundary | `workflow.boundary_analysis` | Planning guidance asks whether the request is a local symptom before scope expands. | | Add frontend PR screenshots | `workflow.pr_visual_evidence` | `suggest` exposes missing screenshots as a gap; `require` blocks completed publication. | +| Render screenshots inline on a private PR | `workflow.visual_evidence_publish.*` | `mode: external-host` uploads the captured PNGs to an anonymous expiring host so the comment renders inline even on a private repo; opt-in, never automatic. | | Ignore old ambiguous deliveries | `workflow.ignored_deliveries` | Listed feature slugs are excluded from delivery-ambiguity resolution so past work stops blocking new work; new, unlisted ambiguous deliveries still pause. | | Use fresh feature workspaces | `workspace.*` | Boatstack creates and cleans branches or linked worktrees under the selected policy. | | Limit generated host surfaces | `adapters` | Export generates only the selected supported adapters. | @@ -95,6 +99,22 @@ Changelog enforcement is mechanical. Boundary analysis is model-mediated plannin Visual-evidence values are `off`, `suggest`, and `require`. Screenshot bytes stay outside Git history until explicitly attached to the PR. +By default Boatstack can publish those screenshots inline only for a **public** repository (it commits the bytes to a Boatstack-owned public branch and renders them from an immutable raw URL). On a **private** repository GitHub cannot fetch those bytes for the comment, so it falls back to manual attachment. To render inline on a private repository, opt into the external-host mode: + +```json +{ + "workflow": { + "visual_evidence_publish": { + "mode": "external-host", + "host": "litterbox", + "expiry": "72h" + } + } +} +``` + +`mode: external-host` uploads the exact captured PNG bytes to an anonymous host (`litterbox`, which auto-expires uploads after `expiry` — one of `1h`, `12h`, `24h`, `72h`; or `catbox`, permanent) and posts the returned URLs inline. It is **never automatic** — only this explicit value turns it on — because the bytes leave your repository to a third party. The PR comment carries a standing reminder naming the host and expiry, so do not use this mode for sensitive screenshots. + ```json { "workflow": { diff --git a/docs/evidence-engineered-coding.md b/docs/evidence-engineered-coding.md index a1d8661..12caf7c 100644 --- a/docs/evidence-engineered-coding.md +++ b/docs/evidence-engineered-coding.md @@ -146,6 +146,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 [`e896983570aa5117a6518003a69a4bb7b1f2ad11`](https://github.com/operatorstack/intelligence-flow/tree/e896983570aa5117a6518003a69a4bb7b1f2ad11/labs/12-product-engineering-loop). +The current moves were derived from the Intelligence Flow benchmark corpus and product-repository studies. The generated source commit is [`c5f3e330d6167247446e1915deaf1bc593cf2e0d`](https://github.com/operatorstack/intelligence-flow/tree/c5f3e330d6167247446e1915deaf1bc593cf2e0d/labs/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/public-claims.json b/docs/public-claims.json index fffd29d..f1f4fa6 100644 --- a/docs/public-claims.json +++ b/docs/public-claims.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "source_commit": "e896983570aa5117a6518003a69a4bb7b1f2ad11", + "source_commit": "c5f3e330d6167247446e1915deaf1bc593cf2e0d", "statuses": ["verified", "observed", "still_being_evaluated"], "claims": [ { @@ -12,7 +12,7 @@ "readable_evidence": "why-these-steps.md#portable-workflow-and-state", "implementation": ["../boatstack/export.go", "../boatstack/references/artifacts.md", "../boatstack/references/workflow.md"], "verification": ["../boatstack/export_test.go"], - "last_verified_version": "source:e896983570aa5117a6518003a69a4bb7b1f2ad11" + "last_verified_version": "source:c5f3e330d6167247446e1915deaf1bc593cf2e0d" }, { "id": "human-decisions", @@ -23,7 +23,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:e896983570aa5117a6518003a69a4bb7b1f2ad11" + "last_verified_version": "source:c5f3e330d6167247446e1915deaf1bc593cf2e0d" }, { "id": "validation-provenance", @@ -34,7 +34,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:e896983570aa5117a6518003a69a4bb7b1f2ad11" + "last_verified_version": "source:c5f3e330d6167247446e1915deaf1bc593cf2e0d" }, { "id": "irreversible-operations", @@ -46,7 +46,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:e896983570aa5117a6518003a69a4bb7b1f2ad11" + "last_verified_version": "source:c5f3e330d6167247446e1915deaf1bc593cf2e0d" }, { "id": "reviewer-ready-pr", @@ -57,7 +57,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:e896983570aa5117a6518003a69a4bb7b1f2ad11" + "last_verified_version": "source:c5f3e330d6167247446e1915deaf1bc593cf2e0d" }, { "id": "phase-scoped-delivery", @@ -68,7 +68,7 @@ "readable_evidence": "why-these-steps.md#phase-scoped-delivery", "implementation": ["../boatstack/delivery.go", "../boatstack/safety.go", "../boatstack/hooks.go", "../boatstack/references/workflow.md"], "verification": ["../boatstack/delivery_test.go", "../boatstack/pr_test.go"], - "last_verified_version": "source:e896983570aa5117a6518003a69a4bb7b1f2ad11" + "last_verified_version": "source:c5f3e330d6167247446e1915deaf1bc593cf2e0d" }, { "id": "model-neutral-contract", @@ -79,7 +79,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:e896983570aa5117a6518003a69a4bb7b1f2ad11" + "last_verified_version": "source:c5f3e330d6167247446e1915deaf1bc593cf2e0d" }, { "id": "cross-model-failures", @@ -90,7 +90,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:e896983570aa5117a6518003a69a4bb7b1f2ad11" + "last_verified_version": "source:c5f3e330d6167247446e1915deaf1bc593cf2e0d" }, { "id": "lower-cost-outcomes", @@ -101,7 +101,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:e896983570aa5117a6518003a69a4bb7b1f2ad11" + "last_verified_version": "source:c5f3e330d6167247446e1915deaf1bc593cf2e0d" }, { "id": "git-worktree-activation", @@ -112,7 +112,7 @@ "readable_evidence": "why-these-steps.md#git-worktree-activation", "implementation": ["../boatstack/runtime_cache.go", "../boatstack/hooks.go"], "verification": ["../boatstack/runtime_cache_test.go", "../boatstack/hooks_test.go"], - "last_verified_version": "source:e896983570aa5117a6518003a69a4bb7b1f2ad11" + "last_verified_version": "source:c5f3e330d6167247446e1915deaf1bc593cf2e0d" }, { "id": "visible-updates", @@ -123,7 +123,7 @@ "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:e896983570aa5117a6518003a69a4bb7b1f2ad11" + "last_verified_version": "source:c5f3e330d6167247446e1915deaf1bc593cf2e0d" } ] } diff --git a/labs/diagram-json/plan.lock.json b/labs/diagram-json/plan.lock.json index 41155f1..bc0fa12 100644 --- a/labs/diagram-json/plan.lock.json +++ b/labs/diagram-json/plan.lock.json @@ -6,7 +6,7 @@ "plan_path": "labs/diagram-json/plan.md", "plan_sha256": "3cc4f533b8d69386deff16b3a594a3ba09d4c0c3db636cccd8c4380084ce6a51", "schema_version": 1, - "source_commit": "e896983570aa5117a6518003a69a4bb7b1f2ad11", + "source_commit": "c5f3e330d6167247446e1915deaf1bc593cf2e0d", "source_plan_path": "labs/diagram-json/source-plan.md", "source_plan_sha256": "e10593ddaa7522ab80cc991d0a09399257139799e37f737794cd49d68a39985b", "spec_path": "labs/diagram-json/spec.md", diff --git a/release-notes/2026-07-23-programmatic-visual-publisher.md b/release-notes/2026-07-23-programmatic-visual-publisher.md new file mode 100644 index 0000000..a574e1f --- /dev/null +++ b/release-notes/2026-07-23-programmatic-visual-publisher.md @@ -0,0 +1,23 @@ +### Boatstack can attach visual evidence to a pull request on its own + +Until now, captured screenshots reached a pull request only through a signed-in host +browser or a manual drag-drop — Boatstack recorded the evidence locally but left the +last step to a human. GitHub exposes no public API that mints its user-attachments +CDN URLs, so there was a real browserless gap. + +`publish-pr` now closes it for public repositories. When it opens or updates a PR, +Boatstack commits the exact captured PNG bytes to a dedicated, Boatstack-owned +evidence branch on `origin` (built with Git plumbing against a temporary index, so it +never disturbs the working tree), then posts or updates one Boatstack-owned comment +that renders each scenario from an immutable `raw.githubusercontent.com` URL pinned to +the evidence commit. The comment carries the same trust fingerprints as the manifest +— source commit, product diff, and manifest fingerprint — and repeats the standing +warning that public-branch screenshots are publicly accessible. + +The publisher is idempotent: a recorded comment is reused, and a lost comment URL is +recovered by a hidden marker so an update never orphans a duplicate. It engages only +when it can actually render — a GitHub origin, `gh` authenticated, and a public +repository. For a private or non-GitHub repository the existing manual-attachment +fallback stays in force, so a suggest-policy PR is never blocked by a limitation +Boatstack cannot overcome. On any publication failure the flow fixes forward: the PR +is preserved and the manifest records the pending state instead of losing evidence. diff --git a/release-notes/2026-07-23-visual-evidence-external-host.md b/release-notes/2026-07-23-visual-evidence-external-host.md new file mode 100644 index 0000000..ee459ed --- /dev/null +++ b/release-notes/2026-07-23-visual-evidence-external-host.md @@ -0,0 +1,20 @@ +### Boatstack can render visual evidence inline on a private pull request + +The programmatic visual-evidence publisher could render screenshots inline only for a +public repository, because it committed the bytes to a public branch and served them +from `raw.githubusercontent.com` — a URL GitHub's image proxy cannot fetch for private +content. On a private repository the publisher declined and left the manual-attachment +fallback in force, so a private PR never got inline screenshots automatically. + +A new opt-in mode closes that gap. Set `workflow.visual_evidence_publish.mode` to +`external-host` and Boatstack uploads the exact captured PNG bytes to an anonymous host +(`litterbox`, which auto-expires uploads after a chosen `1h`/`12h`/`24h`/`72h` window, +or permanent `catbox`) and posts the returned URLs inline in the same single, +idempotent Boatstack-owned comment — on a private repository too. + +Because the bytes leave the repository to a third party, the mode is never selected +automatically: only that explicit config value turns it on, and the comment carries a +standing reminder naming the host and its expiry so reviewers know the images are +external and temporary. The default behavior is unchanged — a public repository still +gets durable inline evidence from a Boatstack-owned public branch, and any repository +without the opt-in keeps the manual-attachment fallback.