Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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"
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,26 @@

<!-- markdownlint-disable MD024 -->

## [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)


Expand Down
89 changes: 89 additions & 0 deletions docs/architecture/adr/030-homebrew-private-tap-distribution.md
Original file line number Diff line number Diff line change
@@ -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).
Loading