diff --git a/.github/workflows/staging-build.yml b/.github/workflows/staging-build.yml index 8b59c5512..76c2bcf06 100644 --- a/.github/workflows/staging-build.yml +++ b/.github/workflows/staging-build.yml @@ -256,6 +256,7 @@ jobs: # Seal those directory inodes before this job exercises the same # privileged recipe boundary as publication. - name: Seal conventional host runtime ownership + if: ${{ needs.change-scope.outputs.package_publish_flow_changed == 'true' }} run: >- /usr/bin/python3 scripts/prepare-homebrew-recipe-host-runtime.py @@ -264,6 +265,7 @@ jobs: # reviewed Homebrew source. Give it an immutable source explicitly so # runner-image Homebrew state cannot decide what the test executes. - name: Checkout exact Homebrew lifecycle source + if: ${{ needs.change-scope.outputs.package_publish_flow_changed == 'true' }} uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false @@ -288,12 +290,14 @@ jobs: run: bash scripts/dev-shell.sh true - name: Install JavaScript dependencies for Homebrew preflight + if: ${{ needs.change-scope.outputs.package_publish_flow_changed == 'true' }} run: | bash scripts/dev-shell.sh bash -c ' PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 npm ci --no-audit --no-fund ' - name: Validate Homebrew publisher trust contract + if: ${{ needs.change-scope.outputs.package_publish_flow_changed == 'true' }} run: | bash scripts/dev-shell.sh env \ KANDELO_HOMEBREW_SOURCE_REPOSITORY="$GITHUB_WORKSPACE/homebrew-lifecycle-source" \ diff --git a/docs/superpowers/plans/2026-08-16-scope-homebrew-publisher-suite.md b/docs/superpowers/plans/2026-08-16-scope-homebrew-publisher-suite.md new file mode 100644 index 000000000..415ebae6e --- /dev/null +++ b/docs/superpowers/plans/2026-08-16-scope-homebrew-publisher-suite.md @@ -0,0 +1,89 @@ +# Scope the Homebrew Publisher Suite Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Stop running the complete Homebrew publisher integration suite for pull requests that do not change publisher policy. + +**Architecture:** Reuse the existing `package_publish_flow_changed` output as the single routing authority. Apply one identical GitHub Actions condition to the publisher-only setup and validation steps, and enforce it with mutation-style Ruby contract tests. + +**Tech Stack:** GitHub Actions YAML, Ruby workflow contract tests, Bash verification through `scripts/dev-shell.sh`. + +## Global Constraints + +- Formula, package, ABI, request, candidate, and bottle identities must remain unchanged. +- Mixed changes that touch publisher policy must run the complete suite. +- The pinned Homebrew source and explicit source environment remain mandatory when the suite runs. +- No new path classifier or compatibility fallback is permitted. + +--- + +### Task 1: Enforce publisher-only preflight routing + +**Files:** +- Modify: `scripts/test-homebrew-publisher-lifecycle-source.rb` +- Modify: `scripts/check-homebrew-publish-workflow-trust.rb` +- Modify: `.github/workflows/staging-build.yml` + +**Interfaces:** +- Consumes: `needs.change-scope.outputs.package_publish_flow_changed` +- Produces: four steps with the exact condition `${{ needs.change-scope.outputs.package_publish_flow_changed == 'true' }}` + +- [x] **Step 1: Write the failing workflow contract assertions** + +Require the host-sealing, lifecycle checkout, JavaScript install, and publisher +validation steps to have the exact publisher-flow condition. Add mutations +that remove or change the condition and require rejection. + +- [x] **Step 2: Run the focused contract to verify RED** + +Run: + +```bash +scripts/dev-shell.sh ruby scripts/test-homebrew-publisher-lifecycle-source.rb +``` + +Expected: failure because the four steps are currently unconditional. + +- [x] **Step 3: Add the minimal workflow conditions** + +Set the same `if` expression on exactly the four publisher-only steps. Preserve +their existing command bodies, source pin, and ordering. Update the structural +trust checker so reusable publication jobs still require unconditional host +sealing while staging preflight requires this exact condition. + +- [x] **Step 4: Run focused verification to GREEN** + +Run: + +```bash +scripts/dev-shell.sh ruby scripts/test-homebrew-publisher-lifecycle-source.rb +scripts/dev-shell.sh ruby scripts/check-homebrew-publish-workflow-trust.rb +scripts/dev-shell.sh bash .github/scripts/test-homebrew-main-shell-change-scope.sh +``` + +Expected: all commands exit zero. + +- [x] **Step 5: Verify workflow syntax and scoped diff** + +Run: + +```bash +scripts/dev-shell.sh actionlint .github/workflows/staging-build.yml +git diff --check +git diff --name-only origin/main...HEAD +``` + +Expected: actionlint and diff checks exit zero; only the two docs, workflow, +structural trust checker, and lifecycle-source contract test differ. + +- [x] **Step 6: Commit** + +```bash +git add \ + .github/workflows/staging-build.yml \ + scripts/check-homebrew-publish-workflow-trust.rb \ + scripts/test-homebrew-publisher-lifecycle-source.rb \ + docs/superpowers/specs/2026-08-16-scope-homebrew-publisher-suite-design.md \ + docs/superpowers/plans/2026-08-16-scope-homebrew-publisher-suite.md +git commit -m "[CI] Reserve publisher integration for policy changes" +``` diff --git a/docs/superpowers/specs/2026-08-16-scope-homebrew-publisher-suite-design.md b/docs/superpowers/specs/2026-08-16-scope-homebrew-publisher-suite-design.md new file mode 100644 index 000000000..e55b1994d --- /dev/null +++ b/docs/superpowers/specs/2026-08-16-scope-homebrew-publisher-suite-design.md @@ -0,0 +1,48 @@ +# Scope the Homebrew Publisher Suite + +## Problem + +The `staging-build.yml` preflight runs +`scripts/test-homebrew-publish-workflow.sh` for every ordinary package or +runtime pull request routed through the legacy preflight. The suite exercises +the complete privileged Homebrew publication lifecycle and takes about +17 minutes. Most candidate iterations do not change that lifecycle, so the +cost delays feedback without increasing evidence for the changed code. + +## Decision + +Run the complete publisher suite in staging only when the protected change +scope reports `package_publish_flow_changed == true`. This includes both +publisher-only changes and mixed changes that touch publisher policy. + +Apply the same condition to the four publisher-only setup steps: + +- seal conventional host runtime ownership; +- check out the pinned Homebrew lifecycle source; +- install JavaScript dependencies for the publisher preflight; and +- run the complete publisher trust suite. + +Ordinary package, runtime, and bottle-candidate iterations retain the normal +preflight matrix, xtask build, and relevant tests. The reusable publisher +workflows keep their existing immutable workflow and implementation digests; +this change does not weaken or regenerate those publication identities. + +## Boundaries + +- Do not change Formula source, package manifests, bottle contracts, ABI + identity, request identity, or candidate selection. +- Do not alter `scripts/test-homebrew-publish-workflow.sh`; it remains the + complete publisher-policy regression suite. +- Do not add a second path classifier. Use the existing fail-closed + `package_publish_flow_changed` output. +- Keep the pinned Homebrew checkout and explicit source environment whenever + the suite runs. + +## Validation + +The workflow contract test must reject an unconditional publisher setup or +suite step, reject a different condition, and retain the exact pinned Homebrew +source and explicit environment. The existing workflow trust checker and +change-scope contracts must remain green. A hosted ordinary package PR will be +used later to measure the avoided wall time; local structural tests do not +make a hosted timing claim. diff --git a/scripts/check-homebrew-publish-workflow-trust.rb b/scripts/check-homebrew-publish-workflow-trust.rb index 22102ca77..ad58bc67f 100644 --- a/scripts/check-homebrew-publish-workflow-trust.rb +++ b/scripts/check-homebrew-publish-workflow-trust.rb @@ -30,6 +30,9 @@ REPO_ROOT, "scripts/prepare-homebrew-recipe-host-runtime.py" ) HOST_RUNTIME_PREPARATION_STEP = "Seal conventional host runtime ownership" +PUBLISHER_FLOW_CONDITION = + "${{ needs.change-scope.outputs.package_publish_flow_changed == 'true' }}" +STAGING_PREFLIGHT_LABEL = ".github/workflows/staging-build.yml:preflight" PRIVILEGED_RECIPE_ENTRYPOINTS = %w[ scripts/homebrew-bottle-build.sh scripts/homebrew-verify-poured-bottle.sh @@ -201,11 +204,18 @@ def check_privileged_recipe_host_runtime(workflows) ) actual.each do |label, (steps, privileged_indices)| expected_command = PRIVILEGED_RECIPE_JOBS.fetch(label) + expected_condition = + label == STAGING_PREFLIGHT_LABEL ? PUBLISHER_FLOW_CONDITION : nil preparation_indices = steps.each_index.select do |index| step = steps.fetch(index) + condition_matches = if expected_condition + step["if"] == expected_condition + else + !step.key?("if") + end step["name"] == HOST_RUNTIME_PREPARATION_STEP && step["run"] == expected_command && - !step.key?("if") && + condition_matches && !step.key?("continue-on-error") end check( diff --git a/scripts/test-homebrew-publisher-lifecycle-source.rb b/scripts/test-homebrew-publisher-lifecycle-source.rb index 97fa8c601..663bd8dab 100644 --- a/scripts/test-homebrew-publisher-lifecycle-source.rb +++ b/scripts/test-homebrew-publisher-lifecycle-source.rb @@ -19,6 +19,10 @@ "actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0" SOURCE_STEP = "Checkout exact Homebrew lifecycle source" VALIDATION_STEP = "Validate Homebrew publisher trust contract" +HOST_RUNTIME_STEP = "Seal conventional host runtime ownership" +JAVASCRIPT_STEP = "Install JavaScript dependencies for Homebrew preflight" +PUBLISHER_FLOW_CONDITION = + "${{ needs.change-scope.outputs.package_publish_flow_changed == 'true' }}" def check(condition, message) raise message unless condition @@ -46,12 +50,20 @@ def check_contract(workflow, lifecycle, roots, dev_shell) "statuses" => "write", }, "staging preflight authority changed") steps = preflight.fetch("steps") + host_runtime = named_step(steps, HOST_RUNTIME_STEP) source = named_step(steps, SOURCE_STEP) + javascript = named_step(steps, JAVASCRIPT_STEP) validation = named_step(steps, VALIDATION_STEP) commit = lifecycle_commit(lifecycle) + [host_runtime, source, javascript, validation].each do |step| + check(step.fetch("if") == PUBLISHER_FLOW_CONDITION, + "#{step.fetch("name")} is not scoped to publisher-flow changes") + end + check(source == { "name" => SOURCE_STEP, + "if" => PUBLISHER_FLOW_CONDITION, "uses" => CHECKOUT_ACTION, "with" => { "persist-credentials" => false, @@ -62,7 +74,7 @@ def check_contract(workflow, lifecycle, roots, dev_shell) }, "staging lifecycle source is not the exact read-only checkout") check(steps.index(source) < steps.index(validation), "staging validates the publisher before provisioning its source") - check(validation.keys.sort == %w[name run], + check(validation.keys.sort == %w[if name run], "staging lifecycle validation gained ambient configuration") expected_run = <<~SHELL @@ -109,6 +121,19 @@ def expect_rejection(label) check_contract(workflow, lifecycle, roots, dev_shell) { + "unconditional publisher setup" => lambda { |candidate| + steps = candidate.fetch("jobs").fetch("preflight").fetch("steps") + named_step(steps, HOST_RUNTIME_STEP).delete("if") + }, + "differently scoped publisher setup" => lambda { |candidate| + steps = candidate.fetch("jobs").fetch("preflight").fetch("steps") + named_step(steps, JAVASCRIPT_STEP)["if"] = + "${{ needs.change-scope.outputs.homebrew_publisher_only_changed == 'true' }}" + }, + "unconditional publisher validation" => lambda { |candidate| + steps = candidate.fetch("jobs").fetch("preflight").fetch("steps") + named_step(steps, VALIDATION_STEP).delete("if") + }, "mutable Homebrew source" => lambda { |candidate| named_step( candidate.fetch("jobs").fetch("preflight").fetch("steps"),