diff --git a/eng/homebrew/README.md b/eng/homebrew/README.md index 47e83a8d75f..ee9842e9ab9 100644 --- a/eng/homebrew/README.md +++ b/eng/homebrew/README.md @@ -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 @@ -59,30 +63,71 @@ 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 ` ` 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= 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 .. @@ -90,9 +135,13 @@ artifact and the `cli-native-archives-osx-*` artifacts into the same parent dire ## 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 diff --git a/eng/homebrew/aspire.rb.template b/eng/homebrew/aspire.rb.template index 19264d0ce1a..dda183328f8 100644 --- a/eng/homebrew/aspire.rb.template +++ b/eng/homebrew/aspire.rb.template @@ -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 diff --git a/eng/homebrew/validate-cask-artifact.sh b/eng/homebrew/validate-cask-artifact.sh index ec8b5b345b6..c27e00ac4d7 100755 --- a/eng/homebrew/validate-cask-artifact.sh +++ b/eng/homebrew/validate-cask-artifact.sh @@ -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" @@ -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="" @@ -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 `. (`--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[*]}" @@ -412,10 +384,9 @@ if [[ -n "$SUMMARY_PATH" && "$VALIDATION_MODE" == "Full" ]]; then mkdir -p "$(dirname "$SUMMARY_PATH")" cat > "$SUMMARY_PATH" < `), the +# conventional PR title and body, and upstream rate limiting. This template +# does not produce a hand-built PR body. +# # NOTE: Cask syntax validation and brew audit are performed during build # in prepare-homebrew-cask.yml. This template only handles submission. +# +# This template only submits *version bumps* for an existing upstream cask. +# The initial submission of the `aspire` cask to Homebrew/homebrew-cask is a +# human-driven, manual operation; the pipeline does not handle first-time +# cask submissions. parameters: - name: caskArtifactPath @@ -15,8 +26,13 @@ parameters: displayName: 'Package version (used in PR title). If empty, extracted from cask file.' - name: dryRun type: boolean - default: false - displayName: 'Skip actual submit (for testing)' + # Default to dry-run: callers must explicitly opt in to opening a real + # upstream PR. Keeps feature-branch and dogfood-build invocations from + # accidentally submitting to Homebrew/homebrew-cask, which is what + # produced the rapid-fire bot PRs that resulted in PR #265387's account + # block. + default: true + displayName: 'When true, run brew bump-cask-pr --write-only locally (no GitHub writes). When false, open a real PR upstream.' steps: - powershell: | @@ -83,7 +99,16 @@ steps: exit 1 } - $requiredChecks = @('rubySyntax', 'brewStyle', 'brewAudit', 'brewInstall', 'brewUninstall') + # schemaVersion 2 added the tapSyntax check (brew test-bot --only-tap-syntax) + # and dropped the isNewCask field. Rejecting older artifacts here forces a + # fresh build before publish, which guarantees the cask was validated with + # the current rule set. + if ([int]$summary.schemaVersion -ne 2) { + Write-Error "Homebrew validation summary schemaVersion is '$($summary.schemaVersion)'; expected 2. Re-run the prepare stage to produce an artifact compatible with this publish step." + exit 1 + } + + $requiredChecks = @('rubySyntax', 'brewStyle', 'tapSyntax', 'brewAudit', 'brewInstall', 'brewUninstall') foreach ($checkName in $requiredChecks) { $check = $summary.checks.$checkName if ($null -eq $check) { @@ -171,367 +196,123 @@ steps: $version = '$(HomebrewVersion)' $caskFile = '$(HomebrewCaskFile)' $caskPath = '$(HomebrewCaskPath)' - $validationSummaryPath = '$(HomebrewValidationSummaryPath)' $caskName = $caskFile -replace '\.rb$', '' + $dryRun = '${{ parameters.dryRun }}' -eq 'true' + $isProductionBranch = '$(_IsProductionBranch)' -eq 'true' $token = $env:HOMEBREW_CASK_GITHUB_TOKEN - $validationSummary = Get-Content -Raw $validationSummaryPath | ConvertFrom-Json - - if ([string]::IsNullOrWhiteSpace($token)) { - Write-Error "GitHub token for Homebrew cask PR is not set. Ensure the variable group is configured." - exit 1 - } - - Write-Host "Submitting cask PR to Homebrew/homebrew-cask" - - $headers = @{ - Authorization = "Bearer $token" - Accept = "application/vnd.github+json" - "User-Agent" = "dotnet-aspire-release-pipeline" - "X-GitHub-Api-Version" = "2022-11-28" - } - - function Get-StatusCode { - param([System.Management.Automation.ErrorRecord]$ErrorRecord) - - if ($null -ne $ErrorRecord.Exception.Response) { - return [int]$ErrorRecord.Exception.Response.StatusCode - } - - return $null - } - - function Invoke-GitHubApi { - param( - [string]$Method, - [string]$Uri, - [object]$Body - ) - - $params = @{ - Method = $Method - Uri = $Uri - Headers = $headers - ErrorAction = 'Stop' - } - - if ($null -ne $Body) { - $params.Body = ($Body | ConvertTo-Json -Depth 10) - $params.ContentType = 'application/json' - } - - return Invoke-RestMethod @params - } - - function Invoke-GitHubGraphQL { - param( - [string]$Query, - [object]$Variables - ) - - $response = Invoke-GitHubApi -Method Post -Uri 'https://api.github.com/graphql' -Body @{ - query = $Query - variables = $Variables - } - - if ($response.errors) { - $messages = ($response.errors | ForEach-Object { $_.message }) -join '; ' - throw "GitHub GraphQL request failed: $messages" - } - - return $response.data - } - - function Test-GitHubRepositoryExists { - param([string]$RepositoryApiUrl) + # When dryRun is false the validation-summary check earlier in this + # template has run and exported its path, so we can rely on the summary + # being present and well-formed here. When dryRun is true we skip the + # validation-summary check (its checks require runInstallTest=true which + # is only set on production builds), so isStableRelease is read from the + # summary opportunistically and defaults to false for dry-run logging. + $isStableRelease = $false + $summaryPath = Join-Path '${{ parameters.caskArtifactPath }}' 'validation-summary.json' + if (Test-Path $summaryPath) { try { - Invoke-GitHubApi -Method Get -Uri $RepositoryApiUrl -Body $null | Out-Null - return $true - } - catch { - $statusCode = Get-StatusCode -ErrorRecord $_ - if ($statusCode -eq 404) { - return $false - } - - throw - } - } - - function Get-Checkmark { - param([bool]$Condition) - - if ($Condition) { - return 'x' - } - - return ' ' - } - - function Get-HomebrewPullRequestBody { - param( - [string]$Version, - [string]$CaskName, - [string]$CaskFile, - [bool]$IsStable, - [psobject]$ValidationSummary, - [int[]]$SupersededPullRequestNumbers = @() - ) - - $stableCheck = Get-Checkmark -Condition $IsStable - $auditDetails = "$($ValidationSummary.checks.brewAudit.details)" - $lines = @( - "Update $CaskName to version $Version.", - "", - "**Validation performed by Aspire CI before submission**", - "- [$stableCheck] The submission is for a stable version or a documented exception.", - "- [x] ``$($ValidationSummary.checks.rubySyntax.details)`` succeeded.", - "- [x] ``brew style --fix`` reported no offenses after copying ``$CaskFile`` into a temporary local tap.", - "- [x] ``$auditDetails`` worked successfully.", - "- [x] ``$($ValidationSummary.checks.brewInstall.details)`` worked successfully.", - "- [x] ``$($ValidationSummary.checks.brewUninstall.details)`` worked successfully.", - "- [x] Release-pipeline download URL validation succeeded for the cask URLs." - ) - - if ([bool]$ValidationSummary.isNewCask) { - $lines += @( - "", - "This submission was validated as a new upstream cask during Aspire CI." - ) - } - - if ($SupersededPullRequestNumbers.Count -gt 0) { - $supersededList = $SupersededPullRequestNumbers | ForEach-Object { "#$_" } - $lines += @( - "", - "Supersedes prior closed PRs: $($supersededList -join ', ')." - ) - } - - $lines += @( - "", - "-----", - "", - "Generated by the Aspire release pipeline from ``$CaskFile``." - ) - - return ($lines -join "`n") - } - - $botUser = (Invoke-GitHubApi -Method Get -Uri 'https://api.github.com/user' -Body $null).login - $upstreamRepo = Invoke-GitHubApi -Method Get -Uri 'https://api.github.com/repos/Homebrew/homebrew-cask' -Body $null - $defaultBranch = $upstreamRepo.default_branch - $defaultBranchForUri = [Uri]::EscapeDataString($defaultBranch) - Write-Host "Authenticated as: $botUser" - Write-Host "Upstream default branch: $defaultBranch" - - $forkApiUrl = "https://api.github.com/repos/$botUser/homebrew-cask" - if (-not (Test-GitHubRepositoryExists -RepositoryApiUrl $forkApiUrl)) { - Write-Host "Forking Homebrew/homebrew-cask into $botUser/homebrew-cask..." - Invoke-GitHubApi -Method Post -Uri 'https://api.github.com/repos/Homebrew/homebrew-cask/forks' -Body @{ default_branch_only = $true } | Out-Null - - $forkReady = $false - for ($attempt = 1; $attempt -le 12; $attempt++) { - Start-Sleep -Seconds 5 - - if (Test-GitHubRepositoryExists -RepositoryApiUrl $forkApiUrl) { - $forkReady = $true - break - } - - Write-Host "Waiting for fork creation ($attempt/12)..." - } - - if (-not $forkReady) { - Write-Error "Timed out waiting for fork $botUser/homebrew-cask to become available." + $summary = Get-Content -Raw $summaryPath | ConvertFrom-Json + $isStableRelease = [bool]$summary.isStableRelease + } catch { } + } + + # Hard gate on stable + production branch for real upstream submission. + # Homebrew/homebrew-cask only accepts stable releases (preview / nightly + # tokens belong on @beta / @nightly variants, which this pipeline does + # not produce); attempting to submit prerelease versions upstream is what + # produced the templated "stable version: [ ] unchecked" PR body that + # Homebrew Lead Maintainers flagged on PR #265387. + if (-not $dryRun) { + if (-not $isProductionBranch) { + Write-Error "Refusing to submit cask PR: dryRun is false but this is not a production branch. Set dryRun=true to exercise the bumper without contacting GitHub." exit 1 } - } else { - Write-Host "Fork already exists: $botUser/homebrew-cask" - } - $branchName = "$($caskName -replace '@', '-')-$version" - $firstLetter = $caskName.Substring(0, 1) - $targetPath = "Casks/$firstLetter/$caskFile" - $targetPathForUri = "Casks/$firstLetter/$([Uri]::EscapeDataString($caskFile))" - $upstreamBranch = Invoke-GitHubApi -Method Get -Uri "https://api.github.com/repos/Homebrew/homebrew-cask/branches/$defaultBranchForUri" -Body $null - $upstreamSha = $upstreamBranch.commit.sha - $caskContent = (Get-Content -Path $caskPath -Raw) -replace "`r`n", "`n" - if (-not $caskContent.EndsWith("`n")) { - $caskContent += "`n" - } - - $upstreamContent = $null - try { - $upstreamFile = Invoke-GitHubApi -Method Get -Uri "https://api.github.com/repos/Homebrew/homebrew-cask/contents/${targetPathForUri}?ref=$defaultBranchForUri" -Body $null - if ($upstreamFile.content) { - $upstreamContent = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String(($upstreamFile.content -replace '\s', ''))) -replace "`r`n", "`n" - } - } - catch { - $statusCode = Get-StatusCode -ErrorRecord $_ - if ($statusCode -ne 404) { - throw - } - } - - if ($upstreamContent -eq $caskContent) { - Write-Host "No Homebrew changes to submit; upstream already matches $caskName $version." - exit 0 - } - - $branchRefUri = "https://api.github.com/repos/$botUser/homebrew-cask/git/refs/heads/$branchName" - try { - Invoke-GitHubApi -Method Get -Uri $branchRefUri -Body $null | Out-Null - Invoke-GitHubApi -Method Patch -Uri $branchRefUri -Body @{ - sha = $upstreamSha - force = $true - } | Out-Null - Write-Host "Reset branch $branchName to upstream/$defaultBranch" - } - catch { - $statusCode = Get-StatusCode -ErrorRecord $_ - if ($statusCode -eq 404) { - Invoke-GitHubApi -Method Post -Uri "https://api.github.com/repos/$botUser/homebrew-cask/git/refs" -Body @{ - ref = "refs/heads/$branchName" - sha = $upstreamSha - } | Out-Null - Write-Host "Created branch $branchName from upstream/$defaultBranch" - } - else { - throw + if (-not $isStableRelease) { + Write-Error "Refusing to submit cask PR: validation summary reports isStableRelease=false. Homebrew/homebrew-cask accepts stable releases only; preview / nightly versions are not in scope for the aspire cask." + exit 1 } - } - $branchFileUri = "https://api.github.com/repos/$botUser/homebrew-cask/contents/${targetPathForUri}?ref=$([Uri]::EscapeDataString($branchName))" - $existingBranchFileSha = $null - try { - $branchFile = Invoke-GitHubApi -Method Get -Uri $branchFileUri -Body $null - $existingBranchFileSha = $branchFile.sha - } - catch { - $statusCode = Get-StatusCode -ErrorRecord $_ - if ($statusCode -ne 404) { - throw + if ([string]::IsNullOrWhiteSpace($token)) { + Write-Error "HOMEBREW_CASK_GITHUB_TOKEN is not set; cannot submit cask PR." + exit 1 } } - $putBody = @{ - message = "$caskName $version" - content = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($caskContent)) - branch = $branchName - } + # Tap Homebrew/homebrew-cask if it isn't already tapped; brew bump-cask-pr + # needs an existing cask file in the tap to bump from. This is a no-op + # when the tap is already present. + Write-Host "Tapping Homebrew/homebrew-cask..." + & brew tap Homebrew/homebrew-cask + if ($LASTEXITCODE -ne 0) { + Write-Error "brew tap Homebrew/homebrew-cask failed (exit $LASTEXITCODE)" + exit $LASTEXITCODE + } + + # brew bump-cask-pr reads HOMEBREW_GITHUB_API_TOKEN for fork/branch/PR + # creation. Map the AzDO secret variable name onto what brew expects. + if (-not [string]::IsNullOrWhiteSpace($token)) { + $env:HOMEBREW_GITHUB_API_TOKEN = $token + } + + # No --sha256 argument: for multi-arch casks (arch arm:/intel:) the + # single --sha256 flag has no per-arch form and brew warns "Multiple + # checksum replacements required; ignoring specified `--sha256` + # argument" if it is passed. brew bump-cask-pr handles this case by + # downloading the new version's binaries from the expanded URL pattern + # and computing checksums itself — the same approach + # Homebrew/actions:bump-packages/action.yml uses for `brew bump + # --open-pr --casks`. The prepare stage already validated those URLs + # are reachable. + $bumpArgs = @( + 'bump-cask-pr' + "--version=$version" + ) - if ($existingBranchFileSha) { - $putBody.sha = $existingBranchFileSha + if ($dryRun) { + # --write-only produces the bump diff in the local tap checkout but + # stops short of forking, branching, or opening a PR. + # --commit (paired with --write-only) generates the same conventional + # ` ` commit the real submission would produce, so the + # dry-run exercises the full diff + commit-message path that would + # land upstream. + # --no-fork skips the upfront fork-existence probe; the bumper would + # otherwise check whether the bot's fork exists even in --write-only + # mode, which is unnecessary work for a dry-run. + $bumpArgs += '--write-only' + $bumpArgs += '--commit' + $bumpArgs += '--no-fork' + Write-Host "Dry-run: running 'brew $($bumpArgs -join ' ')' (no GitHub writes)" + } else { + Write-Host "Submitting cask PR to Homebrew/homebrew-cask via 'brew bump-cask-pr'" + Write-Host " cask: $caskName" + Write-Host " version: $version" + Write-Host " stable: $isStableRelease" } - $contentResult = Invoke-GitHubApi -Method Put -Uri "https://api.github.com/repos/$botUser/homebrew-cask/contents/$targetPathForUri" -Body $putBody - Write-Host "Updated $targetPath on branch $branchName with commit $($contentResult.commit.sha)" - - $compareUri = "https://api.github.com/repos/Homebrew/homebrew-cask/compare/$defaultBranchForUri...$([Uri]::EscapeDataString("${botUser}:$branchName"))" - $compareResult = $null - for ($attempt = 1; $attempt -le 10; $attempt++) { - try { - $compareResult = Invoke-GitHubApi -Method Get -Uri $compareUri -Body $null - if ($compareResult.ahead_by -gt 0) { - break - } + $bumpArgs += $caskName - Write-Host "Waiting for branch comparison to reflect new commit ($attempt/10)..." - } - catch { - $statusCode = Get-StatusCode -ErrorRecord $_ - Write-Host "Branch comparison is not ready yet ($statusCode) ($attempt/10)" - } - - Start-Sleep -Seconds 3 + & brew @bumpArgs + $bumpExit = $LASTEXITCODE + if ($bumpExit -ne 0) { + Write-Error "brew bump-cask-pr exited with code $bumpExit" + exit $bumpExit } - if ($null -eq $compareResult -or $compareResult.ahead_by -le 0) { - Write-Error "Branch $branchName does not contain commits ahead of Homebrew/homebrew-cask:$defaultBranch, so a PR cannot be created." - exit 1 - } - - Write-Host "Branch comparison status: $($compareResult.status); ahead_by=$($compareResult.ahead_by)" - - # Create PR (or update existing) - $encodedHead = [Uri]::EscapeDataString("${botUser}:$branchName") - $existingPRsResponse = Invoke-GitHubApi -Method Get -Uri "https://api.github.com/repos/Homebrew/homebrew-cask/pulls?head=$encodedHead&state=all" -Body $null - $existingPRs = @( - foreach ($existingPr in $existingPRsResponse) { - if ($null -ne $existingPr) { - $existingPr - } - } - ) - $existingOpenPr = $existingPRs | Where-Object { $_.state -eq 'open' } | Select-Object -First 1 - $existingClosedPrs = @($existingPRs | Where-Object { $_.state -ne 'open' } | Sort-Object updated_at -Descending) - $supersededPullRequestNumbers = @( - foreach ($closedPr in $existingClosedPrs) { - [Convert]::ToInt32("$($closedPr.number)", [System.Globalization.CultureInfo]::InvariantCulture) - } - ) - $isStableCask = [bool]$validationSummary.isStableRelease - $pullRequestBody = Get-HomebrewPullRequestBody -Version $version -CaskName $caskName -CaskFile $caskFile -IsStable $isStableCask -ValidationSummary $validationSummary -SupersededPullRequestNumbers $supersededPullRequestNumbers - - if ($existingOpenPr) { - $updatedPr = Invoke-GitHubApi -Method Patch -Uri "https://api.github.com/repos/Homebrew/homebrew-cask/pulls/$($existingOpenPr.number)" -Body @{ - title = "$caskName $version" - body = $pullRequestBody - } - - Write-Host "PR #$($existingOpenPr.number) already exists — updated title/body at $($updatedPr.html_url)" - - if (-not [bool]$existingOpenPr.draft) { - Write-Host "Converting existing PR #$($existingOpenPr.number) to draft..." - - $convertPullRequestToDraftMutation = 'mutation ConvertPullRequestToDraft($pullRequestId: ID!) { - convertPullRequestToDraft(input: { pullRequestId: $pullRequestId }) { - pullRequest { - number - isDraft - url - } - } - }' - - $graphQlData = Invoke-GitHubGraphQL -Query $convertPullRequestToDraftMutation -Variables @{ - pullRequestId = $existingOpenPr.node_id - } - - if (-not $graphQlData.convertPullRequestToDraft.pullRequest.isDraft) { - throw "Failed to convert PR #$($existingOpenPr.number) to draft." - } - - Write-Host "PR #$($existingOpenPr.number) is now draft." - } + if ($dryRun) { + $tapRoot = & brew --repository Homebrew/homebrew-cask + Write-Host "" + Write-Host "=== brew bump-cask-pr --write-only --commit produced commit ===" + & git -C $tapRoot --no-pager show --no-color HEAD + Write-Host "===============================================================" } else { - if ($existingClosedPrs.Count -gt 0) { - $closedPrSummary = ($existingClosedPrs | ForEach-Object { "#$($_.number)" }) -join ', ' - Write-Host "Found prior closed PRs for ${botUser}:${branchName}: $closedPrSummary" - } - - $createdPr = Invoke-GitHubApi -Method Post -Uri 'https://api.github.com/repos/Homebrew/homebrew-cask/pulls' -Body @{ - title = "$caskName $version" - body = $pullRequestBody - head = "${botUser}:$branchName" - base = $defaultBranch - draft = $true - } - - Write-Host "Draft PR created successfully: #$($createdPr.number) $($createdPr.html_url)" + Write-Host "brew bump-cask-pr completed successfully." } displayName: '🟣Submit PR to Homebrew/homebrew-cask' - # Guard: only submit to Homebrew from production branches. - # 1ES PT branch validation surfaces this as a non-blocking warning, which is too easy to miss; - # a hard condition stops accidental submissions from feature, fork or dogfood branches. - # _IsProductionBranch is defined in eng/pipelines/common-variables.yml. - condition: | - and( - succeeded(), - eq('${{ parameters.dryRun }}', 'false'), - eq(variables['_IsProductionBranch'], 'true') - ) + # No top-level branch gate: the script itself hard-fails when dryRun=false + # is invoked from a non-production branch. This keeps the dry-run path + # exerciseable from feature branches (the whole point of dry-run) while + # still blocking real submissions to non-production-branch builds. + condition: succeeded() env: HOMEBREW_CASK_GITHUB_TOKEN: $(aspire-homebrew-bot-pat)