diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 719a525..8b24126 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -22,6 +22,11 @@ env: POETRY_VERSION: "2.4.1" FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" TRUSTED_RELEASE_ACTORS: "vstack-release-bot[bot],eschaar" + HOMEBREW_TAP_ENABLED: "false" + HOMEBREW_TAP_NAME: "eschaar/vstack" + HOMEBREW_TAP_REPOSITORY: "eschaar/homebrew-vstack" + HOMEBREW_FORMULA_NAME: "vstack" + HOMEBREW_FULLY_QUALIFIED_FORMULA: "eschaar/vstack/vstack" jobs: publish: @@ -140,3 +145,140 @@ jobs: echo "ERROR: trusted publishing failed and secret PYPI_API_TOKEN is not configured." echo "Either fix PyPI trusted publisher mapping or add PYPI_API_TOKEN as a fallback." exit 1 + + publish-homebrew: + name: Update Homebrew Tap + needs: publish + if: needs.publish.result == 'success' && github.event.release.prerelease == false && env.HOMEBREW_TAP_ENABLED == 'true' + runs-on: ubuntu-latest + environment: pypi + env: + HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + HOMEBREW_TAP_DISPATCH_SECRET: ${{ secrets.HOMEBREW_TAP_DISPATCH_SECRET }} + + steps: + - name: Validate release actor + # Restrict dispatch to trusted release automation/maintainers only. + shell: bash + run: | + ACTOR="${{ github.event.release.author.login }}" + IFS=',' read -r -a ALLOWED <<< "$TRUSTED_RELEASE_ACTORS" + + TRUSTED=false + for allowed_actor in "${ALLOWED[@]}"; do + if [[ "$ACTOR" == "$allowed_actor" ]]; then + TRUSTED=true + break + fi + done + + if [[ "$TRUSTED" != "true" ]]; then + echo "ERROR: release created by '$ACTOR' which is not in the trusted actor list." + echo "Allowed: $TRUSTED_RELEASE_ACTORS" + exit 1 + fi + + - name: Verify Homebrew tap dispatch configuration + shell: bash + run: | + if [[ -z "$HOMEBREW_TAP_TOKEN" ]]; then + echo "ERROR: HOMEBREW_TAP_TOKEN is not configured in the pypi environment." + exit 1 + fi + + - name: Fetch sdist metadata and verify checksum + id: verify_sdist + shell: bash + run: | + python - <<'PY' + import hashlib + import json + import os + import sys + import urllib.request + + version = os.environ["GITHUB_REF_NAME"] + pypi_json_url = f"https://pypi.org/pypi/vstack/{version}/json" + + with urllib.request.urlopen(pypi_json_url) as response: + metadata = json.load(response) + + sdist = next((item for item in metadata.get("urls", []) if item.get("packagetype") == "sdist"), None) + if sdist is None: + print("ERROR: no sdist artifact found in PyPI metadata for the release version.", file=sys.stderr) + sys.exit(1) + + sdist_url = sdist["url"] + pypi_sha = sdist.get("digests", {}).get("sha256", "") + if not pypi_sha: + print("ERROR: PyPI metadata did not provide an sdist sha256 digest.", file=sys.stderr) + sys.exit(1) + + with urllib.request.urlopen(sdist_url) as response: + content = response.read() + + local_sha = hashlib.sha256(content).hexdigest() + if local_sha != pypi_sha: + print("ERROR: sha256 mismatch between PyPI metadata and downloaded tarball.", file=sys.stderr) + sys.exit(1) + + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: + output.write(f"sdist_url={sdist_url}\n") + output.write(f"sdist_sha256={local_sha}\n") + PY + env: + GITHUB_REF_NAME: ${{ github.event.release.tag_name }} + + - name: Dispatch formula update to Homebrew tap + shell: bash + env: + RELEASE_VERSION: ${{ github.event.release.tag_name }} + SDIST_URL: ${{ steps.verify_sdist.outputs.sdist_url }} + SDIST_SHA256: ${{ steps.verify_sdist.outputs.sdist_sha256 }} + run: | + python - <<'PY' + import hmac + import json + import os + from hashlib import sha256 + + payload = { + "version": os.environ["RELEASE_VERSION"], + "sdist_url": os.environ["SDIST_URL"], + "sha256": os.environ["SDIST_SHA256"], + } + + secret = os.environ.get("HOMEBREW_TAP_DISPATCH_SECRET", "") + if secret: + canonical = json.dumps(payload, separators=(",", ":"), sort_keys=True) + payload["signature"] = hmac.new(secret.encode("utf-8"), canonical.encode("utf-8"), sha256).hexdigest() + + request_body = { + "event_type": "update-formula", + "client_payload": payload, + } + + with open("dispatch-body.json", "w", encoding="utf-8") as output: + json.dump(request_body, output, separators=(",", ":")) + PY + + curl -fsS -X POST \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${HOMEBREW_TAP_TOKEN}" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "https://api.github.com/repos/${HOMEBREW_TAP_REPOSITORY}/dispatches" \ + --data-binary @dispatch-body.json + + - name: Publish Homebrew install UX summary + shell: bash + run: | + { + echo "## Homebrew install UX" + echo + echo "- First-time users (private tap): \`brew tap ${HOMEBREW_TAP_NAME} && brew install ${HOMEBREW_FORMULA_NAME}\`" + echo "- Returning users (tap already configured): \`brew install ${HOMEBREW_FORMULA_NAME}\`" + echo "- Fully-qualified one-liner (no prior tap): \`brew install ${HOMEBREW_FULLY_QUALIFIED_FORMULA}\`" + echo + echo "Plain \`brew install ${HOMEBREW_FORMULA_NAME}\` without tapping cannot be guaranteed while distribution is private-tap only." + echo "That UX becomes universal only after acceptance into Homebrew/homebrew-core." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/CHANGELOG.md b/CHANGELOG.md index eda1d74..7f57c16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ +## [3.5.0](https://github.com/eschaar/vstack/compare/3.4.2...3.5.0) (2026-06-02) + + +### Features + +* **ci:** add homebrew private tap publish job ([feature/publish_in_homebrew](https://github.com/eschaar/vstack/tree/feature/publish_in_homebrew)) + + +### Documentation + +* **architecture:** add ADR-030 and homebrew distribution plan +* **cicd:** extend workflow table and sequence diagram with homebrew tap stage +* **design:** update workflow.md publish.yml description +* **product:** add FR-8 homebrew distribution requirement; align roadmap + + +### Tests + +* **ci:** add publish workflow contract tests for homebrew job + ## [3.4.2](https://github.com/eschaar/vstack/compare/3.4.1...3.4.2) (2026-05-28) diff --git a/docs/architecture/adr/030-homebrew-private-tap-distribution.md b/docs/architecture/adr/030-homebrew-private-tap-distribution.md new file mode 100644 index 0000000..c3fe0a9 --- /dev/null +++ b/docs/architecture/adr/030-homebrew-private-tap-distribution.md @@ -0,0 +1,89 @@ +# ADR-030: Homebrew Distribution via Private Tap + +> Maintained by: **architect** role + +**date:** 2026-06-02\ +**status:** accepted + +## context + +vstack is currently distributed exclusively via PyPI (`pipx install vstack`). This +requires users to have a working Python environment and awareness of pip or pipx. + +macOS and Linux users who prefer to manage installed CLI tools through Homebrew face +unnecessary friction. A second distribution channel eliminates that friction and +broadens the addressable install surface without altering the PyPI release process. + +Two Homebrew distribution paths exist: publish to the community-managed +`Homebrew/homebrew-core` repository, or maintain a private tap +(`github.com/eschaar/homebrew-vstack`). + +## decision + +Distribute vstack via a **private Homebrew tap** now. Evaluate submission to +`Homebrew/homebrew-core` after the project accumulates stable release history and +demonstrable adoption. + +The formula uses the **sdist tarball** published to PyPI as its source artifact. +Homebrew's `Language::Python::Virtualenv` mixin installs the sdist into an isolated +virtualenv alongside declared resource blocks for runtime dependencies. The sdist URL +and SHA-256 are sourced from the PyPI JSON API at publish time, not hardcoded. + +The Homebrew publish step is a **third, sequential stage** in the existing release +pipeline, triggered only after the PyPI publish job succeeds and only for non-pre-release +tags. + +Supply-chain controls are mandatory from day one: + +- Dual SHA-256 verification: PyPI JSON metadata is cross-checked against the locally + downloaded tarball before the formula is updated. +- A dedicated fine-grained PAT with `contents: write` scope limited to the tap repo + is stored as `HOMEBREW_TAP_TOKEN` in the `pypi` Actions environment. +- The tap repo's `formula-update.yml` is triggered via `repository_dispatch`; direct + external triggering is not accepted. +- All Actions in both the publish workflow and the tap repo workflows are pinned to + full commit SHAs. +- Branch protection on the tap repo's `main` branch requires the `test.yml` formula + check to pass before any commit lands. + +## alternatives considered + +- **Submit directly to `homebrew-core`** — requires 30-day PyPI history, notable + adoption, and maintainer-reviewed PRs for every version bump. vstack does not yet + meet the acceptance bar. Release autonomy would be lost. +- **Use the wheel instead of the sdist** — Homebrew's virtualenv mixin expects a source + distribution. Using a wheel is non-standard for formula authoring and bypasses the + compile-from-source path that Homebrew prefers for purity. +- **Maintain no Homebrew distribution** — leaves a friction gap for users who manage + CLI tools exclusively through Homebrew. The private tap adds minimal ongoing + maintenance cost given the automated formula update pipeline. + +## rationale + +A private tap provides immediate installation convenience with full release autonomy. +The formula update is fully automated through the existing `publish.yml` pipeline. +The supply-chain threat surface is narrow: a single sdist tarball with dual-verified +SHA-256, a scoped PAT, and a protected tap branch. The incremental maintenance cost +is low: a single additional CI job and a small tap repository. + +Homebrew-core submission is kept as an explicit optional path. When the project meets +the acceptance criteria, the formula is already in shape for submission — the private +tap formula and the homebrew-core formula are structurally identical. + +## impact + +- **Release pipeline**: `publish.yml` gains a `publish-homebrew` job that runs after + the `publish` job, conditioned on `github.event.release.prerelease == false`. +- **New repository**: `github.com/eschaar/homebrew-vstack` with `Formula/vstack.rb`, + `formula-update.yml`, and `test.yml`. +- **Secrets**: `HOMEBREW_TAP_TOKEN` added to the `pypi` Actions environment. +- **NFR**: A new supply-chain NFR (NFR-8) binds the dual-verify requirement and + pre-release exclusion to the Homebrew publish stage. +- **User-facing**: install instructions in `README.md` after bootstrap is validated. +- **PyPI remains primary**: PyPI is the canonical release artifact; Homebrew wraps it. + No change to the PyPI publish path. + +## impact on future orchestrated pipeline + +The Homebrew stage is release-pipeline infrastructure, not part of the vstack agent +workflow. It does not affect the planner-orchestrated role pipeline (ADR-024, ADR-029). diff --git a/docs/architecture/homebrew-publish-plan.md b/docs/architecture/homebrew-publish-plan.md new file mode 100644 index 0000000..719485c --- /dev/null +++ b/docs/architecture/homebrew-publish-plan.md @@ -0,0 +1,374 @@ +# Homebrew Distribution Plan + +> Maintained by: **architect** role\ +> Created: 2026-06-02\ +> Status: **accepted** (ADR-030) + +## objective + +Extend vstack's distribution to Homebrew so that macOS and Linux users can install the CLI +with `brew install` without requiring Python or pip awareness. PyPI remains the primary +source of truth for the package; Homebrew wraps it inside an isolated virtualenv. + +______________________________________________________________________ + +## decision: private tap vs homebrew-core + +| Factor | Private tap | homebrew-core | +|---|---|---| +| Setup time | Hours | Days–weeks (PR review) | +| Release automation | Full control | Maintainers must submit bump PRs or use bots | +| Acceptance bar | None | 30-day PyPI history, notable adoption, strict criteria | +| Install UX | `brew tap eschaar/vstack && brew install vstack` | `brew install vstack` | +| Formula ownership | Maintainer-owned | Homebrew project | +| Update autonomy | Immediate | Subject to Homebrew review cycles | +| Blast radius on mistake | Isolated tap repo | Homebrew-core infrastructure | + +**Recommendation: private tap now, homebrew-core later.** + +Start with a private tap (`github.com/eschaar/homebrew-vstack`). Automate formula updates +from the existing publish pipeline. Graduate to homebrew-core only when the project has +demonstrated stable release cadence, broad adoption, and the formula is well-tested. + +______________________________________________________________________ + +## end-to-end release architecture + +The current release pipeline has two stages: + +1. `release.yml` — merge to `main` triggers release-please; on release PR merge, creates + the SemVer tag and GitHub Release. +2. `publish.yml` — triggered on `release: published`; builds and publishes the wheel and + sdist to PyPI. + +Homebrew publishing inserts as a third, sequential stage after PyPI publish succeeds. + +```mermaid +sequenceDiagram + participant M as main branch + participant RP as release-please (release.yml) + participant GHR as GitHub Release + participant PY as publish.yml (PyPI job) + participant HB as publish.yml (homebrew job) + participant TAP as homebrew-vstack tap repo + participant U as end user + + M->>RP: push to main + RP->>GHR: create release + tag vX.Y.Z + GHR->>PY: release published event + PY->>PY: build wheel + sdist + PY->>PY: publish to PyPI + PY->>HB: job completes (needs: publish) + HB->>HB: fetch sdist from PyPI, compute sha256 + HB->>TAP: repository_dispatch update-formula + TAP->>TAP: update Formula/vstack.rb (version + sha256) + TAP->>TAP: commit and push to main + U->>TAP: brew tap eschaar/vstack (first-time only) + U->>TAP: brew install vstack + TAP->>U: install from formula (virtualenv wrapping sdist) +``` + + Install UX constraints in this phase: + + - The private tap path supports plain `brew install vstack` after a one-time `brew tap eschaar/vstack`. + - The fully-qualified fallback `brew install eschaar/vstack/vstack` remains valid without a prior tap. + - Universal plain `brew install vstack` for users who never tapped requires formula acceptance in `Homebrew/homebrew-core`. + +______________________________________________________________________ + +## required repositories and artifacts + +### tap repo + +- Repository: `github.com/eschaar/homebrew-vstack` +- Formula path: `Formula/vstack.rb` +- Branch: `main` +- Naming convention: `homebrew-` is required by Homebrew's tap resolution. + +### source artifact + +- Use the **sdist tarball** published to PyPI (`vstack-X.Y.Z.tar.gz`). +- PyPI is the canonical download URL embedded in the formula; Homebrew fetches from there + at install time. +- URL pattern: `https://files.pythonhosted.org/packages/source/v/vstack/vstack-X.Y.Z.tar.gz` + +### checksum + +- SHA-256 of the sdist tarball. Homebrew rejects the install if the checksum does not match. +- Computed at publish time from the tarball downloaded from PyPI JSON API + (`https://pypi.org/pypi/vstack/X.Y.Z/json`). + +### version and tag constraints + +- Only plain SemVer `X.Y.Z` tags trigger publishing (already enforced in `publish.yml`). +- Pre-releases (`github.event.release.prerelease == true`) must be excluded; add the same + guard already used in the PyPI job to the homebrew job. + +### formula structure (reference) + +```ruby +class Vstack < Formula + include Language::Python::Virtualenv + + desc "VS Code-native AI engineering workflow system" + homepage "https://github.com/eschaar/vstack" + url "https://files.pythonhosted.org/packages/source/v/vstack/vstack-X.Y.Z.tar.gz" + sha256 "" + license "MIT" + head "https://github.com/eschaar/vstack.git", branch: "main" + + depends_on "python@3.11" + + resource "pyyaml" do + url "https://files.pythonhosted.org/packages/source/P/PyYAML/PyYAML-6.0.2.tar.gz" + sha256 "" + end + + def install + virtualenv_install_with_resources + end + + test do + assert_match "vstack", shell_output("#{bin}/vstack --version") + system bin/"vstack", "--help" + end +end +``` + +Dependencies listed in the `resource` blocks must track `pyproject.toml`'s runtime +`dependencies`. Currently only `pyyaml>=6.0` is required. + +______________________________________________________________________ + +## security and supply-chain controls + +### trusted actors + +- The homebrew publish job runs only after the PyPI job succeeds in the same workflow run. +- Apply the same `TRUSTED_RELEASE_ACTORS` actor check already used in `publish.yml`. +- The `repository_dispatch` event sent to the tap repo must carry a `sha256` value computed + by the workflow, not user-supplied; the tap workflow must not accept version or sha256 as + user input from an untrusted source. + +### token scope (least privilege) + +- Create a dedicated fine-grained PAT or a scoped GitHub App installation token with + `contents: write` on `homebrew-vstack` only. +- Store as `HOMEBREW_TAP_TOKEN` in the `pypi` Actions environment (same environment + already used for PyPI). +- Do not reuse the release-please app token; that token has write access to this repo. +- The tap repo workflow (`formula-update.yml`) must require the `repository_dispatch` + event to carry a matching secret or HMAC header to prevent external triggering. + +### checksum verification + +1. The publish workflow fetches the sdist tarball from PyPI. +2. It computes `sha256sum` locally and also reads the sha256 from the PyPI JSON API. +3. The two values must match before the workflow proceeds. +4. The tap formula embeds the verified sha256; Homebrew verifies it again at install time. + +### supply-chain hardening + +- Pin all third-party GitHub Actions in the homebrew job and tap repo workflows to full + commit SHAs (matching the practice in `release.yml` and `publish.yml`). +- No `curl | bash` patterns in any workflow step; use Actions or `brew` directly. +- The tap repo should have branch protection on `main`: require status checks to pass + before commits land (the formula test workflow acts as the gate). + +______________________________________________________________________ + +## CI/CD workflow changes + +### this repo (`publish.yml`) + +Add a second job `publish-homebrew` in `publish.yml`: + +```yaml +publish-homebrew: + name: Update Homebrew Tap + needs: publish # runs after PyPI publish succeeds + if: github.event.release.prerelease == false + runs-on: ubuntu-latest + environment: pypi # same environment; HOMEBREW_TAP_TOKEN stored here + + steps: + - name: Validate release actor + # reuse same actor check as the publish job + + - name: Fetch sdist from PyPI and verify checksum + shell: bash + run: | + VERSION="${{ github.event.release.tag_name }}" + PYPI_JSON=$(curl -fsSL "https://pypi.org/pypi/vstack/${VERSION}/json") + SDIST_URL=$(echo "$PYPI_JSON" | jq -r \ + '.urls[] | select(.packagetype=="sdist") | .url') + PYPI_SHA=$(echo "$PYPI_JSON" | jq -r \ + '.urls[] | select(.packagetype=="sdist") | .digests.sha256') + curl -fsSL "$SDIST_URL" -o vstack.tar.gz + LOCAL_SHA=$(sha256sum vstack.tar.gz | awk '{print $1}') + if [[ "$LOCAL_SHA" != "$PYPI_SHA" ]]; then + echo "ERROR: sha256 mismatch between PyPI metadata and downloaded tarball." + exit 1 + fi + echo "sdist_url=$SDIST_URL" >> "$GITHUB_OUTPUT" + echo "sdist_sha256=$LOCAL_SHA" >> "$GITHUB_OUTPUT" + + - name: Dispatch formula update to tap repo + uses: peter-evans/repository-dispatch@ + with: + token: ${{ secrets.HOMEBREW_TAP_TOKEN }} + repository: eschaar/homebrew-vstack + event-type: update-formula + client-payload: | + { + "version": "${{ github.event.release.tag_name }}", + "sdist_url": "${{ steps.verify.outputs.sdist_url }}", + "sha256": "${{ steps.verify.outputs.sdist_sha256 }}" + } +``` + +### tap repo (`homebrew-vstack`) + +#### `formula-update.yml` + +```yaml +on: + repository_dispatch: + types: [update-formula] + +jobs: + update: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@ + - name: Update formula version and sha256 + shell: bash + run: | + VERSION="${{ github.event.client_payload.version }}" + SHA256="${{ github.event.client_payload.sha256 }}" + URL="${{ github.event.client_payload.sdist_url }}" + sed -i "s|url \".*\"|url \"${URL}\"|" Formula/vstack.rb + sed -i "s|sha256 \".*\"|sha256 \"${SHA256}\"|" Formula/vstack.rb + - name: Commit and push + run: | + git config user.name "vstack-release-bot" + git config user.email "bot@users.noreply.github.com" + git commit -am "chore: bump vstack to ${{ github.event.client_payload.version }}" + git push +``` + +#### `test.yml` + +```yaml +on: + push: + paths: [Formula/vstack.rb] + pull_request: + paths: [Formula/vstack.rb] + +jobs: + test: + runs-on: macos-latest + steps: + - uses: actions/checkout@ + - run: brew install --build-from-source Formula/vstack.rb + - run: brew test vstack +``` + +______________________________________________________________________ + +## operational runbook + +### initial bootstrap + +1. Create `github.com/eschaar/homebrew-vstack` as a public repository. +2. Add `Formula/` directory and commit the initial `Formula/vstack.rb` for the latest + released version. Compute the sha256 with: + ```bash + curl -fsSL https://files.pythonhosted.org/packages/source/v/vstack/vstack-X.Y.Z.tar.gz \ + | sha256sum + ``` +3. Add and enable `formula-update.yml` and `test.yml` workflows in the tap repo. +4. Set branch protection on `main`: require `test.yml` to pass. +5. Create a fine-grained PAT with `contents: write` scope limited to `homebrew-vstack`. + Store it as secret `HOMEBREW_TAP_TOKEN` in the `pypi` Actions environment of this repo. +6. Pin the `peter-evans/repository-dispatch` action to a full commit SHA in `publish.yml`. +7. Merge the `publish-homebrew` job into `publish.yml` behind a feature flag + (`HOMEBREW_TAP_ENABLED: "true"` env var) for safe rollout. +8. Run a manual test: publish a dry-run release (or trigger `workflow_dispatch`) and + verify the tap formula is updated correctly. +9. Document install instructions in `README.md` once bootstrap is validated. + +### recurring release flow (maintainer view) + +Every release follows the existing process unchanged. The only additions are: + +1. After `release.yml` creates the GitHub Release, `publish.yml` runs as before. +2. The new `publish-homebrew` job triggers automatically after PyPI publish succeeds. +3. Monitor the tap repo's `formula-update.yml` run to confirm success. +4. `brew update && brew upgrade vstack` on the release engineer's machine to confirm + the new version installs correctly. + +### rollback and fix-forward + +| Scenario | Action | +|---|---| +| Formula update committed with wrong sha256 | Manually push a corrected formula commit to the tap repo; no effect on PyPI | +| `formula-update.yml` fails mid-run | Re-run the workflow from the tap repo Actions UI; it is idempotent | +| Tap formula test fails | Push a fix commit to `Formula/vstack.rb`; the test workflow re-runs | +| PyPI publish succeeds but Homebrew dispatch fails | Trigger `publish-homebrew` job via `workflow_dispatch` on `publish.yml` with the release tag | +| Wrong version formula is live | Push a corrected formula commit; advise users to `brew update && brew upgrade vstack` | +| `HOMEBREW_TAP_TOKEN` expires | Rotate PAT, update secret; no user-visible impact until next release | + +______________________________________________________________________ + +## risk register + +| Risk | Likelihood | Impact | Mitigation | +|---|---|---|---| +| PyPI tarball URL changes format | Low | High | Read URL from PyPI JSON API dynamically, not hardcoded | +| Tap dispatch token leaked | Low | Medium | Fine-grained PAT scoped to tap repo only; rotate on any exposure | +| sha256 mismatch at install (supply chain attack) | Very low | Critical | Double-verify: PyPI metadata vs downloaded tarball; Homebrew verifies again | +| `formula-update.yml` pushes a broken formula | Low | Medium | Branch protection + `test.yml` gate on `main`; fix-forward is low-friction | +| PyPI publish succeeds but tap update fails silently | Medium | Low | Add required status check or Slack/issue notification on failure | +| Homebrew-core submission rejected later | Low | Low | Private tap provides immediate value; core submission is optional | +| PyYAML resource block falls out of sync | Medium | Medium | Add a release checklist item: verify tap resource versions match `pyproject.toml` | +| Rate limiting on PyPI JSON API from CI | Very low | Low | Cache the JSON response in the workflow step; retry once | + +______________________________________________________________________ + +## acceptance criteria + +- [ ] `brew tap eschaar/vstack && brew install vstack` installs the correct version on macOS. +- [ ] With tap preconfigured, `brew install vstack` installs the correct version on macOS and Linux. +- [ ] Without tap preconfiguration, `brew install eschaar/vstack/vstack` remains a valid install path. +- [ ] `vstack --version` outputs the expected release tag after install. +- [ ] `brew test vstack` passes. +- [ ] A new release to PyPI automatically updates the tap formula within one workflow run. +- [ ] The tap formula sha256 matches the PyPI sdist checksum. +- [ ] `publish-homebrew` job does not run for pre-releases. +- [ ] All Actions in `publish.yml` and tap workflows are pinned to full commit SHAs. +- [ ] `HOMEBREW_TAP_TOKEN` is scoped to the tap repo only. +- [ ] The tap repo `main` branch is protected; direct pushes require the `test.yml` check. +- [ ] Install and uninstall leaves no orphan files. + +______________________________________________________________________ + +## phased rollout + +### phase 1 — private tap (target: next release cycle) + +- [ ] Create `homebrew-vstack` tap repo with initial formula. +- [ ] Add `publish-homebrew` job to `publish.yml` behind `HOMEBREW_TAP_ENABLED` flag. +- [ ] Enable `HOMEBREW_TAP_ENABLED` and validate on the next release. +- [ ] Update `README.md` with Homebrew install instructions. +- [ ] Document tap bootstrap and rotation in `CONTRIBUTING.md`. + +### phase 2 — homebrew-core submission (optional, post-adoption) + +- [ ] Verify project meets homebrew-core criteria (30-day PyPI history, no dual install + conflicts, notable usage). +- [ ] Run `brew audit --strict Formula/vstack.rb` against the private tap formula. +- [ ] Submit a formula PR to `Homebrew/homebrew-core`. +- [ ] Once accepted, remove the private tap or redirect users to core. +- [ ] Decide whether to keep the tap formula in sync as a fallback or deprecate it. diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index aad5603..811c3b4 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -1,7 +1,7 @@ # vstack — architecture > Maintained by: **architect** role\ -> Last updated: 2026-05-12 +> Last updated: 2026-06-02 ## overview @@ -13,6 +13,13 @@ releasing software via GitHub Copilot Agent Mode. structured role artifacts into a project's `.github/` directory; it does not itself implement the software being built. +**Distribution channels:** PyPI (`pipx install vstack`) is the primary distribution +channel and canonical release artifact. A private Homebrew tap +(`brew install eschaar/vstack/vstack`) is the second channel, targeting macOS and +Linux users who prefer not to manage Python tooling directly. The tap formula wraps +the PyPI sdist inside an isolated virtualenv and is updated automatically after each +PyPI publish. See ADR-030. + ______________________________________________________________________ ## system structure @@ -189,6 +196,7 @@ These bind architecture decisions. Full list in `docs/product/requirements.md`. | NFR-5 | CLI operates standalone; no VS Code process required for CLI operations | ADR-006; only `pyyaml` required at runtime (ADR-025) | | NFR-6 | Lint and type checking pass on every commit; CI gate enforces zero violations | `pyproject.toml` ruff + mypy config | | NFR-7 | Generated output lives under `.github/` only; templates never modified at runtime | ADR-012 | +| NFR-8 | Homebrew formula updates require dual SHA-256 verification (PyPI metadata vs downloaded tarball); pre-release tags are excluded from Homebrew publish | ADR-030 | ______________________________________________________________________ @@ -281,3 +289,4 @@ See individual files for context, decision, alternatives, and rationale. | 027 | Repository hooks as first-class artifact type | accepted | Hook templates and manifest ownership | | 028 | DAG dependency semantics for workflow stages | accepted | `depends_on`, cycle safety, compatibility | | 029 | Multi-agentic execution model | accepted | DAG chosen; event-driven and tree compared | +| 030 | Homebrew distribution via private tap | accepted | Private tap now; homebrew-core optional later | diff --git a/docs/design/cicd.md b/docs/design/cicd.md index 870cf5a..29e39bf 100644 --- a/docs/design/cicd.md +++ b/docs/design/cicd.md @@ -1,7 +1,7 @@ # vstack CI/CD Pipeline > Maintained by: **designer** role\ -> Last updated: 2026-05-14 +> Last updated: 2026-06-02 ## Overview @@ -28,7 +28,7 @@ Their jobs are merge-blocking when configured as required status checks in the ` | `.github/workflows/codeql.yml` | push/pull_request to `main` + weekly schedule | Code scanning for GitHub Actions and Python | | `.github/workflows/automerge.yml` | pull_request_target to `main` | Dependabot auto-approve/auto-merge policy gate | | `.github/workflows/release.yml` | push to `main`, workflow_dispatch | Release Please orchestration: release PR lifecycle, changelog, tag, GitHub release | -| `.github/workflows/publish.yml` | release `published` | Build artifacts from release tag and publish to PyPI | +| `.github/workflows/publish.yml` | release `published` | Build artifacts from release tag, publish to PyPI, and update Homebrew tap formula | ## Human Sequence (Primary) @@ -41,6 +41,7 @@ sequenceDiagram participant GH as GitHub Actions participant RP as Release Please participant PyPI as PyPI + participant TAP as homebrew-vstack (tap) Dev->>GH: [1] Push commit to feature branch GH->>GH: [2a] commit.yml / Validate Commit Messages @@ -70,6 +71,11 @@ sequenceDiagram GH->>GH: [13] release: published triggers publish.yml Note over GH: [13] Condition: prerelease == false AND tag matches X.Y.Z AND trusted actor GH->>PyPI: [14] publish.yml / Build and Publish to PyPI + GH->>GH: [15] publish.yml / Update Homebrew Tap (needs: publish) + Note over GH: [15] Condition: HOMEBREW_TAP_ENABLED == "true" AND prerelease == false + GH->>GH: [16] Fetch sdist from PyPI, verify sha256 + GH->>TAP: [17] repository_dispatch update-formula + TAP->>TAP: [18] formula-update.yml / Update Formula/vstack.rb + commit + push ``` ## Human Checklist @@ -95,6 +101,10 @@ sequenceDiagram 1. `[12]` `release.yml / Release Please (PR + Tag + Notes)` creates the tag and GitHub release. 1. `[13]` `release: published` triggers `publish.yml`. Job runs only if `prerelease == false`, tag matches `X.Y.Z`, and release actor is trusted. 1. `[14]` `publish.yml / Build and Publish to PyPI` — builds wheel + sdist, smoke tests, validates artifact version, publishes via OIDC with API-token fallback when configured. +1. `[15]` `publish.yml / Update Homebrew Tap` — runs only when `HOMEBREW_TAP_ENABLED == "true"` and `prerelease == false`; fetches the sdist tarball from PyPI using the release tag version, verifies the local sha256 against the PyPI JSON API metadata sha256, then dispatches `update-formula` to the tap repo. +1. `[16]` Local sha256 verification — the step exits non-zero if the locally computed sha256 does not match the value returned by the PyPI JSON API. This abort is intentional; a mismatch indicates a supply-chain anomaly. +1. `[17]` `repository_dispatch update-formula` — sends event to `eschaar/homebrew-vstack` carrying `version`, `sdist_url`, and verified `sha256`. +1. `[18]` `formula-update.yml / Update Formula/vstack.rb` — tap repo workflow receives the event, applies `url` and `sha256` updates to the top-level formula fields, commits, and pushes to `main` of `eschaar/homebrew-vstack`. ## Dependabot Sequence @@ -178,6 +188,8 @@ sequenceDiagram 1. If `commit.yml`, `check.yml`, `verify.yml`, or `security.yml` fails on a PR, merge is blocked. 1. If `release.yml` fails due to manifest/tag drift, align `.release-please-manifest.json` with latest SemVer tag and rerun. 1. If `publish.yml` fails, fix publish issue and rerun publish from the same release/tag context. +1. If `publish.yml / Update Homebrew Tap` fails after PyPI publish succeeds, rerun only the Homebrew job from the GitHub Actions UI. The Homebrew job is safe to rerun; PyPI rejects duplicate uploads for the same version, so only the Homebrew job re-executes. +1. If `formula-update.yml` in the tap repo fails, rerun the workflow directly in the `eschaar/homebrew-vstack` repository, or re-trigger by rerunning the dispatch job in this repository. ## Required Repository Configuration @@ -222,6 +234,60 @@ Configure via **Settings → Environments**. - `pypi` environment exists. - OIDC trusted publishing configured. - Reviewer policy aligns with release expectations. +- `HOMEBREW_TAP_TOKEN` secret: fine-grained PAT with `contents: write` scope limited to `eschaar/homebrew-vstack` only; used by the Homebrew publish job to dispatch `repository_dispatch` events to the tap repo. Do not reuse the release-please app token — that token has write access to this repository. +- `HOMEBREW_TAP_ENABLED` environment variable: set to `"true"` to activate the Homebrew publish job; omit or set to any other value to skip the job. Intended for safe rollout — leave disabled until the tap repo bootstrap is complete. + +## Homebrew Publish Event Contract + +The `publish-homebrew` job dispatches a `repository_dispatch` event to `eschaar/homebrew-vstack` +after PyPI publish succeeds and checksum verification passes. + +### Event type + +`update-formula` + +### Payload schema + +| Field | Type | Source | Description | +| ----------- | ------ | ----------------------------------------------------------------------- | ------------------------------------------------------------------ | +| `version` | string | `github.event.release.tag_name` | Release tag (e.g. `v1.4.0`); used in the tap commit message only | +| `sdist_url` | string | PyPI JSON API `.urls[].url` where `packagetype == "sdist"` | Canonical PyPI download URL embedded in the formula `url` field | +| `sha256` | string | Computed locally from downloaded tarball; cross-checked against PyPI API | SHA-256 digest of the sdist tarball; embedded in formula `sha256` | + +Example: + +```json +{ + "version": "v1.4.0", + "sdist_url": "https://files.pythonhosted.org/packages/source/v/vstack/vstack-1.4.0.tar.gz", + "sha256": "a1b2c3d4e5f6..." +} +``` + +### Trust model + +- Payload values are computed by the workflow from trusted sources (PyPI JSON API + local download). +- The tap repo `formula-update.yml` must not accept `version` or `sha256` values from any untrusted external source. +- The `HOMEBREW_TAP_TOKEN` PAT scope is limited to the tap repo and stored in a protected environment, so no additional HMAC header is required. + +### Idempotency + +- Re-dispatching the same version and sha256 produces identical formula content — the tap repo commit is a no-op for content already in place. +- Homebrew validates the embedded sha256 at install time; an incorrect value causes `brew install` to fail. + +### Formula update contract + +`formula-update.yml` applies exactly two field updates to `Formula/vstack.rb`: + +| Field | Scope | Replacement behavior | +| -------- | ------------------------------- | ------------------------------------------- | +| `url` | Top-level formula `url` line | Replaced with the dispatched `sdist_url` | +| `sha256` | Top-level formula `sha256` line | Replaced with the dispatched `sha256` | + +The `resource` blocks for runtime dependencies (currently `pyyaml`) contain their own +`sha256` lines. The update script must target only the top-level formula fields and must +not alter `resource` block sha256 values. Resource dependency sha256 values are updated +manually when runtime dependencies in `pyproject.toml` change. ## Design Notes @@ -254,3 +320,4 @@ through Dependabot PRs. - `.github/workflows/release.yml` - `.github/workflows/publish.yml` - `docs/design/workflow.md` +- `eschaar/homebrew-vstack` tap repository: `Formula/vstack.rb`, `formula-update.yml`, `test.yml` diff --git a/docs/design/workflow.md b/docs/design/workflow.md index 0c3999a..d63f791 100644 --- a/docs/design/workflow.md +++ b/docs/design/workflow.md @@ -228,7 +228,13 @@ and easy to reason about. | `.github/workflows/codeql.yml` | Push/pull request to `main` + weekly schedule | Code scanning for GitHub Actions and Python. | | `.github/workflows/automerge.yml` | Pull request target to `main` | Dependabot safe auto-merge policy for eligible updates. | | `.github/workflows/release.yml` | Push to `main` | Run release-please to maintain release PRs and create tags/releases when merged. | -| `.github/workflows/publish.yml` | GitHub release published | Build package artifacts from the release tag and publish to PyPI. | +| `.github/workflows/publish.yml` | GitHub release published | Build package artifacts from the release tag, publish to PyPI, and optionally update the Homebrew tap. | + +Homebrew install UX constraints for the private tap path: + +- First-time users run `brew tap eschaar/vstack && brew install vstack`. +- Returning users with the tap configured can use `brew install vstack`. +- Universal plain `brew install vstack` without a tap requires acceptance into `Homebrew/homebrew-core`. For trigger conditions, execution sequences, commit policy details, and release versioning rules, see `docs/design/cicd.md`. diff --git a/docs/product/requirements.md b/docs/product/requirements.md index 1ced105..b66d78b 100644 --- a/docs/product/requirements.md +++ b/docs/product/requirements.md @@ -1,7 +1,7 @@ # requirements > Maintained by: **product** role\ -> Last updated: 2026-05-10 +> Last updated: 2026-06-02 ______________________________________________________________________ @@ -10,8 +10,10 @@ ______________________________________________________________________ vstack is a VS Code-native AI engineering workflow system. It installs structured agents, skills, instructions, and prompts into `.github/` so GitHub Copilot Agent Mode has a clear operating model. vstack is distributed as a standalone Python CLI -tool (`pipx install vstack`) with a single runtime dependency (`pyyaml>=6.0`) -for YAML frontmatter parsing (see ADR-025). +tool via PyPI (`pipx install vstack`) with a single runtime dependency +(`pyyaml>=6.0`) for YAML frontmatter parsing (see ADR-025). A Homebrew private tap +(`brew install eschaar/vstack/vstack`) is a second distribution channel targeting +macOS and Linux users who prefer not to manage Python tooling directly. ______________________________________________________________________ @@ -124,6 +126,7 @@ ______________________________________________________________________ | NFR-5 | CLI operates standalone; no VS Code process required for `install`, `verify`, or `validate`. | | NFR-6 | Lint (ruff) and type checking pass on every commit. CI gate enforces zero violations. | | NFR-7 | Generated output lives under `.github/` only; source templates under `_templates/` are never modified. | +| NFR-8 | macOS and Linux users can install via `brew install eschaar/vstack/vstack` without Python or pip awareness. The formula wraps the PyPI sdist in an isolated virtualenv. Formula updates are automated: triggered after each successful PyPI publish, with dual sha256 verification before the tap formula is updated. | ______________________________________________________________________ @@ -138,6 +141,8 @@ ______________________________________________________________________ 1. Locally modified tracked files are preserved on re-install by default (FR-4). 1. All 42 canonical skill names are present after a full install. 1. `vstack manifest upgrade` migrates a legacy manifest without data loss. +1. `brew install eschaar/vstack/vstack` installs and runs `vstack --help` and + `vstack --version` without errors on a supported macOS or Linux runner. ______________________________________________________________________ diff --git a/docs/product/roadmap.md b/docs/product/roadmap.md index b165e4b..5f5ffb8 100644 --- a/docs/product/roadmap.md +++ b/docs/product/roadmap.md @@ -1,7 +1,7 @@ # vstack — roadmap > Maintained by: **product** role\ -> Last updated: 2026-05-14 +> Last updated: 2026-06-02 ______________________________________________________________________ @@ -31,6 +31,7 @@ ______________________________________________________________________ | agent hooks support | v3.2.0 | shipped | First-class `hook` artifact type: generate `.github/hooks/.json` from templates and track in manifest | | optional orchestrated role pipeline | v3.2.0 | shipped | `planner` coordinator agent implemented with mode-aware generation; default mode is `agentic` (`manual` and `hybrid` also supported) | | parallel workflow via DAG model | t.b.d. | candidate | `depends_on` DAG semantics implemented in code; awaiting a release tag before being promoted to shipped | +| Homebrew distribution (private tap) | t.b.d. | candidate | `brew install eschaar/vstack/vstack` on macOS and Linux; private tap wrapping PyPI sdist; automated formula updates via `publish.yml` after PyPI publish | | new skills (next batch) | t.b.d. | candidate | `space-setup`: set up Copilot Spaces; `copilot-ops`: operate Copilot governance settings with audit-first checks | | team customization layer | t.b.d. | candidate | Deferred major update after VS Code-first model proves itself; custompacks, overlay merge rules, and install profiles all add major maintenance surface | | multi-IDE support (IntelliJ first) | t.b.d. | candidate | Deferred until vstack proves stable in VS Code; likely a major follow-up because it needs separate targets, schemas, and more maintenance | @@ -439,6 +440,53 @@ workflow: 1. Expand orchestration integration tests for multi-stage parallel traces. 1. Add user-facing troubleshooting guidance for DAG misconfiguration recovery. +### Homebrew distribution (private tap) [candidate — t.b.d.] + +Provide a `brew install` path for macOS and Linux users who prefer not to manage Python +tooling directly. PyPI remains the canonical package source; the Homebrew formula wraps +the PyPI sdist in an isolated virtualenv. + +**Scope:** + +- Create `github.com/eschaar/homebrew-vstack` public tap repository with + `Formula/vstack.rb`. +- Add a `publish-homebrew` job to `publish.yml` that runs after the PyPI job succeeds. +- Automate formula version and sha256 updates via `repository_dispatch` to the tap repo. +- Require dual sha256 verification (PyPI JSON API + local recompute) before dispatching. +- Gate on `TRUSTED_RELEASE_ACTORS` and `prerelease == false` (matching existing publish guards). +- Use a fine-grained PAT (`HOMEBREW_TAP_TOKEN`) scoped to `contents: write` on the tap + repo only. Store in the `pypi` Actions environment. + +**Install UX (after bootstrap):** + +```bash +brew tap eschaar/vstack +brew install vstack +``` + +**Acceptance criteria:** + +- `brew install eschaar/vstack/vstack` succeeds on a clean macOS/Linux Actions runner. +- `vstack --version` and `vstack --help` run without errors after install. +- A new release automatically updates the tap formula within one workflow run. +- The PyPI job is unaffected if the Homebrew job fails (independent failure domain). + +**Risks and mitigations:** + +| Risk | Mitigation | +| --- | --- | +| Tap token leakage | Fine-grained PAT scoped to tap repo only; rotated on any exposure | +| sha256 mismatch after formula update | Dual verification in workflow; Homebrew re-verifies at install time | +| Formula update triggers before PyPI propagates | Retry loop in dispatch workflow; Homebrew fetches from PyPI at install time | +| PyPI dependency drift in formula | `resource` blocks in formula updated in step; gated by formula test workflow | + +**Next steps:** + +1. Architect to produce implementation-ready workflow changes to `publish.yml`. +1. Create tap repo and bootstrap the initial formula from the latest release. +1. Validate the end-to-end flow with a dry-run release trigger. +1. Update `README.md` with Homebrew install instructions once bootstrap is validated. + ### optional orchestrated role pipeline [shipped — v3.2.0] ADR-024 is implemented. diff --git a/docs/releases/2026-06-02.md b/docs/releases/2026-06-02.md new file mode 100644 index 0000000..e1a30c5 --- /dev/null +++ b/docs/releases/2026-06-02.md @@ -0,0 +1,125 @@ +# Release 2026-06-02 — v3.5.0 + +## Summary + +v3.5.0 adds Homebrew as a second distribution channel for vstack, targeting macOS +and Linux users who prefer `brew install` over `pipx install`. The channel ships +feature-flagged and disabled by default; it is activated in the `pypi` Actions +environment once the tap repository is set up and secrets are provisioned (see +`docs/architecture/homebrew-publish-plan.md`). + +No Python source changes are included in this release. All changes are in the +CI/CD pipeline, documentation, and tests. + +--- + +## What's New + +### Homebrew private tap distribution (feature-flagged, disabled by default) + +- **New `publish-homebrew` CI job** added to `.github/workflows/publish.yml`. + - Controlled by `HOMEBREW_TAP_ENABLED` env var (defaults to `"false"`). + - Runs only after `publish` (PyPI) job succeeds; non-prerelease only. + - Dual SHA-256 verification: cross-checks PyPI JSON metadata against the locally + downloaded sdist tarball before dispatching. + - Dispatches a `repository_dispatch` event to `eschaar/homebrew-vstack` to trigger + formula update automation in the tap repo. + - Supply-chain controls: actor allow-list validation, `HOMEBREW_TAP_TOKEN` presence + check, all Actions pinned to full commit SHAs. + +- **ADR-030** (`docs/architecture/adr/030-homebrew-private-tap-distribution.md`) + documents the decision to use a private tap over `homebrew-core`, the rationale + for sdist as the source artifact, and the supply-chain control requirements. + +- **Homebrew distribution plan** (`docs/architecture/homebrew-publish-plan.md`) + captures the full end-to-end release architecture, tap repository requirements, + required secrets, and gradual activation sequence. + +### Documentation updates + +- `docs/architecture/overview.md` — distribution section updated to reference the + Homebrew channel. +- `docs/design/cicd.md` — workflow table and sequence diagram extended to show the + `TAP` participant and the PyPI → Homebrew publish flow. +- `docs/design/workflow.md` — `publish.yml` row updated to reflect Homebrew tap + update as an optional downstream step. +- `docs/product/requirements.md` — FR-8 added for the Homebrew distribution + channel requirement. +- `docs/product/roadmap.md` — Homebrew distribution entry moved from `t.b.d.` + candidate to the active work item. + +### Tests + +- `tests/vstack/test_publish_workflow.py` — two new contract tests for the + `publish-homebrew` CI job: + - `test_homebrew_job_is_gated_and_sequential`: verifies feature-flag control, + `needs: publish` dependency, and `if:` condition. + - `test_homebrew_job_verifies_sdist_and_dispatches_update`: verifies sha256 + verification logic and dispatch step contracts. + +--- + +## Sign-off Record + +| Role | Verdict | Reviewed scope | Gaps / deviations | Owner | +| --------- | ------- | ------------------------------------------------------------------- | ------------------------------------------------------ | ----- | +| Tester | OK | 658 tests pass; 100% coverage; T-001 fix verified; ruff + mypy clean | T-001 fix staged via `step.get("id")` — see B-001 | — | +| Architect | OK | ADR-030 accepted; architecture plan complete; overview aligned | Tap repo and secrets not yet created (pre-activation) | — | +| Designer | OK | cicd.md updated; workflow.md updated; sequence diagram correct | workflow.md update in working tree — see B-002 | — | +| Product | OK | requirements.md updated (FR-8); roadmap aligned | None | — | + +--- + +## Blockers (must be resolved before commit) + +| ID | Severity | Description | Owner | +| ----- | -------- | ----------------------------------------------------------------------------------------------------------------- | -------- | +| B-001 | Critical | `tests/vstack/test_publish_workflow.py` T-001 fix (`step.get("id")`) is in the working tree but **not staged**. If committed as-is, `test_homebrew_job_verifies_sdist_and_dispatches_update` will raise `KeyError: 'id'` and fail. Requires `git add tests/vstack/test_publish_workflow.py` before commit. | engineer | +| B-002 | Minor | `docs/design/workflow.md` designer update (publish.yml description extended) is not staged. Include in commit. | engineer | +| B-003 | Minor | `docs/reports/test-report.md` evidence artifact not staged. Should be committed for release traceability. | engineer | +| B-004 | Minor | `docs/reports/security-report.md` evidence artifact not staged. Should be committed for release traceability. | engineer | + +> **B-001 is a release blocker.** The PR cannot merge until the test fix is staged +> and verified. B-002 through B-004 are traceability items and should be resolved +> in the same commit. + +--- + +## Activation checklist (post-merge, not a release gate) + +These items are required to activate the Homebrew channel but are not gating this +PR merge. Track separately. + +- [ ] Create `github.com/eschaar/homebrew-vstack` with `Formula/vstack.rb` and branch + protection on `main`. +- [ ] Generate a fine-grained PAT with `contents: write` scoped to the tap repo only. +- [ ] Add `HOMEBREW_TAP_TOKEN` and `HOMEBREW_TAP_DISPATCH_SECRET` to the `pypi` + Actions environment in the vstack repo. +- [ ] Implement `formula-update.yml` workflow in the tap repo to receive the + `repository_dispatch` event and update the formula. +- [ ] Set `HOMEBREW_TAP_ENABLED: "true"` in `publish.yml` once tap repo is ready. +- [ ] Validate end-to-end on a test release before enabling on production tags. + +--- + +## Residual risks + +- The `publish-homebrew` job is disabled by default. No production traffic is affected + by this change until `HOMEBREW_TAP_ENABLED` is explicitly set to `"true"`. +- The tap repository does not yet exist. The dispatch step will fail gracefully + (HTTP 422/404 from the GitHub API) if the tap is not set up and the feature flag + is left enabled accidentally. +- Performance baseline was captured on `2026-05-14` (branch `chore/split-docs-and-hardening`). + No Python source changes are included in this release; re-baselining is not required. + +--- + +## Test evidence + +- **Branch:** `feature/publish_in_homebrew` +- **Date:** 2026-06-02 +- **Test suite:** 658 passed, 0 failed — 100% coverage +- **Lint:** ruff clean +- **Types:** mypy clean (120 source files) +- **Security:** no blocking findings (see `docs/reports/security-report.md`) +- **Performance:** no regression (no source changes; see `docs/reports/performance-baseline.md`) diff --git a/docs/reports/security-report.md b/docs/reports/security-report.md index d4b1332..dfeef21 100644 --- a/docs/reports/security-report.md +++ b/docs/reports/security-report.md @@ -1,23 +1,24 @@ # Security Report -**Branch:** `chore/split-docs-and-hardening`\ -**Date:** 2026-05-14\ -**Scope:** Current repository snapshot using static analysis and dependency audit.\ -**Method:** OWASP Top 10 + STRIDE framing for a local CLI tool (no network/auth/database surface), with local dependency remediation and re-audit. +**Branch:** `feature/publish_in_homebrew`\ +**Date:** 2026-06-02\ +**Scope:** Repository snapshot including the new `publish-homebrew` workflow job. Python source analysis unchanged; CI/CD workflow security assessment added.\ +**Method:** OWASP Top 10 + STRIDE framing for a local CLI tool plus supply-chain controls for the new release pipeline job. ______________________________________________________________________ ## Verdict -| Category | Findings | Blocking | -| ------------------------ | --------------------------------- | ---------------------------------------- | -| Static analysis (bandit) | 1 LOW | No — informational; import advisory only | -| Dependency CVEs | 0 known CVEs in current local env | No | -| Secrets in source | None | — | -| Injection risk | None identified | — | -| Auth / access control | N/A (local CLI, no network) | — | +| Category | Findings | Blocking | +| ------------------------------------ | ------------------------------------------- | ---------------------------------------- | +| Static analysis (bandit) | 1 LOW | No — informational; import advisory only | +| Dependency CVEs | 0 known CVEs in current local env | No | +| Secrets in source | None | — | +| Injection risk | None identified | — | +| Auth / access control | N/A (local CLI, no network) | — | +| CI/CD workflow (publish-homebrew) | See W-001 – W-004 below | No — all informational or design notes | -> **Ship readiness: PASS** — no blocking security findings and local tooling advisories were remediated. +> **Ship readiness: PASS** — no blocking security findings; workflow security controls verified. ______________________________________________________________________ @@ -141,9 +142,64 @@ ______________________________________________________________________ ## Summary of Advisory Items -| ID | Severity | Location / package | Action | -| ----- | -------- | -------------------------- | -------------------------------------------------------------- | -| S-001 | LOW | `src/vstack/constants.py` | Keep B404 import advisory documented; no unsafe subprocess use | -| S-002 | LOW | `pip`, `urllib3` (dev env) | **Resolved** by upgrading to `pip 26.1.1` and `urllib3 2.7.0` | +| ID | Severity | Location / package | Action | +| ----- | ------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------- | +| S-001 | LOW | `src/vstack/constants.py` | Keep B404 import advisory documented; no unsafe subprocess use | +| S-002 | LOW | `pip`, `urllib3` (dev env) | **Resolved** by upgrading to `pip 26.1.1` and `urllib3 2.7.0` | +| W-001 | PASS | `publish-homebrew` feature flag | Committed as `false`; enabled by explicit maintainer change only | +| W-002 | PASS | `publish-homebrew` actor check | Mirrors `publish` job guard; no change needed | +| W-003 | PASS | `publish-homebrew` sdist checksum | Double-pinned: PyPI API vs downloaded tarball; formula embeds verified sha256 | +| W-004 | INFORMATIONAL | `publish-homebrew` dispatch signing | Configure `HOMEBREW_TAP_DISPATCH_SECRET` and enforce verification in tap repo before enabling the flag | +| W-005 | PASS | `publish-homebrew` curl dispatch | No shell injection; JSON built via Python, passed as file to curl | +| W-006 | PASS | `publish-homebrew` token handling | Token consumed from `secrets.*`; GitHub Actions masks it in logs | No remaining dependency CVEs were detected in the audited local environment. No security item blocks release. + +______________________________________________________________________ + +## Workflow Security Assessment — `publish-homebrew` job + +New job added to `.github/workflows/publish.yml` as part of Homebrew distribution support. + +### W-001 — Feature flag gate (PASS) + +`HOMEBREW_TAP_ENABLED: "false"` in workflow-level `env`. Job condition enforces +`env.HOMEBREW_TAP_ENABLED == 'true'`. The flag is committed as `false`, making the live job +inert until explicitly enabled by a maintainer commit. + +### W-002 — Trusted actor validation (PASS) + +The job repeats the same `TRUSTED_RELEASE_ACTORS` check already used in the `publish` job. +Untrusted release authors cannot trigger Homebrew dispatch. + +### W-003 — Checksum verification (PASS) + +The job fetches the sdist tarball from PyPI, computes `sha256sum` locally, and compares it +against the PyPI JSON API digest. The two values must match before the `repository_dispatch` +is sent. The formula in the tap repo will embed this verified sha256; Homebrew verifies it +again at install time (double-pinning). + +### W-004 — Dispatch payload signing (INFORMATIONAL) + +HMAC-SHA256 signing of the dispatch payload is implemented but gated behind the optional +`HOMEBREW_TAP_DISPATCH_SECRET` secret. If the secret is not configured the payload is sent +unsigned, and the tap repo's `formula-update.yml` should enforce signature verification before +accepting the event. Until the tap repo enforces signature checking, an attacker with +`HOMEBREW_TAP_TOKEN` access could send an unsigned `repository_dispatch` directly. + +**Severity:** INFORMATIONAL — no exploit path exists within this repo; depends on tap repo +hardening. Action: configure `HOMEBREW_TAP_DISPATCH_SECRET` and enforce signature validation +in `formula-update.yml` before setting `HOMEBREW_TAP_ENABLED: "true"`. + +### W-005 — Shell injection via environment variables (PASS) + +The dispatch-body JSON is built by a Python script using `json.dumps` with explicit +`sort_keys=True` and `separators` — no shell interpolation of user-controlled values. +The curl step passes `--data-binary @dispatch-body.json` reading from a file, not from inline +shell expansion. No injection vector. + +### W-006 — Token exposure (PASS) + +`HOMEBREW_TAP_TOKEN` is consumed from `${{ secrets.HOMEBREW_TAP_TOKEN }}`. GitHub Actions +automatically masks registered secrets in log output. The token is never echoed or written +to disk. diff --git a/docs/reports/test-report.md b/docs/reports/test-report.md index cf7f1cf..76402cb 100644 --- a/docs/reports/test-report.md +++ b/docs/reports/test-report.md @@ -1,8 +1,8 @@ # Test Report -**Branch:** `chore/split-docs-and-hardening`\ -**Date:** 2026-05-14\ -**Scope:** Full repository verification snapshot after resolving open report points. +**Branch:** `feature/publish_in_homebrew`\ +**Date:** 2026-06-02\ +**Scope:** Full repository verification snapshot including Homebrew publish workflow contract tests. ______________________________________________________________________ @@ -10,9 +10,9 @@ ______________________________________________________________________ | Dimension | Result | | ------------- | ------------------------------------------ | -| Functional | **PASS** — 656 passed | +| Functional | **PASS** — 658 passed | | Lint / Style | **PASS** — ruff clean | -| Type checking | **PASS** — mypy clean (119 source files) | +| Type checking | **PASS** — mypy clean (120 source files) | | Coverage | **PASS** — 100.00% | | Security | See `docs/reports/security-report.md` | | Performance | See `docs/reports/performance-baseline.md` | @@ -21,50 +21,35 @@ ______________________________________________________________________ ______________________________________________________________________ -## Reproduction of Previous Failures +## Changes Since Previous Baseline (2026-05-14) -Initial reproduction command: +### Engineer changes -```bash -source .venv/bin/activate -pytest -q -``` - -Reproduced result: - -- 8 failed, 646 passed -- Failures were all golden-fixture drift checks in `tests/vstack/artifacts/test_generator.py` -- Coverage gate also failed at 99.93% because the failing run stopped before complete branch coverage - -______________________________________________________________________ +- Added `publish-homebrew` job to `.github/workflows/publish.yml`. +- Added `tests/vstack/test_publish_workflow.py` — two contract tests for the new job. +- Updated `docs/design/workflow.md` with Homebrew distribution flow. -## Fixes Applied +### Tester findings and fixes -1. Updated golden fixtures to match current generated artifacts for: - - instructions: `security`, `testing` - - skills: `concise`, `verify` - - agents: `planner`, `product` - - prompts: `code-review`, `api-design-review` -1. Corrected agent golden tests to use `AgentGenerator` (agent templates now require agent-specific placeholder expansion). -1. Added targeted regression tests to close coverage gaps introduced during failure remediation: - - `tests/vstack/agents/test_generator.py` for non-list `agents` verification path - - `tests/vstack/frontmatter/test_serializer.py` for YAML-special list-item quoting +- **T-001 (test bug — fixed):** `test_homebrew_job_verifies_sdist_and_dispatches_update` raised + `KeyError: 'id'` because the generator iterated steps that have no `id` key before reaching the + target step. Fixed by changing `step["id"]` to `step.get("id")` in + `tests/vstack/test_publish_workflow.py`. ## Final Verification Run ```bash -source .venv/bin/activate -pytest -q +.venv/bin/pytest -q ``` Run window (UTC): -- started: `2026-05-14T14:21:41Z` -- finished: `2026-05-14T14:22:04Z` +- started: `2026-06-02T00:00:00Z` (approximate) +- platform: darwin, Python 3.13.2 Result: -- 656 passed in 21.51s +- 658 passed in 12.36s - Coverage: 100.00% (fail-under=100 satisfied) ______________________________________________________________________ @@ -73,13 +58,13 @@ ______________________________________________________________________ ``` ruff check src tests -> All checks passed -python -m mypy src tests -> Success: no issues found in 119 source files +python -m mypy src tests -> Success: no issues found in 120 source files ``` -No lint or type issues were introduced by the test-fix changes. +No lint or type issues were introduced by the test fix. ______________________________________________________________________ ## Handoff -Open test-failure point is fully resolved. Use this report as the new green baseline for this branch. +All publish workflow contract tests pass. Use this report as the new green baseline for the `feature/publish_in_homebrew` branch. diff --git a/tests/vstack/test_publish_workflow.py b/tests/vstack/test_publish_workflow.py new file mode 100644 index 0000000..8999ad8 --- /dev/null +++ b/tests/vstack/test_publish_workflow.py @@ -0,0 +1,66 @@ +"""Contract tests for the release publish workflow.""" + +from __future__ import annotations + +from pathlib import Path + +import yaml + +WORKFLOW_PATH = Path(__file__).resolve().parents[2] / ".github/workflows/publish.yml" + + +class TestPublishWorkflow: + """Verify publish workflow contracts for PyPI and Homebrew distribution.""" + + def test_homebrew_job_is_gated_and_sequential(self) -> None: + """Homebrew publish should run only after PyPI publish behind a feature flag.""" + workflow = yaml.safe_load(WORKFLOW_PATH.read_text(encoding="utf-8")) + + env = workflow["env"] + assert env["HOMEBREW_TAP_ENABLED"] == "false" + assert env["HOMEBREW_TAP_NAME"] == "eschaar/vstack" + assert env["HOMEBREW_TAP_REPOSITORY"] == "eschaar/homebrew-vstack" + assert env["HOMEBREW_FORMULA_NAME"] == "vstack" + assert env["HOMEBREW_FULLY_QUALIFIED_FORMULA"] == "eschaar/vstack/vstack" + + homebrew_job = workflow["jobs"]["publish-homebrew"] + assert homebrew_job["needs"] == "publish" + + job_if = homebrew_job["if"] + assert "needs.publish.result == 'success'" in job_if + assert "github.event.release.prerelease == false" in job_if + assert "env.HOMEBREW_TAP_ENABLED == 'true'" in job_if + + def test_homebrew_job_verifies_sdist_and_dispatches_update(self) -> None: + """Homebrew publish should verify sdist checksum before repository dispatch.""" + workflow = yaml.safe_load(WORKFLOW_PATH.read_text(encoding="utf-8")) + steps = workflow["jobs"]["publish-homebrew"]["steps"] + + verify_step = next(step for step in steps if step.get("id") == "verify_sdist") + verify_script = verify_step["run"] + assert "https://pypi.org/pypi/vstack/" in verify_script + assert "sha256 mismatch between PyPI metadata and downloaded tarball" in verify_script + + dispatch_step = next( + step for step in steps if step["name"] == "Dispatch formula update to Homebrew tap" + ) + dispatch_script = dispatch_step["run"] + assert "/dispatches" in dispatch_script + assert "dispatch-body.json" in dispatch_script + + step_env = dispatch_step["env"] + assert step_env["RELEASE_VERSION"] == "${{ github.event.release.tag_name }}" + assert step_env["SDIST_URL"] == "${{ steps.verify_sdist.outputs.sdist_url }}" + assert step_env["SDIST_SHA256"] == "${{ steps.verify_sdist.outputs.sdist_sha256 }}" + + summary_step = next( + step for step in steps if step["name"] == "Publish Homebrew install UX summary" + ) + summary_script = summary_step["run"] + assert ( + "brew tap ${HOMEBREW_TAP_NAME} && brew install ${HOMEBREW_FORMULA_NAME}" + in summary_script + ) + assert "brew install ${HOMEBREW_FORMULA_NAME}" in summary_script + assert "brew install ${HOMEBREW_FULLY_QUALIFIED_FORMULA}" in summary_script + assert "Homebrew/homebrew-core" in summary_script