diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index 08f714a..ca7985f 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -130,6 +130,187 @@ jobs: release-artifacts/uninstall-endpoint-mac.sh \ release-artifacts/uninstall-endpoint-windows.ps1 + publish-homebrew: + name: Update Homebrew tap + if: github.event_name == 'release' && !github.event.release.prerelease + runs-on: ubuntu-latest + + steps: + - name: Extract SHA256 checksums from the released install script + env: + VERSION: ${{ github.event.release.tag_name }} + run: | + set -euo pipefail + + # The release pipeline already bakes per-platform SHA256s into + # install-safe-chain.sh. Parse them out instead of re-downloading + # ~200MB of binaries just to recompute the same hashes. + curl -fsSL --retry 3 \ + "https://github.com/AikidoSec/safe-chain/releases/download/${VERSION}/install-safe-chain.sh" \ + -o install-safe-chain.sh + + extract_sha() { + grep -E "^${1}=\"[a-f0-9]+\"" install-safe-chain.sh \ + | head -n1 \ + | sed -E "s/^${1}=\"([a-f0-9]+)\"/\1/" + } + + { + echo "SHA_MACOS_X64=$(extract_sha SHA256_MACOS_X64)" + echo "SHA_MACOS_ARM64=$(extract_sha SHA256_MACOS_ARM64)" + echo "SHA_LINUX_X64=$(extract_sha SHA256_LINUX_X64)" + echo "SHA_LINUX_ARM64=$(extract_sha SHA256_LINUX_ARM64)" + } >> "$GITHUB_ENV" + + - name: Verify all checksums were extracted + run: | + set -euo pipefail + missing=() + for var in SHA_MACOS_X64 SHA_MACOS_ARM64 SHA_LINUX_X64 SHA_LINUX_ARM64; do + if [ -z "${!var:-}" ]; then + missing+=("$var") + fi + done + if [ ${#missing[@]} -gt 0 ]; then + echo "::error::Could not extract checksum(s) from install-safe-chain.sh: ${missing[*]}" + exit 1 + fi + + - name: Checkout Homebrew tap + uses: actions/checkout@v4 + with: + repository: AikidoSec/homebrew-tap + path: homebrew-tap + token: ${{ secrets.HOMEBREW_TAP_TOKEN }} + persist-credentials: true + + - name: Render formula from template + env: + VERSION: ${{ github.event.release.tag_name }} + run: | + set -euo pipefail + mkdir -p homebrew-tap/Formula + + # Quoted heredoc preserves Ruby `#{version}` interpolation and the + # backticks inside the caveats string. Placeholders are substituted + # with sed below, after the heredoc is written. + cat > homebrew-tap/Formula/safe-chain.rb <<'RUBY' + # typed: strict + # frozen_string_literal: true + + # This file is auto-generated by AikidoSec/safe-chain's release + # workflow. Do not edit by hand — changes will be overwritten on + # the next release. + # + # Aikido Safe Chain wraps your package managers (npm, yarn, pnpm, + # pip, poetry, uv, ...) and blocks installations of known-malicious + # packages using the Aikido Intel feed. + class SafeChain < Formula + desc "Block malicious packages from npm, yarn, pnpm, pip, poetry and uv" + homepage "https://github.com/AikidoSec/safe-chain" + version "__VERSION__" + license "AGPL-3.0-or-later" + + livecheck do + url :stable + strategy :github_latest + end + + on_macos do + on_arm do + url "https://github.com/AikidoSec/safe-chain/releases/download/#{version}/safe-chain-macos-arm64", + using: :nounzip + sha256 "__SHA_MACOS_ARM64__" + end + on_intel do + url "https://github.com/AikidoSec/safe-chain/releases/download/#{version}/safe-chain-macos-x64", + using: :nounzip + sha256 "__SHA_MACOS_X64__" + end + end + + on_linux do + on_arm do + url "https://github.com/AikidoSec/safe-chain/releases/download/#{version}/safe-chain-linux-arm64", + using: :nounzip + sha256 "__SHA_LINUX_ARM64__" + end + on_intel do + url "https://github.com/AikidoSec/safe-chain/releases/download/#{version}/safe-chain-linux-x64", + using: :nounzip + sha256 "__SHA_LINUX_X64__" + end + end + + def install + binary_name = if OS.mac? + Hardware::CPU.arm? ? "safe-chain-macos-arm64" : "safe-chain-macos-x64" + else + Hardware::CPU.arm? ? "safe-chain-linux-arm64" : "safe-chain-linux-x64" + end + + chmod 0755, binary_name + bin.install binary_name => "safe-chain" + end + + def caveats + <<~CAVEATS + Aikido Safe Chain works by wrapping your package managers (npm, yarn, + pnpm, pip, poetry, uv, ...) with shell aliases. After installing, run: + + safe-chain setup + + For CI/non-interactive environments, use: + + safe-chain setup-ci + + Then restart your shell (or `source ~/.zshrc` / `~/.bashrc`) so the + new aliases are picked up. + CAVEATS + end + + test do + assert_match version.to_s, shell_output("#{bin}/safe-chain --version") + assert_match "safe-chain setup", shell_output("#{bin}/safe-chain help") + end + end + RUBY + + sed -i \ + -e "s|__VERSION__|${VERSION}|g" \ + -e "s|__SHA_MACOS_ARM64__|${SHA_MACOS_ARM64}|g" \ + -e "s|__SHA_MACOS_X64__|${SHA_MACOS_X64}|g" \ + -e "s|__SHA_LINUX_ARM64__|${SHA_LINUX_ARM64}|g" \ + -e "s|__SHA_LINUX_X64__|${SHA_LINUX_X64}|g" \ + homebrew-tap/Formula/safe-chain.rb + + # Fail fast if any placeholder wasn't substituted (e.g. a new + # __XXX__ added to the template without a matching sed rule). + if grep -q "__[A-Z_]*__" homebrew-tap/Formula/safe-chain.rb; then + echo "::error::Unsubstituted placeholder(s) remain in the formula:" + grep -n "__[A-Z_]*__" homebrew-tap/Formula/safe-chain.rb + exit 1 + fi + + - name: Commit and push formula update + working-directory: homebrew-tap + env: + VERSION: ${{ github.event.release.tag_name }} + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + git add Formula/safe-chain.rb + + if git diff --staged --quiet; then + echo "No formula changes to commit." + exit 0 + fi + + git commit -m "safe-chain ${VERSION}" + git push + publish-npm: name: Publish to npm if: github.event_name == 'release' diff --git a/README.md b/README.md index cb8f34b..92961a5 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,17 @@ curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/inst iex (iwr "https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.ps1" -UseBasicParsing) ``` +### macOS / Linux (Homebrew) + +If you'd prefer a package manager over piping a script from the internet, install Safe Chain via our [Homebrew tap](https://github.com/AikidoSec/homebrew-tap): + +```shell +brew install AikidoSec/tap/safe-chain +safe-chain setup +``` + +After running `safe-chain setup`, restart your shell to pick up the new aliases. For CI/non-interactive environments use `safe-chain setup-ci` instead. + ### Pinning to a specific version To install a specific version instead of the latest, replace `latest` with the version number in the URL (available from version 1.3.2 onwards): diff --git a/docs/homebrew.md b/docs/homebrew.md new file mode 100644 index 0000000..37da85a --- /dev/null +++ b/docs/homebrew.md @@ -0,0 +1,114 @@ +# Homebrew Tap + +End-user install instructions live in the top-level [`README.md`](../README.md#macos--linux-homebrew). This page is for maintainers: how the tap is wired up and what one-time setup is required. + +## Architecture + +``` +AikidoSec/safe-chain AikidoSec/homebrew-tap +┌──────────────────────────┐ ┌──────────────────────────┐ +│ tag push (vX.Y.Z) │ │ Formula/ │ +│ → create-binaries │ │ safe-chain.rb │ +│ → publish-binaries │ │ ^ │ +│ (draft release + │ │ │ │ +│ install-*.sh with │ │ │ │ +│ baked SHA256s) │ │ │ git push │ +│ │ │ │ on each release │ +│ release.published │ ────────► │ │ │ +│ → publish-homebrew │ │ │ +│ → publish-npm │ │ │ +└──────────────────────────┘ └──────────────────────────┘ +``` + +The `publish-homebrew` job in [`.github/workflows/build-and-release.yml`](../.github/workflows/build-and-release.yml) fires on `release.published` (i.e. when a maintainer publishes the draft release that `publish-binaries` created on the tag push). It: + +1. Downloads the release's `install-safe-chain.sh` asset (already has per-platform SHA256s baked in by the earlier `publish-binaries` step — see lines 76–86 of the workflow). +2. Parses the four SHA256s it needs (macOS x64/arm64, Linux x64/arm64) out of the install script. +3. Renders `Formula/safe-chain.rb` from a quoted heredoc template, substituting the new version and SHAs. +4. Pushes the rendered formula to the `main` branch of `AikidoSec/homebrew-tap`. + +Prereleases are skipped (`if: ... && !github.event.release.prerelease`) so beta tags like `0.0.1-sha256-in-installer-beta` don't update the stable tap. + +## One-time setup + +This needs to happen exactly once. Until it's done, the `publish-homebrew` job will fail on the next release with a 404 on the tap-repo checkout (which is harmless — it doesn't block npm publish — but the job will appear red). + +### 1. Create the tap repository + +Create a new public repository under the AikidoSec organisation called **`homebrew-tap`** (the `homebrew-` prefix is required by `brew tap`; users will run `brew tap AikidoSec/tap` which expands to `AikidoSec/homebrew-tap`). + +``` +Owner: AikidoSec +Repository: homebrew-tap +Visibility: Public +Initialize: Yes (add a README — anything will do, the workflow only touches Formula/safe-chain.rb) +Default branch: main +``` + +No further repo configuration is required. The `Formula/` directory will be created by the workflow on the first publish. + +### 2. Create the token used by the workflow + +The workflow needs to push to `AikidoSec/homebrew-tap` from `AikidoSec/safe-chain`. The default `GITHUB_TOKEN` is scoped to the safe-chain repo only and cannot push cross-repo, so we need a token that grants write access to the tap repo. + +**Recommended:** a fine-grained personal access token from a maintenance bot account (or a deploy-token-style PAT), scoped to `AikidoSec/homebrew-tap` only. + +- Resource owner: `AikidoSec` +- Repository access: **Only select repositories** → `AikidoSec/homebrew-tap` +- Repository permissions: **Contents: Read and write** +- Expiration: 1 year (set a calendar reminder; expired tokens silently break releases) + +Alternative: a GitHub App installation with `contents:write` on the tap repo and a step that exchanges the App's private key for an installation token. More secure for an org-owned automation but more setup. The PAT path is fine for a low-blast-radius tap. + +### 3. Add the token as a secret on `AikidoSec/safe-chain` + +Settings → Secrets and variables → Actions → New repository secret: + +- Name: `HOMEBREW_TAP_TOKEN` +- Value: the PAT (or App installation token) from step 2 + +That's it. The next release published from `main` will populate `Formula/safe-chain.rb` in the tap. + +### 4. (Optional) Bootstrap with the current release + +If you want users to be able to `brew install AikidoSec/tap/safe-chain` immediately rather than waiting for the next release, manually trigger the workflow against the current tag: + +```sh +gh workflow run "Create Release" --repo AikidoSec/safe-chain --ref +``` + +Or simply cut a patch release; the next normal release will populate the tap. + +## How users install + +```sh +brew install AikidoSec/tap/safe-chain +safe-chain setup +``` + +`brew install` downloads the prebuilt platform-specific binary from the GitHub release (verified against the SHA256 in the formula) and places it on PATH as `safe-chain`. The user then runs `safe-chain setup` (or `safe-chain setup-ci`) to install the shell aliases that wrap `npm`, `yarn`, `pnpm`, `pip`, etc. This second step is the same as the one in the `curl | sh` install path; Homebrew doesn't (and shouldn't) modify user shell rc files at install time. + +## How users upgrade + +```sh +brew upgrade safe-chain +``` + +The formula's `livecheck` block uses GitHub's `releases/latest` strategy, so `brew outdated` and Homebrew's auto-bump tooling pick up new versions automatically once the tap repo has been updated by the release workflow. + +## Testing the formula locally + +```sh +brew tap AikidoSec/tap +brew install --build-from-source AikidoSec/tap/safe-chain +brew test AikidoSec/tap/safe-chain +brew style $(brew --repository)/Library/Taps/aikidosec/homebrew-tap/Formula/safe-chain.rb +brew audit --new --formula AikidoSec/tap/safe-chain +``` + +The PR that wired this up (#TBD) validated all four of these against the 1.5.3 release on macOS arm64 (Homebrew 5.1.11). + +## Future work + +- **Submission to `homebrew-core`:** A custom tap is the lowest-friction path. If we ever want `brew install safe-chain` (no tap prefix) to work, the formula needs to be submitted to Homebrew's central `homebrew-core` repo. That requires the project to meet [Homebrew's notability criteria](https://docs.brew.sh/Acceptable-Formulae) and the formula to build from source (no prebuilt binaries) — which would mean either compiling the Node + pkg bundling in-formula or rewriting the wrapper in a compiled language. Not in scope right now. +- **winget and Chocolatey:** Also requested in [#372](https://github.com/AikidoSec/safe-chain/issues/372). Each has its own manifest format and release-time automation; they should be separate PRs.