From 45c8fdfdf9d9f9229c2312460afc0c15eefd084e Mon Sep 17 00:00:00 2001 From: Greg Magolan Date: Tue, 23 Jun 2026 13:37:48 -0700 Subject: [PATCH 1/6] ci: add macOS matrix coverage on main pushes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an `os` dimension to the preset CI matrix that expands to both ubuntu-latest and macos-latest on main pushes, and stays ubuntu-only on PRs. macOS runners are billed at 10x Linux on GHA, so PR cost is unchanged; only post-merge runs pay for the macOS legs. This catches Apple-Silicon-only breakage that the Linux matrix can't — e.g. the gcr.io/distroless/base linux/arm64/v8 manifest bug that broke `aspect build //...` for Go projects on Macs while CI stayed green. Also makes the actionlint download OS/arch-aware (uname-derived) so the lint step works on the macOS arm64 runners. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yaml | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 96c2e16a..2ed549ec 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -33,7 +33,12 @@ jobs: - ruby - scala - kitchen-sink - runs-on: ubuntu-latest + # macOS runners are billed at 10x Linux on GHA, so only exercise them + # on main pushes (where Apple-Silicon-only breakage like the + # distroless linux/arm64/v8 manifest bug would otherwise slip through); + # PRs stay Linux-only. + os: ${{ (github.event_name == 'push' && github.ref == 'refs/heads/main') && fromJSON('["ubuntu-latest","macos-latest"]') || fromJSON('["ubuntu-latest"]') }} + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v6 @@ -78,8 +83,16 @@ jobs: env: ACTIONLINT_VERSION: 1.7.7 run: | + case "$(uname -s)" in + Darwin) os=darwin ;; + *) os=linux ;; + esac + case "$(uname -m)" in + arm64|aarch64) arch=arm64 ;; + *) arch=amd64 ;; + esac curl -fsSL -o actionlint.tar.gz \ - "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" + "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_${os}_${arch}.tar.gz" tar -xzf actionlint.tar.gz actionlint ./actionlint -color .github/workflows/*.yaml From f9cd7db1e4ff5bdd0af24b0c4e2ed6d6470bb825 Mon Sep 17 00:00:00 2001 From: Greg Magolan Date: Tue, 23 Jun 2026 14:08:27 -0700 Subject: [PATCH 2/6] ci: authenticate launcher/bazelisk GitHub API calls The macOS legs failed at the render step with "403 API rate limit exceeded": on the first-ever macOS run the cache is cold, so all 12 presets stampede the unauthenticated GitHub releases API (60 req/hr by IP) that the aspect-launcher and bazelisk use to resolve their versions. Set GH_TOKEN / GITHUB_TOKEN / BAZELISK_GITHUB_TOKEN from the job token so those calls get the 5000 req/hr authenticated limit, and add the contents:read permission the token needs. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yaml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2ed549ec..fd1aa39d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -10,6 +10,17 @@ concurrency: permissions: id-token: write + contents: read + +# The aspect-launcher and bazelisk resolve their versions via the GitHub +# releases API. Unauthenticated that API is capped at 60 req/hr by source IP, +# which the macOS legs blow through on a cold cache (all presets stampede the +# API at once on the first-ever macOS run) → "403 API rate limit exceeded". +# Authenticate those calls with the job token to get the 5000 req/hr limit. +env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BAZELISK_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} jobs: # Render every preset from the local template tree (via the shared renderer the From 3676fc7d9b7a543477493c3b75406c5d51a0e0c7 Mon Sep 17 00:00:00 2001 From: Greg Magolan Date: Tue, 23 Jun 2026 15:11:51 -0700 Subject: [PATCH 3/6] fix(template): LLVM 19 + rules_cc 0.2.18 for macOS 15 SDK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hermetic C++ toolchain failed to build on macOS 15.5 / Xcode 16.4 (surfaced by the new macOS CI legs) for cpp, rust, and kitchen-sink: 1. clang 15 can't parse the macOS 15 SDK's libc++ headers ("unknown type name '__remove_cv'"). Bump to LLVM 19.1.7 — clang 19 handles the SDK, and 19.1.7 ships linux-x64 + darwin-arm64 + darwin-x64, so the per-platform version map collapses to a single entry. 2. toolchains_llvm 1.8.0's generated cc_toolchain selects on the rules_cc target //cc/toolchains/args/archiver_flags:use_libtool_on_macos_setting, which rules_cc 0.2.19 renamed away → macOS analysis failed with "no such target". Pin rules_cc to 0.2.18 (newest version that still has it) and widen its gate from `cpp` to `cpp or (rust and lint)` to match the condition under which the LLVM toolchain is registered — rust+lint pulls in the toolchain too, so it needs the same pin. Verified locally on macOS 15.5 SDK (Apple Silicon): cpp builds + tests pass, rust builds (incl. the C++-backed formatter that previously failed). Co-Authored-By: Claude Opus 4.8 (1M context) --- template/MODULE.bazel | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/template/MODULE.bazel b/template/MODULE.bazel index dd46dbce..35d84802 100644 --- a/template/MODULE.bazel +++ b/template/MODULE.bazel @@ -60,8 +60,13 @@ bazel_dep(name = "aspect_rules_swc", version = "2.7.2") {% if python %} bazel_dep(name = "aspect_rules_py", version = "2.0.0-alpha.3") {% endif %} -{% if cpp %} -bazel_dep(name = "rules_cc", version = "0.2.19") +{% if cpp or (rust and lint) %} +# Pinned to match the LLVM toolchain below (registered for the same condition). +# toolchains_llvm 1.8.0's generated cc_toolchain selects on the rules_cc target +# `//cc/toolchains/args/archiver_flags:use_libtool_on_macos_setting`, which +# rules_cc 0.2.19 renamed away — so 0.2.18 is the newest compatible version. +# Without this, macOS analysis fails: "no such target ...use_libtool_on_macos_setting". +bazel_dep(name = "rules_cc", version = "0.2.18") {% endif %} {% if cpp or (rust and lint) %} # Hermetic C++ toolchain. Also needed by rust+lint: it links rules_rust's @@ -193,11 +198,11 @@ register_toolchains("@uv//:all") # fuller toolchain from the root module takes priority and fixes that link. llvm = use_extension("@toolchains_llvm//toolchain/extensions:llvm.bzl", "llvm") llvm.toolchain( - # NB: llvm doesn't release for all platforms on every patch release + # clang 19 — clang 15 can't parse the macOS 15 SDK's libc++ headers + # ("unknown type name '__remove_cv'"). 19.1.7 is the last 19.1.x patch that + # ships linux-x64 + darwin-arm64 + darwin-x64, so one entry covers all. llvm_versions = { - "": "15.0.6", - "darwin-aarch64": "15.0.7", - "darwin-x86_64": "15.0.7", + "": "19.1.7", }, ) use_repo(llvm, "llvm_toolchain", "llvm_toolchain_llvm") From d3be212234f17d0274973d837285dcbdfbe26b0c Mon Sep 17 00:00:00 2001 From: Greg Magolan Date: Tue, 23 Jun 2026 15:18:10 -0700 Subject: [PATCH 4/6] ci: namespace task:name by OS so status checks don't mingle The per-task --task:name values were keyed only on the preset, so a macOS leg and its Linux counterpart emitted the same task name (e.g. test-go), colliding in the PR summary comment / status checks. Append matrix.os to each so they're distinct (test-go-ubuntu-latest vs test-go-macos-latest). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yaml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index fd1aa39d..3673f039 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -77,10 +77,10 @@ jobs: git -c user.email=ci@aspect.build -c user.name=CI commit -q -m "rendered ${{ matrix.preset }}" # Normalize (buildifier + gazelle + format), mirroring the publish job. bazel run --run_in_cwd @buildifier_prebuilt//buildifier -- -r . || true - aspect gazelle --task:name gazelle-${{ matrix.preset }} --check-only=false || true + aspect gazelle --task:name gazelle-${{ matrix.preset }}-${{ matrix.os }} --check-only=false || true case "${{ matrix.preset }}" in minimal|scala|ruby) ;; - *) aspect format --task:name format-${{ matrix.preset }} --scope=all || true ;; + *) aspect format --task:name format-${{ matrix.preset }}-${{ matrix.os }} --scope=all || true ;; esac git add -A git -c user.email=ci@aspect.build -c user.name=CI commit -q --amend --no-edit || true @@ -110,7 +110,7 @@ jobs: # No separate build step: `aspect test` builds everything it needs. - name: Test working-directory: ${{ env.WS }} - run: aspect test --task:name test-${{ matrix.preset }} -- //... + run: aspect test --task:name test-${{ matrix.preset }}-${{ matrix.os }} -- //... # The steps below run the SAME tasks the generated project's CI # (template/.github/workflows/ci.yaml) runs, with the same per-preset @@ -121,12 +121,12 @@ jobs: # buildifier always runs (Starlark; only needs buildifier_prebuilt). - name: Buildifier working-directory: ${{ env.WS }} - run: aspect buildifier --task:name buildifier-${{ matrix.preset }} --scope=all + run: aspect buildifier --task:name buildifier-${{ matrix.preset }}-${{ matrix.os }} --scope=all # gazelle runs for every preset (the stamped CI always has a gazelle job). - name: Gazelle (no changes expected) working-directory: ${{ env.WS }} - run: aspect gazelle --task:name gazelle-check-${{ matrix.preset }} + run: aspect gazelle --task:name gazelle-check-${{ matrix.preset }}-${{ matrix.os }} # Lint runs only for presets with a wired rules_lint aspect (matches the # stamped CI's lint-job gating and .aspect/config.axl's aspects list). @@ -134,21 +134,21 @@ jobs: - name: Lint if: ${{ !contains(fromJSON('["minimal","go","scala"]'), matrix.preset) }} working-directory: ${{ env.WS }} - run: aspect lint --task:name lint-${{ matrix.preset }} -- //... + run: aspect lint --task:name lint-${{ matrix.preset }}-${{ matrix.os }} -- //... # format runs for every preset with a wired source formatter. scala/ruby # have none; minimal has no languages (buildifier covers its Starlark). - name: Format (no changes expected) if: ${{ !contains(fromJSON('["minimal","scala","ruby"]'), matrix.preset) }} working-directory: ${{ env.WS }} - run: aspect format --task:name format-${{ matrix.preset }} --scope=all + run: aspect format --task:name format-${{ matrix.preset }}-${{ matrix.os }} --scope=all # delivery runs for presets whose stamped CI has a delivery job (oci/stamp). # --track-state=false --mode=always mirrors the stamped delivery job. - name: Delivery if: ${{ contains(fromJSON('["go","kitchen-sink"]'), matrix.preset) }} working-directory: ${{ env.WS }} - run: aspect delivery --task:name delivery-${{ matrix.preset }} --track-state=false --mode=always + run: aspect delivery --task:name delivery-${{ matrix.preset }}-${{ matrix.os }} --track-state=false --mode=always # Execute the preset's user story — an executable-Markdown tutorial that # builds/tests/extends the generated project. Run with `sh` (the ~~~ alias From 599a311cf5de4b02349bfea63879c1a6c5b9e962 Mon Sep 17 00:00:00 2001 From: Greg Magolan Date: Tue, 23 Jun 2026 15:49:50 -0700 Subject: [PATCH 5/6] ci: skip clang-tidy lint on macOS (rules_lint #924) The clang-tidy lint aspect fails on macOS for cpp and kitchen-sink: rules_lint's _update_flag strips absolute `-isystem` paths as if they were MSVC `/flags`, dropping the macOS SDK libc++ include so clang-tidy can't find /. It's an unfixed upstream bug with no released fix and no public knob on the aspect. Skip the Lint step for those two presets on macOS legs only; build, test, and format still run there. Re-enable once https://github.com/aspect-build/rules_lint/issues/924 ships (PR #779). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yaml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 3673f039..50df6ae5 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -131,8 +131,15 @@ jobs: # Lint runs only for presets with a wired rules_lint aspect (matches the # stamped CI's lint-job gating and .aspect/config.axl's aspects list). # go/scala have no linter; rust lints via clippy (aspect_rules_lint_rust). + # + # The clang-tidy aspect (cpp, and cpp-in-kitchen-sink) is skipped on macOS: + # rules_lint's _update_flag strips absolute `-isystem` paths as if they were + # MSVC `/flags`, so the macOS SDK libc++ include is dropped and clang-tidy + # can't find /. Build/test/format still run on macOS for + # cpp. Re-enable once https://github.com/aspect-build/rules_lint/issues/924 + # ships (PR #779). - name: Lint - if: ${{ !contains(fromJSON('["minimal","go","scala"]'), matrix.preset) }} + if: ${{ !contains(fromJSON('["minimal","go","scala"]'), matrix.preset) && !(matrix.os == 'macos-latest' && contains(fromJSON('["cpp","kitchen-sink"]'), matrix.preset)) }} working-directory: ${{ env.WS }} run: aspect lint --task:name lint-${{ matrix.preset }}-${{ matrix.os }} -- //... From 5be0550edfb5ab34501a8e1b81522630ae228593 Mon Sep 17 00:00:00 2001 From: Greg Magolan Date: Tue, 23 Jun 2026 16:08:15 -0700 Subject: [PATCH 6/6] ci: disable per-task GitHub status comments/checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each preset runs ~7 aspect tasks across 12 presets × 2 OSes; every task posting a GitHub status comment + check run hammered the App-installation API limit (HTTP 403 "API rate limit exceeded for installation"). Disable --github-status-comments and --github-status-checks on every task call — the GHA check itself is the source of truth for the matrix. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yaml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 50df6ae5..608da74c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -77,10 +77,10 @@ jobs: git -c user.email=ci@aspect.build -c user.name=CI commit -q -m "rendered ${{ matrix.preset }}" # Normalize (buildifier + gazelle + format), mirroring the publish job. bazel run --run_in_cwd @buildifier_prebuilt//buildifier -- -r . || true - aspect gazelle --task:name gazelle-${{ matrix.preset }}-${{ matrix.os }} --check-only=false || true + aspect gazelle --task:name gazelle-${{ matrix.preset }}-${{ matrix.os }} --github-status-comments:enabled=false --github-status-checks:enabled=false --check-only=false || true case "${{ matrix.preset }}" in minimal|scala|ruby) ;; - *) aspect format --task:name format-${{ matrix.preset }}-${{ matrix.os }} --scope=all || true ;; + *) aspect format --task:name format-${{ matrix.preset }}-${{ matrix.os }} --github-status-comments:enabled=false --github-status-checks:enabled=false --scope=all || true ;; esac git add -A git -c user.email=ci@aspect.build -c user.name=CI commit -q --amend --no-edit || true @@ -110,7 +110,7 @@ jobs: # No separate build step: `aspect test` builds everything it needs. - name: Test working-directory: ${{ env.WS }} - run: aspect test --task:name test-${{ matrix.preset }}-${{ matrix.os }} -- //... + run: aspect test --task:name test-${{ matrix.preset }}-${{ matrix.os }} --github-status-comments:enabled=false --github-status-checks:enabled=false -- //... # The steps below run the SAME tasks the generated project's CI # (template/.github/workflows/ci.yaml) runs, with the same per-preset @@ -121,12 +121,12 @@ jobs: # buildifier always runs (Starlark; only needs buildifier_prebuilt). - name: Buildifier working-directory: ${{ env.WS }} - run: aspect buildifier --task:name buildifier-${{ matrix.preset }}-${{ matrix.os }} --scope=all + run: aspect buildifier --task:name buildifier-${{ matrix.preset }}-${{ matrix.os }} --github-status-comments:enabled=false --github-status-checks:enabled=false --scope=all # gazelle runs for every preset (the stamped CI always has a gazelle job). - name: Gazelle (no changes expected) working-directory: ${{ env.WS }} - run: aspect gazelle --task:name gazelle-check-${{ matrix.preset }}-${{ matrix.os }} + run: aspect gazelle --task:name gazelle-check-${{ matrix.preset }}-${{ matrix.os }} --github-status-comments:enabled=false --github-status-checks:enabled=false # Lint runs only for presets with a wired rules_lint aspect (matches the # stamped CI's lint-job gating and .aspect/config.axl's aspects list). @@ -141,21 +141,21 @@ jobs: - name: Lint if: ${{ !contains(fromJSON('["minimal","go","scala"]'), matrix.preset) && !(matrix.os == 'macos-latest' && contains(fromJSON('["cpp","kitchen-sink"]'), matrix.preset)) }} working-directory: ${{ env.WS }} - run: aspect lint --task:name lint-${{ matrix.preset }}-${{ matrix.os }} -- //... + run: aspect lint --task:name lint-${{ matrix.preset }}-${{ matrix.os }} --github-status-comments:enabled=false --github-status-checks:enabled=false -- //... # format runs for every preset with a wired source formatter. scala/ruby # have none; minimal has no languages (buildifier covers its Starlark). - name: Format (no changes expected) if: ${{ !contains(fromJSON('["minimal","scala","ruby"]'), matrix.preset) }} working-directory: ${{ env.WS }} - run: aspect format --task:name format-${{ matrix.preset }}-${{ matrix.os }} --scope=all + run: aspect format --task:name format-${{ matrix.preset }}-${{ matrix.os }} --github-status-comments:enabled=false --github-status-checks:enabled=false --scope=all # delivery runs for presets whose stamped CI has a delivery job (oci/stamp). # --track-state=false --mode=always mirrors the stamped delivery job. - name: Delivery if: ${{ contains(fromJSON('["go","kitchen-sink"]'), matrix.preset) }} working-directory: ${{ env.WS }} - run: aspect delivery --task:name delivery-${{ matrix.preset }}-${{ matrix.os }} --track-state=false --mode=always + run: aspect delivery --task:name delivery-${{ matrix.preset }}-${{ matrix.os }} --github-status-comments:enabled=false --github-status-checks:enabled=false --track-state=false --mode=always # Execute the preset's user story — an executable-Markdown tutorial that # builds/tests/extends the generated project. Run with `sh` (the ~~~ alias