Skip to content
Draft
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
105 changes: 77 additions & 28 deletions eng/homebrew/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@ brew install --cask aspire # stable

## Supported Platforms

macOS only (arm64, x64). The cask uses `arch arm: "arm64", intel: "x64"` for URL templating.
macOS only (arm64, x64). The cask uses `arch arm: "arm64", intel: "x64"`
for URL templating and declares `depends_on :macos` so Homebrew's tap-syntax
check (`brew test-bot --only-tap-syntax`, which evaluates every cask on
every supported platform including Linux) doesn't try to load it on Linux
where the `arch` hash has no matching key.

## Artifact URLs

Expand Down Expand Up @@ -59,40 +63,85 @@ Where arch is `arm64` or `x64`.
| `azure-pipelines.yml` (prepare stage) | Stable or prerelease casks (artifacts only) | — |
| `release-publish-nuget.yml` (release) | — | Stable cask only |

Publishing submits a PR to `Homebrew/homebrew-cask` using the GitHub REST API:

1. Forks `Homebrew/homebrew-cask` (idempotent — reuses existing fork)
2. Creates or resets a branch named `aspire-{version}`
3. Copies the generated cask to `Casks/a/aspire.rb`
4. Reuses the existing open PR for that branch when present
5. Force-pushes the same branch for reruns; if prior PRs from that branch were closed, the publish step opens a fresh PR and marks the old ones as superseded
6. Opens a PR with title `aspire {version}` when none exists

Prepare validation currently runs:

1. `ruby -c` for syntax validation
2. `brew style --fix` on the generated cask
3. `brew audit --cask --online local/aspire/aspire`
- Adds `--new` only when the cask is absent upstream (existing casks fail the additional `--new` checks).
- Adds `--no-signing` in offline mode (PR-artifact validation), because the served archives are local loopback URLs of unsigned PR builds rather than notarized release assets.
4. `HOMEBREW_NO_INSTALL_FROM_API=1 brew install --cask ...` followed by uninstall validation

PR artifact validation uses the same shared script and local tap, but rewrites
the cask URLs to loopback archive URLs and runs in offline mode (see above).
Release preparation keeps the full online signing audit.

To dogfood a GitHub Actions artifact locally, download the `homebrew-cask-prerelease`
artifact and the `cli-native-archives-osx-*` artifacts into the same parent directory, then run:
### Submission

Publishing is performed by [`brew bump-cask-pr`](https://docs.brew.sh/Manpage),
the canonical Homebrew tool for cask version bumps. The bumper handles
forking, branch naming, the conventional `<cask> <version>` commit message,
the conventional PR title and body, and upstream rate limiting.

`publish-homebrew.yml` exposes a `dryRun` parameter that defaults to `true`.
Callers must explicitly pass `dryRun: false` to open a real upstream PR;
this keeps feature-branch and dogfood-build invocations from accidentally
submitting to `Homebrew/homebrew-cask`.

- **`dryRun: true` (default)** — runs `brew bump-cask-pr --write-only --commit
--no-fork` to produce the bump diff and the conventional commit in a local
tap checkout. No GitHub writes; safe to invoke from any branch. The
resulting `git show HEAD` is logged so the operator can review exactly
what the real submission would do.
- **`dryRun: false`** — runs `brew bump-cask-pr --version=<v> aspire` to
submit the bump upstream. Hard-fails if the current branch is not a
production branch or if the validation summary reports
`isStableRelease: false` — defense in depth on top of the artifact-name
gate (`homebrew-cask-stable` is the only artifact name the release flow
consumes).

`--sha256` is intentionally not passed: for multi-arch casks the flag has
no per-arch form, so the bumper falls back to downloading the binaries and
computing checksums itself. This matches how `Homebrew/actions:bump-packages`
invokes `brew bump --open-pr --casks`.

### Initial cask submission is manual

This pipeline only handles **version bumps** for an existing upstream cask.
The very first submission of the `aspire` cask to
`Homebrew/homebrew-cask` is a human-driven, one-time operation —
`brew bump-cask-pr` requires the cask to already exist in the upstream tap,
and first-time submissions require additional `--new`-specific audit checks
plus maintainer review that aren't appropriate for an automated pipeline.

### Prepare validation

The prepare stage validates the cask end-to-end, mirroring the checks that
`Homebrew/homebrew-cask` CI runs on a PR (see
[`ci.yml`](https://github.com/Homebrew/homebrew-cask/blob/main/.github/workflows/ci.yml)).
The output `validation-summary.json` is consumed by the publish stage as a
gate; its `schemaVersion` must match what the publish step expects.

1. `ruby -c aspire.rb` — Ruby syntax check
2. `brew style --fix` — Cookbook formatting rules
3. `brew test-bot --tap local/aspire --only-tap-syntax` — tap-level
cross-platform syntax check; this is the upstream job that catches
"Invalid cask (Linux on …)" or other platform-evaluation failures
4. `brew audit --cask --online --signing` — the same audit flags upstream's
per-cask CI matrix runs for an existing-cask bump. Offline mode (PR-build
validation) substitutes `--no-signing` because the served archives are
loopback URLs of unsigned PR builds rather than notarized release assets.
5. `HOMEBREW_NO_INSTALL_FROM_API=1 brew install --cask …` followed by
uninstall validation, only in `Full` mode.

PR artifact validation uses the same shared script and local tap, but
rewrites the cask URLs to loopback archive URLs and runs in offline mode
(see above). Release preparation keeps the full online signing audit.

To dogfood a GitHub Actions artifact locally, download the
`homebrew-cask-prerelease` artifact and the `cli-native-archives-osx-*`
artifacts into the same parent directory, then run:

```bash
./dogfood.sh --archive-root ..
```

## Open Items

- [ ] Submit initial `aspire` cask PR to `Homebrew/homebrew-cask` for acceptance
- [ ] (Future) Decide whether to add a separate prerelease cask (for example, `aspire@prerelease`) and update pipelines/docs accordingly
- [ ] Configure `aspire-homebrew-bot-pat` secret in the pipeline variable group
- [ ] Submit the initial `aspire` cask PR to `Homebrew/homebrew-cask`
manually for first-time acceptance. The pipeline only handles
subsequent version bumps.
- [ ] (Future) Decide whether to add a separate prerelease cask (for
example, `aspire@prerelease`) and update pipelines/docs accordingly.
- [ ] Configure the `aspire-homebrew-bot-pat` secret in the pipeline
variable group.

## References

Expand Down
8 changes: 8 additions & 0 deletions eng/homebrew/aspire.rb.template
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ cask "aspire" do
skip "ci.dot.net artifact storage has no version index; Aspire release automation manages cask updates"
end

# Aspire CLI is distributed for macOS only via Homebrew cask. Without this,
# brew test-bot --only-tap-syntax tries to evaluate the cask on Linux too —
# the arch hash above has no Linux keys, so the cask becomes invalid and the
# syntax job fails (see Homebrew/homebrew-cask CI: "Invalid cask (Linux on
# ...)"). See https://docs.brew.sh/Cask-Cookbook#depends_on for the cross-
# platform / single-platform conventions.
depends_on :macos

binary "aspire"

# Write the sidecar file
Expand Down
87 changes: 31 additions & 56 deletions eng/homebrew/validate-cask-artifact.sh
Original file line number Diff line number Diff line change
Expand Up @@ -76,46 +76,17 @@ if [[ "$VALIDATION_MODE" == "GenerateOnly" ]]; then
exit 0
fi

# Aspire CLI is distributed for macOS only via Homebrew cask, and the
# pipeline only submits version bumps for an already-merged upstream cask.
# Initial cask submissions to Homebrew/homebrew-cask are handled manually by
# a human contributor; this script intentionally does not include the
# `brew audit --new` checks that only apply to first-time submissions.

read_cask_version() {
local cask_file="$1"
awk -F'"' '/^[[:space:]]*version[[:space:]]+"/ { print $2; exit }' "$cask_file"
}

# Detects whether the Aspire cask already exists in the upstream
# Homebrew/homebrew-cask repository. Echoes 'true' if the cask is new (404 from
# the contents API), 'false' if it already exists (200), and exits non-zero on
# any other response so we don't silently mis-classify the audit mode.
#
# brew audit treats new submissions and updates differently: `--new` enables
# extra checks (e.g. canonical naming) that fail on existing casks. The PR body
# template downstream also keys "validated as a new upstream cask" off the same
# signal, so getting this wrong produces misleading review text in the bot PR.
detect_upstream_cask_is_new() {
local cask_name="$1"
local first_letter="${cask_name:0:1}"
local target_path="Casks/$first_letter/$cask_name.rb"
local api_url="https://api.github.com/repos/Homebrew/homebrew-cask/contents/$target_path"
local status_code
local curl_args=(-sS -o /dev/null -w "%{http_code}")

# Authenticate when a token is available. Unauthenticated GitHub API requests
# are throttled to 60/hour shared across the runner IP pool, which routinely
# produces 403s on hosted CI; an installation/PAT/Actions token raises that
# to 1000/hour or more. GH_TOKEN is the convention used by the `gh` CLI and
# by most repo scripts; GITHUB_TOKEN is the default exposed by GitHub Actions.
local token="${GH_TOKEN:-${GITHUB_TOKEN:-}}"
if [[ -n "$token" ]]; then
curl_args+=(-H "Authorization: Bearer $token" -H "X-GitHub-Api-Version: 2022-11-28")
fi

status_code="$(curl "${curl_args[@]}" "$api_url")"
case "$status_code" in
200) echo "false" ;;
404) echo "true" ;;
*) echo "Error: could not determine whether $target_path exists upstream (HTTP $status_code)" >&2; exit 1 ;;
esac
}

find_archive() {
local archive_root="$1"
local archive_name="$2"
Expand Down Expand Up @@ -294,6 +265,17 @@ cp "$TAPPED_CASK_PATH" "$CASK_FILE"
brew style --cask "$TAPPED_CASK_PATH"
echo "brew style --fix reported no offenses after copying aspire.rb into a temporary local tap."

echo ""
# Tap-level syntax check: validates that every cask in the tap can be parsed
# on every supported platform. Upstream's syntax (macos-N) CI job runs the
# equivalent command and rejects casks that fail to evaluate on a non-host
# platform (e.g. a macOS-only cask with no `depends_on macos:` declared).
# Matches `Homebrew/homebrew-cask:.github/workflows/ci.yml` `brew test-bot
# --only-tap-syntax` step.
echo "Running brew test-bot --only-tap-syntax against local tap..."
brew test-bot --tap "$TAP_NAME" --only-tap-syntax
echo "brew test-bot --only-tap-syntax succeeded."

AUDIT_CASK_PATH="$TAPPED_CASK_PATH"
AUDIT_NOTE=""

Expand Down Expand Up @@ -327,27 +309,17 @@ if [[ "$VALIDATION_MODE" == "Offline" ]]; then
fi

echo ""
if [[ "$VALIDATION_MODE" == "Offline" ]]; then
echo "Skipping upstream cask probe because validation mode is Offline; running standard audit."
IS_NEW_CASK="false"
else
echo "Determining whether $CASK_NAME is a new upstream cask..."
IS_NEW_CASK="$(detect_upstream_cask_is_new "$CASK_NAME")"
if [[ "$IS_NEW_CASK" == "true" ]]; then
echo "Detected new upstream cask; running new-cask audit."
else
echo "Detected existing upstream cask; running standard audit."
fi
fi

echo ""
# Match the audit arg set that Homebrew/homebrew-cask CI runs for an existing
# cask bump: `brew audit --cask --online --signing <cask>`. (`--new` is added
# upstream only when the cask is being submitted for the first time, which is
# a human-driven path not covered by this pipeline.) `--no-signing` is used in
# Offline mode because the local archive server set up earlier serves the
# binaries verbatim, which doesn't preserve the macOS notarization metadata
# upstream's signing check inspects.
echo "Auditing cask via local tap..."
audit_args=(--cask --online)
if [[ "$IS_NEW_CASK" == "true" ]]; then
audit_args+=(--new)
fi
audit_args=(--cask --online --signing)
if [[ "$VALIDATION_MODE" == "Offline" ]]; then
audit_args+=(--no-signing)
audit_args=(--cask --online --no-signing)
fi
audit_args+=("$TAP_NAME/$CASK_NAME")
audit_command="brew audit ${audit_args[*]}"
Expand Down Expand Up @@ -412,10 +384,9 @@ if [[ -n "$SUMMARY_PATH" && "$VALIDATION_MODE" == "Full" ]]; then
mkdir -p "$(dirname "$SUMMARY_PATH")"
cat > "$SUMMARY_PATH" <<EOF
{
"schemaVersion": 1,
"schemaVersion": 2,
"validatedByPreparePipeline": true,
"isStableRelease": $is_stable_release,
"isNewCask": $IS_NEW_CASK,
"checks": {
"rubySyntax": {
"status": "passed",
Expand All @@ -425,6 +396,10 @@ if [[ -n "$SUMMARY_PATH" && "$VALIDATION_MODE" == "Full" ]]; then
"status": "passed",
"details": "brew style --fix reported no offenses after copying aspire.rb into a temporary local tap"
},
"tapSyntax": {
"status": "passed",
"details": "brew test-bot --tap $TAP_NAME --only-tap-syntax"
},
"brewAudit": {
"status": "passed",
"details": "$audit_command"
Expand Down
Loading
Loading