chore(ci): serve the Intel Mac artifact from the Homebrew formula (#308)
## Summary
The release matrix builds `x86_64-apple-darwin` as of #306, so every release from the next one on publishes `all-smi-macos-x86_64.zip`. The formula update workflow only ever knew about the aarch64 zip, so Intel Macs are served nothing. This teaches the workflow to publish the Intel artifact into the tap, and to behave predictably in every intermediate state along the way.
The formula itself lives in the external repo `lablup/homebrew-tap`, so it is deliberately not touched here. The exact stanza a maintainer needs to add is below, to be applied on purpose rather than by an automated run against a production tap.
## Two premises in the issue turned out to be wrong
**`set_artifact` does not need reworking.** The issue says it "asserts exactly ONE matching url stanza ... so it cannot express two macOS artifacts today". That assertion is per artifact file name, not per platform, and `all-smi-macos-aarch64.zip` and `all-smi-macos-x86_64.zip` are not substrings of each other, so each one addresses exactly one url stanza once both exist. Verified by running the shipped step against a two-stanza fixture: each macOS stanza got its own url and its own sha256, with no cross-contamination. The only change made to it is that its stanza counting moved out into a `count_artifact` helper, which the new Intel branch reuses to ask whether the tap has a stanza at all. The uniqueness assertion is unchanged and still fires: a fixture with two Intel stanzas is refused with "expected exactly one url stanza for all-smi-macos-x86_64.zip, found 2".
**The formula does not need an `on_arm`/`on_intel` DSL restructure.** The live formula uses `if Hardware::CPU.arm?` / `if Hardware::CPU.intel?` conditionals inside `on_macos do` / `on_linux do`, and `on_linux` already carries both branches. The macOS change is therefore to mirror a pattern the file already contains, which keeps the diff at four lines and leaves the working Linux stanzas alone. Introducing `on_arm`/`on_intel` blocks instead would rewrite stanzas that are currently correct and in use, for no behavioral gain.
## Formula diff for lablup/homebrew-tap (apply by hand, not from CI)
```diff
--- Formula/all-smi.rb
+++ Formula/all-smi.rb
@@ -9,6 +9,10 @@
url "https://github.com/lablup/all-smi/releases/download/v0.25.0/all-smi-macos-aarch64.zip"
sha256 "d090d6abbda5ac0c2fc06c0924b70845b32fd02ce6d1fca8f3ddf6fce1cad814"
end
+ if Hardware::CPU.intel?
+ url "https://github.com/lablup/all-smi/releases/download/v<VERSION>/all-smi-macos-x86_64.zip"
+ sha256 "<SHA256 of that asset>"
+ end
end
on_linux do
```
Apply it with the real url and checksum of a release that actually publishes the asset, not with a placeholder: between the tap commit and the next workflow run the formula is live for `brew install`, and a placeholder checksum is a broken install for Intel users during that window. `brew style Formula/all-smi.rb` passes on the result.
Recommended order:
1. Merge this PR.
2. Cut a release. It publishes `all-smi-macos-x86_64.zip`, and the formula update run for it ends red on the last step (see the state table below), having pushed the other three artifacts correctly.
3. Get the checksum: `curl -fLs https://github.com/lablup/all-smi/releases/download/v<VERSION>/all-smi-macos-x86_64.zip | shasum -a 256`, or read it from the `.sha256` sidecar the release already publishes.
4. Apply the diff above to `lablup/homebrew-tap` with that version and checksum.
5. Re-run "Update Homebrew Formula" via `workflow_dispatch` with the same tag. It is idempotent: the commit step already skips an empty diff, and validation should now report 4 sha256 stanzas.
6. Confirm `brew install lablup/tap/all-smi` on both an Apple Silicon and an Intel Mac.
## The four states, and why each behaves the way it does
The asset and the stanza arrive independently: the asset in a release, the stanza in a hand-written commit to the tap. All four combinations happen, so each one is answered explicitly rather than left to whatever the rewrite happens to do.
| release asset | formula stanza | behavior |
|---|---|---|
| present | present | update it, like any other artifact. Steady state. |
| absent | absent | no-op with a `::notice::`. This is every release up to and including v0.25.0, so re-running against those tags stays green. |
| present | absent | update and push the other three artifacts, then end the job red in a final step. |
| absent | present | refuse in the update step, before anything is pushed. |
**Why "asset present, stanza absent" pushes first and then fails.** Failing before the push would withhold a correct update from macOS aarch64 and both Linux targets over a tap that is merely incomplete, which punishes three platforms for a gap affecting a fourth. A warning alone would be true, printed, and forgotten, and Intel users would stay unserved for as long as nobody reads the logs, which is indefinitely. Ending the job red after the push keeps the tap current, puts the gap on the run summary where a failed check is visible, and makes the fix the four-line tap edit above plus an idempotent re-run. Since this workflow is triggered by `workflow_run` after every Release, that red run repeats every release until someone acts, which is the point.
**Why "asset absent, stanza present" hard-fails instead of skipping.** Skipping would bump `version` while the Intel url still points at an older release. `brew install` on an Intel Mac would then fetch that older binary, its recorded checksum would verify, and the user would silently get a version the formula does not claim to be. That state passes `ruby -c` and `brew style`, so nothing downstream catches it. It also means either the release lost an artifact it used to publish or the tag predates the artifact, both of which want a human rather than a best-effort write.
## What changed
- `.github/workflows/update_homebrew_formula.yml`, download step: reads the release's asset list through `gh api` and fetches `all-smi-macos-x86_64.zip` through the existing hard-failing `fetch` when it is listed. Presence is decided from the asset list rather than from a failed download because `curl` cannot tell "this release never published that asset" apart from "the network dropped", and reading a transient failure as absence is how an artifact stops being published without anyone noticing. The asset list is captured into a variable before the `grep`, because a pipeline inside `if` suppresses errexit and would reintroduce exactly that conflation through the API call. The match is `grep -qx` because the release also carries `<asset>.sha256` sidecars that an unanchored match would accept as the asset itself.
- Update step: `count_artifact` extracted from `set_artifact` (which is otherwise unchanged) so the Intel branch can distinguish "no stanza yet" from "malformed formula", plus the four-state decision above.
- Validate step: the expected sha256 count is derived from the checksums the run actually wrote instead of the hardcoded `3`, so it is 4 when the Intel stanza is updated and 3 when it is skipped, and it will not go stale the next time an artifact is added. A new guard additionally requires every `releases/download` url in the formula to name the version being written, which catches a stanza left behind at an older release even when every checksum in the file verifies. That is the same class of failure as the mlxcel corruption the file's existing comments document, caught from the other end.
- New final step `Report unpublished Intel macOS artifact`, gated on `env.mac_x86_state == 'stanza-missing'`, which is what turns the third state red after the push.
## Test plan
The shell logic was executed, not just reasoned about. `run-workflow-steps.sh` extracts the `Update formula` and `Validate updated formula` bodies straight out of the YAML being committed and runs them against fixture copies of the live tap formula, so the thing under test is the file in this PR rather than a paraphrase of it.
- [x] state 1, two-stanza fixture with the asset present: green, both macOS stanzas rewritten to the new version with their own checksums, 4 sha256 stanzas, `ruby -c` and `brew style` both clean.
- [x] state 2, live one-stanza fixture with the asset absent: green, `::notice::`, macOS section untouched, 3 sha256 stanzas.
- [x] state 3, live one-stanza fixture with the asset present: `::warning::`, formula valid at 3 sha256 stanzas, validation passes, push proceeds, job then fails on the final gate.
- [x] state 4, two-stanza fixture with the asset absent: fails in the update step with the version-skew error, before the commit step is reached.
- [x] malformed tap with two Intel stanzas: refused by `set_artifact`'s unchanged uniqueness assertion.
- [x] a formula whose Intel url is left at v0.25.0 while `version` says 0.26.0: caught by the new url guard (4 release urls, 3 matching the version). The sha256 count guard catches the same situation independently, verified by softening the state-4 refusal to a skip and watching validation reject the result at "expected 3 sha256 stanzas, found 4".
- [x] asset probe against real releases: `v0.25.0` reports the Intel asset absent (it is), the aarch64 asset present, and a nonexistent tag hard-fails rather than being read as "asset absent". `curl` of the real aarch64 zip reproduces the exact checksum currently in the tap, `d090d6ab...`, so the fetch path is verified against production data.
- [x] `unzip -qqt` on a truncated zip returns 9, so the existing integrity check that the Intel fetch reuses does catch a partial download.
- [x] `actionlint`: no new findings. The three `SC2086` infos it reports are present on `main` unchanged.
- [x] BSD awk only, no GNU extensions, and bash 3.2 compatible, matching what `macos-latest` provides.
Not verifiable before a release exists: the end-to-end `brew install` on both architectures, which is step 6 of the sequence above.
Closes #308 J
Jeongkyu Shin committed
16b564c157ff39f492d9133af0a8a31c094851c6
Parent: 5cc7807
Committed by GitHub <noreply@github.com>
on 8/5/2026, 5:50:14 AM