From 3f5f5c5768331529fd5fc553fcab50174530e0fb Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Fri, 2 Oct 2026 18:28:45 -0700 Subject: [PATCH 1/2] fix(release): advance the Homebrew formula without a notarized macOS build update-homebrew needed attach-macos to succeed, and the renderer required every macOS checksum. Apple has held every app notarization since 2026-09-29 In Progress, so sign-macos failed on v0.6.0 and v0.6.1, the job was skipped, and the tap's Linux formula stayed on 0.5.1 while nothing about Linux was wrong. The job now waits for attach-macos to finish rather than succeed. It keeps always() fenced by create-release so failed validation still never publishes. Linux checksums are always required. macOS takes one of two complete forms: the release's own tarball and DMG, which render the formula and the cask together as before, or no macOS assets at all, in which case the renderer reads the macOS version and checksum from the formula already in the tap, pins them with a block-level version in on_macos, and leaves the cask untouched. A release carrying only one of the two macOS assets stops the job. Re-running sign-macos once Apple clears the queue still brings macOS and the cask up through attach-macos as RELEASING.md describes. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 75 +++++++---- docs/development/RELEASING.md | 8 +- packaging/homebrew/hypercolor.rb | 4 +- scripts/homebrew-formula.mjs | 82 ++++++++++-- scripts/tests/homebrew-formula.test.mjs | 89 ++++++++++++- scripts/tests/macos-ci-coverage.test.mjs | 10 +- scripts/tests/macos-release.test.mjs | 155 +++++++++++++++++------ 7 files changed, 338 insertions(+), 85 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 35ca034f7..da82ed97b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2856,11 +2856,17 @@ jobs: git push update-homebrew: + # Linux advances the tap on every stable release. macOS joins when the + # signed lane attached its assets; otherwise the renderer carries the + # published macOS stanza forward and leaves the cask alone, so a stalled + # notarization never holds the Linux formula back. if: >- + always() && + needs.create-release.result == 'success' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && startsWith(github.ref, 'refs/tags/') && !contains(github.ref_name, '-') - needs: attach-macos + needs: [create-release, attach-macos] runs-on: ubuntu-latest permissions: contents: read @@ -2882,22 +2888,34 @@ jobs: run: | set -euo pipefail mkdir -p release-artifacts - # macOS ships for Apple silicon only. - for platform in linux-amd64 linux-arm64 macos-arm64; do - tarball="hypercolor-${VERSION}-${platform}.tar.gz" + fetch() { gh release download "v${VERSION}" \ - --repo "${{ github.repository }}" \ - --pattern "${tarball}" \ - --dir release-artifacts - sha=$(sha256sum "release-artifacts/${tarball}" | cut -d' ' -f1) + --repo "${{ github.repository }}" --pattern "$1" --dir release-artifacts + sha256sum "release-artifacts/$1" | cut -d' ' -f1 + } + for platform in linux-amd64 linux-arm64; do + sha=$(fetch "hypercolor-${VERSION}-${platform}.tar.gz") echo "sha256_${platform//-/_}=${sha}" >> "$GITHUB_OUTPUT" echo " ${platform}: ${sha}" done + # macOS ships for Apple silicon only, and only once notarized. + macos_tarball="hypercolor-${VERSION}-macos-arm64.tar.gz" dmg="Hypercolor-${VERSION}-arm64.dmg" - gh release download "v${VERSION}" \ - --repo "${{ github.repository }}" --pattern "$dmg" --dir release-artifacts - sha=$(sha256sum "release-artifacts/${dmg}" | cut -d' ' -f1) - echo "sha256_dmg_arm64=${sha}" >> "$GITHUB_OUTPUT" + assets=$(gh release view "v${VERSION}" --repo "${{ github.repository }}" \ + --json assets --jq '.assets[].name') + has_tarball=false has_dmg=false + grep -qxF "$macos_tarball" <<<"$assets" && has_tarball=true + grep -qxF "$dmg" <<<"$assets" && has_dmg=true + if [[ "$has_tarball" != "$has_dmg" ]]; then + echo "::error::v${VERSION} carries only part of its macOS assets; the tap was not updated." + exit 1 + fi + if [[ "$has_tarball" == true ]]; then + echo "sha256_macos_arm64=$(fetch "$macos_tarball")" >> "$GITHUB_OUTPUT" + echo "sha256_dmg_arm64=$(fetch "$dmg")" >> "$GITHUB_OUTPUT" + else + echo "::warning::v${VERSION} has no signed macOS build; macOS stays on the published formula." + fi - name: Verify Homebrew tap token env: @@ -2935,16 +2953,20 @@ jobs: run: | set -euo pipefail mkdir -p homebrew-tap/Formula homebrew-tap/Casks - node scripts/homebrew-formula.mjs \ - --version "$VERSION" \ - --linux-amd64 "$SHA256_LINUX_AMD64" \ - --linux-arm64 "$SHA256_LINUX_ARM64" \ - --macos-arm64 "$SHA256_MACOS_ARM64" \ - --dmg-arm64 "$SHA256_DMG_ARM64" \ - --template packaging/homebrew/hypercolor.rb \ - --cask-template packaging/homebrew/hypercolor-app.rb \ - --output homebrew-tap/Formula/hypercolor.rb \ - --cask-output homebrew-tap/Casks/hypercolor-app.rb + args=(--version "$VERSION" + --linux-amd64 "$SHA256_LINUX_AMD64" --linux-arm64 "$SHA256_LINUX_ARM64" + --template packaging/homebrew/hypercolor.rb + --output homebrew-tap/Formula/hypercolor.rb) + if [[ -n "$SHA256_MACOS_ARM64" ]]; then + args+=(--macos-arm64 "$SHA256_MACOS_ARM64" --dmg-arm64 "$SHA256_DMG_ARM64" + --cask-template packaging/homebrew/hypercolor-app.rb + --cask-output homebrew-tap/Casks/hypercolor-app.rb) + else + # Read the published formula before the renderer overwrites it. + cp homebrew-tap/Formula/hypercolor.rb "${RUNNER_TEMP}/published-hypercolor.rb" + args+=(--carry-macos-from "${RUNNER_TEMP}/published-hypercolor.rb") + fi + node scripts/homebrew-formula.mjs "${args[@]}" echo "Rendered formula:" cat homebrew-tap/Formula/hypercolor.rb @@ -2961,6 +2983,7 @@ jobs: working-directory: homebrew-tap env: VERSION: ${{ steps.version.outputs.version }} + SHA256_MACOS_ARM64: ${{ steps.checksums.outputs.sha256_macos_arm64 }} run: | set -euo pipefail git config user.name "github-actions[bot]" @@ -2970,9 +2993,11 @@ jobs: echo "Homebrew packages already match v${VERSION}" exit 0 fi - git commit \ - -m "hypercolor: update to ${VERSION}" \ - -m "Update formula and cask from the published release checksums." + body="Update formula and cask from the published release checksums." + if [[ -z "$SHA256_MACOS_ARM64" ]]; then + body="Linux moves to ${VERSION}; macOS and the cask stay on the last notarized build." + fi + git commit -m "hypercolor: update to ${VERSION}" -m "$body" git push # ── Update Nix Release Pin ─────────────────────────────────────── diff --git a/docs/development/RELEASING.md b/docs/development/RELEASING.md index 51be00768..a2ea043ca 100644 --- a/docs/development/RELEASING.md +++ b/docs/development/RELEASING.md @@ -76,8 +76,12 @@ the release. `create-release` publishes the Linux and Windows artifacts without waiting for `sign-macos`, and npm, PyPI, the AUR, and Nix follow it. `attach-macos` then adds the signed arm64 tarball, its checksum, the DMG, and the DMG's notarization receipt to the same release once `sign-macos` -succeeds, uploading only assets the release does not already carry, and -`update-homebrew` runs after it. If `sign-macos` times out waiting on Apple, +succeeds, uploading only assets the release does not already carry. +`update-homebrew` waits for `attach-macos` to finish but not to succeed: the +Linux formula advances on every stable tag, and without a notarized macOS +build the renderer carries the published macOS stanza forward and leaves the +cask alone. A release carrying only part of its macOS assets stops the job +instead. If `sign-macos` times out waiting on Apple, check **Actions → macOS Notary Status → Run workflow**, which lists the team's submissions and their status. Once Apple has cleared the queue, use "Re-run failed jobs" on the tag's CI/CD run: the signing job picks up the diff --git a/packaging/homebrew/hypercolor.rb b/packaging/homebrew/hypercolor.rb index 3289a3f09..bee8cb46f 100644 --- a/packaging/homebrew/hypercolor.rb +++ b/packaging/homebrew/hypercolor.rb @@ -3,7 +3,9 @@ # Homebrew formula for Hypercolor. # -# scripts/homebrew-formula.mjs renders every platform from the same release. +# scripts/homebrew-formula.mjs renders Linux from every stable release. macOS +# comes from the same release when its notarized build shipped; otherwise the +# macOS stanza is carried forward from the formula already in the tap. class Hypercolor < Formula # Sequoia's symbolic version cannot distinguish 15.0 from the 15.2 floor. diff --git a/scripts/homebrew-formula.mjs b/scripts/homebrew-formula.mjs index 5c60d8637..866157a8f 100644 --- a/scripts/homebrew-formula.mjs +++ b/scripts/homebrew-formula.mjs @@ -1,9 +1,13 @@ #!/usr/bin/env node // Render the Homebrew formula for a stable Hypercolor release. // -// Formula and cask metadata come from the same published release. Every -// platform checksum is required so a partial release cannot advance the tap. -// macOS ships for Apple silicon only, so there is no Intel macOS input. +// Linux checksums always come from the release being published. macOS takes +// one of two complete forms: the release's own Apple silicon tarball and DMG, +// which renders the formula and the cask together, or no macOS input at all, +// which carries the macOS stanza forward from the formula already in the tap +// and leaves the cask alone. A release that is missing only part of its macOS +// assets never renders. macOS ships for Apple silicon only, so there is no +// Intel macOS input. // // node scripts/homebrew-formula.mjs \ // --version 0.5.0 \ @@ -12,6 +16,13 @@ // --macos-arm64 --dmg-arm64 \ // --cask-template packaging/homebrew/hypercolor-app.rb \ // --output hypercolor.rb --cask-output hypercolor-app.rb +// +// node scripts/homebrew-formula.mjs \ +// --version 0.5.1 \ +// --linux-amd64 --linux-arm64 \ +// --template packaging/homebrew/hypercolor.rb \ +// --carry-macos-from homebrew-tap/Formula/hypercolor.rb \ +// --output hypercolor.rb import { readFileSync, writeFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; @@ -20,8 +31,11 @@ const VERSION_PATTERN = /^\d+\.\d+\.\d+$/; const SHA256_PATTERN = /^[0-9a-f]{64}$/; const MACOS_SHAS = { arm64: 'SHA256_MACOS_ARM64' }; const LINUX_SHAS = { amd64: 'SHA256_LINUX_AMD64', arm64: 'SHA256_LINUX_ARM64' }; -const CLI_OPTIONS = ['version', 'linux-amd64', 'linux-arm64', 'macos-arm64', 'dmg-arm64', - 'template', 'output', 'cask-template', 'cask-output']; +const REQUIRED_OPTIONS = ['version', 'linux-amd64', 'linux-arm64', 'template', 'output']; +const MACOS_RELEASE_OPTIONS = ['macos-arm64', 'dmg-arm64', 'cask-template', 'cask-output']; +const CARRY_OPTION = 'carry-macos-from'; +const CLI_OPTIONS = [...REQUIRED_OPTIONS, ...MACOS_RELEASE_OPTIONS, CARRY_OPTION]; +const MACOS_BLOCK_PATTERN = /^ on_macos do\n([\s\S]*?)^ end\n/m; const LEFTOVER_PATTERN = /[A-Z0-9_]*PLACEHOLDER|SHA256_[A-Z0-9_]+/; class FormulaError extends Error {} @@ -46,13 +60,28 @@ function requirePlaceholder(template, placeholder) { } } -/** Render all architectures from the checksums of one published release. */ +/** + * Render every architecture. `macos` holds this release's checksums, or a + * `carried` stanza read from the published formula by `readPublishedMacos`. + */ export function renderFormula({ template, version, linux, macos }) { requireVersion(version, 'version'); for (const placeholder of ['VERSION_PLACEHOLDER', ...Object.values(LINUX_SHAS), ...Object.values(MACOS_SHAS)]) { requirePlaceholder(template, placeholder); } let formula = template.replace('VERSION_PLACEHOLDER', version); + if (macos?.carried) { + const carried = macos.carried; + requireVersion(carried.version, 'carried macOS version'); + const block = formula.match(MACOS_BLOCK_PATTERN); + if (!block || !block[1].includes(MACOS_SHAS.arm64)) { + throw new FormulaError('template has no on_macos download block to carry forward into'); + } + // A block-level version points the macOS URL at the carried release. + formula = formula.replace(block[0], + block[0].replace(' on_macos do\n', ` on_macos do\n version "${carried.version}"\n`)); + macos = { arm64: carried.arm64 }; + } for (const [platform, checksums, placeholders] of [ ['linux', linux, LINUX_SHAS], ['macos', macos, MACOS_SHAS], ]) { @@ -65,6 +94,21 @@ export function renderFormula({ template, version, linux, macos }) { return formula; } +/** Read the macOS version and checksum the published formula installs. */ +export function readPublishedMacos(formula) { + const block = formula.match(MACOS_BLOCK_PATTERN)?.[1]; + if (block === undefined) throw new FormulaError('published formula has no on_macos download block'); + const version = block.match(/^ version "([^"]+)"$/m)?.[1] ?? formula.match(/^ version "([^"]+)"$/m)?.[1]; + const checksums = [...block.matchAll(/sha256 "([^"]+)"/g)].map(match => match[1]); + if (checksums.length !== 1) { + throw new FormulaError(`published on_macos block must carry exactly one sha256, found ${checksums.length}`); + } + return { + version: requireVersion(version, 'published macOS version'), + arm64: requireSha(checksums[0], 'published macOS arm64 sha256'), + }; +} + function parseArgs(argv) { const args = {}; for (let index = 0; index < argv.length; index += 1) { @@ -81,16 +125,28 @@ function parseArgs(argv) { export function main(argv) { const args = parseArgs(argv); - for (const required of CLI_OPTIONS) { + for (const required of REQUIRED_OPTIONS) { if (args[required] === undefined) throw new FormulaError(`--${required} is required`); } + const macosGiven = MACOS_RELEASE_OPTIONS.filter(option => args[option] !== undefined); + const carry = args[CARRY_OPTION]; + if (carry !== undefined && macosGiven.length > 0) { + throw new FormulaError(`--${CARRY_OPTION} cannot be combined with --${macosGiven[0]}`); + } + if (carry === undefined && macosGiven.length !== MACOS_RELEASE_OPTIONS.length) { + const missing = MACOS_RELEASE_OPTIONS.find(option => args[option] === undefined); + throw new FormulaError(`--${missing} is required unless --${CARRY_OPTION} names the published formula`); + } + const linux = { amd64: args['linux-amd64'], arm64: args['linux-arm64'] }; const template = readFileSync(args.template, 'utf8'); - const formula = renderFormula({ - template, - version: args.version, - linux: { amd64: args['linux-amd64'], arm64: args['linux-arm64'] }, - macos: { arm64: args['macos-arm64'] }, - }); + if (carry !== undefined) { + const carried = readPublishedMacos(readFileSync(carry, 'utf8')); + const formula = renderFormula({ template, version: args.version, linux, macos: { carried } }); + writeFileSync(args.output, formula); + console.log(`wrote formula for ${args.version}; macOS stays on ${carried.version} and the cask is unchanged`); + return; + } + const formula = renderFormula({ template, version: args.version, linux, macos: { arm64: args['macos-arm64'] } }); const cask = renderCask({ template: readFileSync(args['cask-template'], 'utf8'), version: args.version, arm64: args['dmg-arm64'] }); writeFileSync(args.output, formula); diff --git a/scripts/tests/homebrew-formula.test.mjs b/scripts/tests/homebrew-formula.test.mjs index 74f3ca65a..371edfe06 100644 --- a/scripts/tests/homebrew-formula.test.mjs +++ b/scripts/tests/homebrew-formula.test.mjs @@ -1,11 +1,11 @@ import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; -import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import test from 'node:test'; import { fileURLToPath } from 'node:url'; -import { renderFormula, renderCask } from '../homebrew-formula.mjs'; +import { readPublishedMacos, renderFormula, renderCask } from '../homebrew-formula.mjs'; const repo = fileURLToPath(new URL('../../', import.meta.url)); const script = path.join(repo, 'scripts/homebrew-formula.mjs'); @@ -115,3 +115,88 @@ test('CLI validates both packages before writing either output', () => { rmSync(dir, { recursive: true, force: true }); } }); + +// The tap formula as the 0.5.x carry-forward renderer published it: Linux on +// the release version, macOS pinned to an older signed build. +const legacyPublished = `class Hypercolor < Formula + version "0.5.1" + + on_macos do + version "0.3.2" + depends_on macos: :sequoia + + if Hardware::CPU.arm? + url "https://github.com/hyperb1iss/hypercolor/releases/download/v#{version}/hypercolor-#{version}-macos-arm64.tar.gz" + sha256 "${sha('9')}" + end + end +end +`; + +test('carry-forward advances Linux and pins macOS to the published build', () => { + const carried = readPublishedMacos(legacyPublished); + assert.deepEqual(carried, { version: '0.3.2', arm64: sha('9') }); + const formula = renderFormula({ template, version: '0.6.1', linux, macos: { carried } }); + assert.match(formula, /^ version "0\.6\.1"$/m); + const macDownload = formula.match(/^ on_macos do\n([\s\S]*?)^ end\n/m)[1]; + assert.match(macDownload, /^ version "0\.3\.2"\n depends_on arch: :arm64$/m); + assert.match(macDownload, new RegExp(`-macos-arm64\\.tar\\.gz"\\n +sha256 "${sha('9')}"`)); + const linuxDownload = formula.match(/^ on_linux do\n([\s\S]*?)^ end\n/m)[1]; + assert.doesNotMatch(linuxDownload, /version "/); + assert.match(linuxDownload, new RegExp(`sha256 "${linux.amd64}"`)); + // The service block's on_macos stays untouched. + assert.equal([...formula.matchAll(/^ version "/gm)].length, 1); + assert.doesNotMatch(formula, /PLACEHOLDER|SHA256_/); +}); + +test('carry-forward is stable across consecutive releases', () => { + const full = renderFormula({ template, version: '0.6.0', linux, macos }); + assert.deepEqual(readPublishedMacos(full), { version: '0.6.0', arm64: macos.arm64 }); + const once = renderFormula({ template, version: '0.6.1', linux, macos: { carried: readPublishedMacos(full) } }); + const twice = renderFormula({ template, version: '0.6.2', linux, macos: { carried: readPublishedMacos(once) } }); + assert.deepEqual(readPublishedMacos(twice), { version: '0.6.0', arm64: macos.arm64 }); + assert.equal([...twice.matchAll(/^ version "/gm)].length, 1); +}); + +test('carry-forward refuses a published formula it cannot read', () => { + assert.throws(() => readPublishedMacos('class Hypercolor < Formula\nend\n'), /no on_macos download block/); + const twoShas = legacyPublished.replace(/(sha256 "9+")/, `$1\n sha256 "${sha('8')}"`); + assert.throws(() => readPublishedMacos(twoShas), /exactly one sha256, found 2/); + assert.throws(() => readPublishedMacos(legacyPublished.replace(sha('9'), 'invalid')), /published macOS arm64 sha256/); + assert.throws(() => renderFormula({ template, version: '0.6.1', linux, + macos: { carried: { version: '0.3.2-rc.1', arm64: sha('9') } } }), /carried macOS version/); +}); + +test('CLI carry mode writes only the formula and refuses mixed or partial macOS inputs', () => { + const dir = mkdtempSync(path.join(tmpdir(), 'homebrew-formula-carry-')); + try { + const published = path.join(dir, 'published.rb'); + writeFileSync(published, legacyPublished); + const formulaOutput = path.join(dir, 'hypercolor.rb'); + const caskOutput = path.join(dir, 'hypercolor-app.rb'); + const base = [script, '--version', '0.6.1', '--template', templatePath, + '--linux-amd64', linux.amd64, '--linux-arm64', linux.arm64, '--output', formulaOutput]; + const run = argv => spawnSync(process.execPath, argv, { encoding: 'utf8' }); + + const mixed = run([...base, '--carry-macos-from', published, '--macos-arm64', macos.arm64]); + assert.equal(mixed.status, 1); + assert.match(mixed.stderr, /cannot be combined with --macos-arm64/); + const partial = run([...base, '--macos-arm64', macos.arm64, '--cask-template', caskPath, + '--cask-output', caskOutput]); + assert.equal(partial.status, 1); + assert.match(partial.stderr, /--dmg-arm64 is required unless --carry-macos-from/); + const neither = run(base); + assert.equal(neither.status, 1); + assert.match(neither.stderr, /--macos-arm64 is required unless --carry-macos-from/); + assert.equal(existsSync(formulaOutput), false); + + const carried = run([...base, '--carry-macos-from', published]); + assert.equal(carried.status, 0, carried.stderr); + assert.match(carried.stdout, /macOS stays on 0\.3\.2 and the cask is unchanged/); + assert.equal(readFileSync(formulaOutput, 'utf8'), renderFormula({ template, version: '0.6.1', linux, + macos: { carried: readPublishedMacos(legacyPublished) } })); + assert.equal(existsSync(caskOutput), false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/scripts/tests/macos-ci-coverage.test.mjs b/scripts/tests/macos-ci-coverage.test.mjs index e008fe0a2..b93492f94 100644 --- a/scripts/tests/macos-ci-coverage.test.mjs +++ b/scripts/tests/macos-ci-coverage.test.mjs @@ -79,9 +79,13 @@ test('publication retains every validation gate while compilation overlaps it', test('only Homebrew waits for macOS; every other channel follows create-release', () => { const job = id => workflow.match(new RegExp(`^ ${id}:\\n([\\s\\S]*?)(?=^ [a-z][\\w-]*:|$(?![\\s\\S]))`, 'm'))?.[1]; const needs = id => job(id)?.match(/^ needs: (.+)$/m)?.[1]; - // The formula and cask need the macOS checksums, so Homebrew moves with - // the macOS assets; nothing else may depend on Apple. - assert.equal(needs('update-homebrew'), 'attach-macos'); + // Homebrew waits for attach-macos to finish but never for it to succeed: + // Linux advances on create-release alone and macOS joins once notarized. + // always() must stay fenced by create-release so failed validation never + // publishes. Nothing else may depend on Apple. + assert.equal(needs('update-homebrew'), '[create-release, attach-macos]'); + const homebrewCondition = job('update-homebrew').split(' needs:')[0]; + assert.match(homebrewCondition, /always\(\) &&\n\s+needs\.create-release\.result == 'success'/); assert.equal(needs('update-aur'), 'create-release'); assert.equal(needs('update-nix'), 'create-release'); assert.equal(needs('publish-npm'), '[sdk, create-release]'); diff --git a/scripts/tests/macos-release.test.mjs b/scripts/tests/macos-release.test.mjs index c688ad92e..9436f264d 100644 --- a/scripts/tests/macos-release.test.mjs +++ b/scripts/tests/macos-release.test.mjs @@ -104,7 +104,7 @@ test('native app version validation accepts an exact stamped prerelease', () => assert.match(step, /if \(\$version -ne \$cargoVersion -and \$baseVersion -ne \$cargoVersion\)/); }); -test('Homebrew checksum step supplies every value consumed by its renderer', () => { +function homebrewJob() { const repo = fileURLToPath(new URL('../../', import.meta.url)); const workflow = readFileSync(path.join(repo, '.github/workflows/ci.yml'), 'utf8'); const job = workflow.match(/^ update-homebrew:\n([\s\S]*?)(?=^ [a-z][\w-]*:|$(?![\s\S]))/m)?.[1]; @@ -117,59 +117,136 @@ test('Homebrew checksum step supplies every value consumed by its renderer', () assert.ok(run, 'step has a shell body'); return run.replace(/^ /gm, '').replaceAll('${{ github.repository }}', 'hyperb1iss/hypercolor'); }; + return { repo, workflow, job, steps, shell }; +} + +// Run the job's checksum and render steps against a release that carries +// `assets`, with `published` standing in for the tap's current formula. +function runHomebrewJob({ assets, published }) { + const { repo, steps, shell } = homebrewJob(); const dir = mkdtempSync(path.join(tmpdir(), 'homebrew-workflow-')); - try { - const output = path.join(dir, 'outputs'); - const env = { ...process.env, VERSION: '0.5.2', GITHUB_OUTPUT: output }; - // Substitute only the download transport; execute the workflow shell itself. - const gh = `gh() { - local artifact='' directory='' - while (( $# )); do - case "$1" in - --pattern) artifact="$2"; shift ;; - --dir) directory="$2"; shift ;; - esac - shift - done - test -n "$artifact" && test -n "$directory" || return 99 - printf 'fixture:%s' "$artifact" > "$directory/$artifact" - } - `; - const checksums = spawnSync('bash', ['-c', gh + shell(steps.get('Download release tarballs and compute checksums'))], - { cwd: dir, env, encoding: 'utf8' }); - assert.equal(checksums.status, 0, checksums.stderr); - const values = Object.fromEntries(readFileSync(output, 'utf8').trim().split('\n').map(line => line.split('='))); - const expectedAssets = { - sha256_linux_amd64: 'hypercolor-0.5.2-linux-amd64.tar.gz', - sha256_linux_arm64: 'hypercolor-0.5.2-linux-arm64.tar.gz', - sha256_macos_arm64: 'hypercolor-0.5.2-macos-arm64.tar.gz', - sha256_dmg_arm64: 'Hypercolor-0.5.2-arm64.dmg', - }; - assert.deepEqual(Object.keys(values).sort(), Object.keys(expectedAssets).sort()); - for (const [key, asset] of Object.entries(expectedAssets)) { - assert.equal(values[key], createHash('sha256').update(`fixture:${asset}`).digest('hex')); - } + const output = path.join(dir, 'outputs'); + const env = { ...process.env, VERSION: '0.5.2', GITHUB_OUTPUT: output, RUNNER_TEMP: dir, + FIXTURE_ASSETS: assets.join('\n') }; + // Substitute only the release transport; execute the workflow shell itself. + const gh = `gh() { + if [[ "$1 $2" == "release view" ]]; then + printf '%s\\n' "$FIXTURE_ASSETS" + return 0 + fi + local artifact='' directory='' + while (( $# )); do + case "$1" in + --pattern) artifact="$2"; shift ;; + --dir) directory="$2"; shift ;; + esac + shift + done + test -n "$artifact" && test -n "$directory" || return 99 + grep -qxF "$artifact" <<<"$FIXTURE_ASSETS" || return 98 + printf 'fixture:%s' "$artifact" > "$directory/$artifact" + } + `; + const checksums = spawnSync('bash', ['-c', gh + shell(steps.get('Download release tarballs and compute checksums'))], + { cwd: dir, env, encoding: 'utf8' }); + const values = checksums.status === 0 + ? Object.fromEntries(readFileSync(output, 'utf8').trim().split('\n').map(line => line.split('='))) + : {}; + let rendered; + if (checksums.status === 0) { const renderStep = steps.get('Render formula and cask'); for (const [, name, key] of renderStep.matchAll(/^ (SHA256_\w+): \$\{\{ steps.checksums.outputs.(\w+) \}\}/gm)) { - assert.ok(values[key], `renderer input ${name} has an upstream value`); - env[name] = values[key]; + env[name] = values[key] ?? ''; } mkdirSync(path.join(dir, 'scripts')); copyFileSync(path.join(repo, 'scripts/homebrew-formula.mjs'), path.join(dir, 'scripts/homebrew-formula.mjs')); symlinkSync(path.join(repo, 'packaging'), path.join(dir, 'packaging')); - const rendered = spawnSync('bash', ['-c', shell(renderStep)], { cwd: dir, env, encoding: 'utf8' }); - assert.equal(rendered.status, 0, rendered.stderr); + mkdirSync(path.join(dir, 'homebrew-tap/Formula'), { recursive: true }); + mkdirSync(path.join(dir, 'homebrew-tap/Casks'), { recursive: true }); + writeFileSync(path.join(dir, 'homebrew-tap/Formula/hypercolor.rb'), published.formula); + writeFileSync(path.join(dir, 'homebrew-tap/Casks/hypercolor-app.rb'), published.cask); + rendered = spawnSync('bash', ['-c', shell(renderStep)], { cwd: dir, env, encoding: 'utf8' }); + } + return { dir, checksums, values, rendered, + read: file => readFileSync(path.join(dir, 'homebrew-tap', file), 'utf8') }; +} + +const linuxAssets = ['hypercolor-0.5.2-linux-amd64.tar.gz', 'hypercolor-0.5.2-linux-arm64.tar.gz']; +const macosAssets = ['hypercolor-0.5.2-macos-arm64.tar.gz', 'Hypercolor-0.5.2-arm64.dmg']; +const fixtureSha = asset => createHash('sha256').update(`fixture:${asset}`).digest('hex'); +const publishedTap = { + formula: `class Hypercolor < Formula + version "0.5.1" + + on_macos do + version "0.3.2" + url "https://github.com/hyperb1iss/hypercolor/releases/download/v#{version}/hypercolor-#{version}-macos-arm64.tar.gz" + sha256 "${'9'.repeat(64)}" + end +end +`, + cask: 'cask "hypercolor-app" do\n version "0.3.2"\nend\n', +}; + +test('Homebrew checksum step supplies every value consumed by its renderer', () => { + const run = runHomebrewJob({ assets: [...linuxAssets, ...macosAssets], published: publishedTap }); + try { + assert.equal(run.checksums.status, 0, run.checksums.stderr); + const expectedAssets = { + sha256_linux_amd64: linuxAssets[0], + sha256_linux_arm64: linuxAssets[1], + sha256_macos_arm64: macosAssets[0], + sha256_dmg_arm64: macosAssets[1], + }; + assert.deepEqual(Object.keys(run.values).sort(), Object.keys(expectedAssets).sort()); + for (const [key, asset] of Object.entries(expectedAssets)) { + assert.equal(run.values[key], fixtureSha(asset)); + } + assert.equal(run.rendered.status, 0, run.rendered.stderr); for (const file of ['Formula/hypercolor.rb', 'Casks/hypercolor-app.rb']) { - const content = readFileSync(path.join(dir, 'homebrew-tap', file), 'utf8'); + const content = run.read(file); assert.match(content, /version "0\.5\.2"/); - assert.doesNotMatch(content, /PLACEHOLDER|SHA256_/); + assert.doesNotMatch(content, /version "0\.3\.2"|PLACEHOLDER|SHA256_/); } // Homebrew never asks the release for an Intel macOS artifact. + const { job, workflow } = homebrewJob(); assert.doesNotMatch(job, /macos-amd64|x86_64|MACOS_AMD64/i); const aur = workflow.match(/^ update-aur:\n([\s\S]*?)(?=^ [a-z][\w-]*:)/m)[1]; assert.doesNotMatch(aur, /macos-|\.dmg/); } finally { - rmSync(dir, { recursive: true, force: true }); + rmSync(run.dir, { recursive: true, force: true }); + } +}); + +test('a release without a notarized macOS build advances Linux and carries macOS forward', () => { + const run = runHomebrewJob({ assets: linuxAssets, published: publishedTap }); + try { + assert.equal(run.checksums.status, 0, run.checksums.stderr); + assert.match(run.checksums.stdout, /no signed macOS build; macOS stays on the published formula/); + assert.deepEqual(Object.keys(run.values).sort(), ['sha256_linux_amd64', 'sha256_linux_arm64']); + assert.equal(run.rendered.status, 0, run.rendered.stderr); + const formula = run.read('Formula/hypercolor.rb'); + assert.match(formula, /^ version "0\.5\.2"$/m); + const macDownload = formula.match(/^ on_macos do\n([\s\S]*?)^ end\n/m)[1]; + assert.match(macDownload, /^ version "0\.3\.2"$/m); + assert.match(macDownload, new RegExp(`sha256 "${'9'.repeat(64)}"`)); + assert.match(formula, new RegExp(`sha256 "${fixtureSha(linuxAssets[0])}"`)); + assert.equal(run.read('Casks/hypercolor-app.rb'), publishedTap.cask); + } finally { + rmSync(run.dir, { recursive: true, force: true }); + } +}); + +test('a release with only part of its macOS assets never reaches the renderer', () => { + for (const partial of macosAssets) { + const run = runHomebrewJob({ assets: [...linuxAssets, partial], published: publishedTap }); + try { + assert.equal(run.checksums.status, 1); + assert.match(run.checksums.stdout, /carries only part of its macOS assets/); + assert.equal(run.rendered, undefined); + } finally { + rmSync(run.dir, { recursive: true, force: true }); + } } }); From 5318a1cf94e4ac09b6319c10f7bdf2f2e51ab001 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Fri, 2 Oct 2026 18:40:30 -0700 Subject: [PATCH 2/2] fix(release): keep the Homebrew catch-up path working on re-run Three gaps in the carry-forward job. The upload step reused one artifact name per run, so re-running sign-macos after Apple clears the queue would re-run this job and fail on the name the carry-mode attempt already uploaded; it now overwrites. The checksum step echoed $(fetch ...) directly, which discards the download's exit status, so a failed macOS tarball download fell through to carry mode with an empty checksum; it now assigns first so the step fails. And the job guarded with always(), which also fires on cancellation; !cancelled() keeps the same behaviour for skipped or failed macOS without that. Tests now pin the prerelease guard, run the push step with a stubbed git to check the commit body in both modes, and fail a download of each macOS asset in turn. RELEASING.md drops the older sentence saying the job needs the macOS checksums. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 13 ++++++-- docs/development/RELEASING.md | 7 +++-- scripts/tests/macos-ci-coverage.test.mjs | 9 ++++-- scripts/tests/macos-release.test.mjs | 40 ++++++++++++++++++++++-- 4 files changed, 57 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index da82ed97b..46c103f4b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2861,7 +2861,7 @@ jobs: # published macOS stanza forward and leaves the cask alone, so a stalled # notarization never holds the Linux formula back. if: >- - always() && + !cancelled() && needs.create-release.result == 'success' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && startsWith(github.ref, 'refs/tags/') && @@ -2911,8 +2911,12 @@ jobs: exit 1 fi if [[ "$has_tarball" == true ]]; then - echo "sha256_macos_arm64=$(fetch "$macos_tarball")" >> "$GITHUB_OUTPUT" - echo "sha256_dmg_arm64=$(fetch "$dmg")" >> "$GITHUB_OUTPUT" + # Assign before echoing so a failed download stops the job + # instead of falling through to carry mode. + macos_sha=$(fetch "$macos_tarball") + dmg_sha=$(fetch "$dmg") + echo "sha256_macos_arm64=${macos_sha}" >> "$GITHUB_OUTPUT" + echo "sha256_dmg_arm64=${dmg_sha}" >> "$GITHUB_OUTPUT" else echo "::warning::v${VERSION} has no signed macOS build; macOS stays on the published formula." fi @@ -2978,6 +2982,9 @@ jobs: homebrew-tap/Formula/hypercolor.rb homebrew-tap/Casks/hypercolor-app.rb if-no-files-found: error + # Re-running sign-macos re-runs this job in the same run, where a + # carry-mode attempt already uploaded this name. + overwrite: true - name: Push to homebrew-tap working-directory: homebrew-tap diff --git a/docs/development/RELEASING.md b/docs/development/RELEASING.md index a2ea043ca..1f7bb6a9d 100644 --- a/docs/development/RELEASING.md +++ b/docs/development/RELEASING.md @@ -51,9 +51,10 @@ The tag lane also updates the Homebrew tap: `update-homebrew` renders `packaging/homebrew/hypercolor.rb` and `packaging/homebrew/hypercolor-app.rb` with `scripts/homebrew-formula.mjs`, filling the Linux amd64 and arm64 stanzas, the Apple silicon macOS stanza, and the Apple silicon cask from the -tarballs and DMG the release just published. It runs after the macOS -artifacts are attached, because the formula and cask need the macOS -checksums. +tarballs and DMG the release just published. It waits for `attach-macos` +so a notarized release updates every platform at once, but it does not need +macOS to succeed: without a notarized build, Linux still advances and macOS +carries forward, as the signing section below describes. macOS releases target Apple silicon only. macOS 26 Tahoe is the last release that runs on Intel Macs, so the release builds, signs, and publishes no diff --git a/scripts/tests/macos-ci-coverage.test.mjs b/scripts/tests/macos-ci-coverage.test.mjs index b93492f94..dc28b94c9 100644 --- a/scripts/tests/macos-ci-coverage.test.mjs +++ b/scripts/tests/macos-ci-coverage.test.mjs @@ -81,11 +81,14 @@ test('only Homebrew waits for macOS; every other channel follows create-release' const needs = id => job(id)?.match(/^ needs: (.+)$/m)?.[1]; // Homebrew waits for attach-macos to finish but never for it to succeed: // Linux advances on create-release alone and macOS joins once notarized. - // always() must stay fenced by create-release so failed validation never - // publishes. Nothing else may depend on Apple. + // !cancelled() must stay fenced by create-release so failed validation + // never publishes, and prereleases never reach the tap. Nothing else may + // depend on Apple. assert.equal(needs('update-homebrew'), '[create-release, attach-macos]'); const homebrewCondition = job('update-homebrew').split(' needs:')[0]; - assert.match(homebrewCondition, /always\(\) &&\n\s+needs\.create-release\.result == 'success'/); + assert.match(homebrewCondition, /!cancelled\(\) &&\n\s+needs\.create-release\.result == 'success'/); + assert.doesNotMatch(homebrewCondition, /always\(\)/); + assert.match(homebrewCondition, /!contains\(github\.ref_name, '-'\)/); assert.equal(needs('update-aur'), 'create-release'); assert.equal(needs('update-nix'), 'create-release'); assert.equal(needs('publish-npm'), '[sdk, create-release]'); diff --git a/scripts/tests/macos-release.test.mjs b/scripts/tests/macos-release.test.mjs index 9436f264d..2e5590685 100644 --- a/scripts/tests/macos-release.test.mjs +++ b/scripts/tests/macos-release.test.mjs @@ -122,12 +122,12 @@ function homebrewJob() { // Run the job's checksum and render steps against a release that carries // `assets`, with `published` standing in for the tap's current formula. -function runHomebrewJob({ assets, published }) { +function runHomebrewJob({ assets, published, failDownloads = [] }) { const { repo, steps, shell } = homebrewJob(); const dir = mkdtempSync(path.join(tmpdir(), 'homebrew-workflow-')); const output = path.join(dir, 'outputs'); const env = { ...process.env, VERSION: '0.5.2', GITHUB_OUTPUT: output, RUNNER_TEMP: dir, - FIXTURE_ASSETS: assets.join('\n') }; + FIXTURE_ASSETS: assets.join('\n'), FIXTURE_FAILING: failDownloads.join('\n') }; // Substitute only the release transport; execute the workflow shell itself. const gh = `gh() { if [[ "$1 $2" == "release view" ]]; then @@ -144,6 +144,7 @@ function runHomebrewJob({ assets, published }) { done test -n "$artifact" && test -n "$directory" || return 99 grep -qxF "$artifact" <<<"$FIXTURE_ASSETS" || return 98 + if [[ -n "$FIXTURE_FAILING" ]] && grep -qxF "$artifact" <<<"$FIXTURE_FAILING"; then return 1; fi printf 'fixture:%s' "$artifact" > "$directory/$artifact" } `; @@ -167,7 +168,21 @@ function runHomebrewJob({ assets, published }) { writeFileSync(path.join(dir, 'homebrew-tap/Casks/hypercolor-app.rb'), published.cask); rendered = spawnSync('bash', ['-c', shell(renderStep)], { cwd: dir, env, encoding: 'utf8' }); } - return { dir, checksums, values, rendered, + // Run the push step with git stubbed so the commit it would write is visible. + const push = () => { + const git = `git() { + case "$1" in + diff) return 1 ;; + commit) shift; printf 'COMMIT'; printf ' [%s]' "$@"; printf '\\n' ;; + esac + return 0 + } + `; + const pushEnv = { ...env, SHA256_MACOS_ARM64: values.sha256_macos_arm64 ?? '' }; + return spawnSync('bash', ['-c', git + shell(steps.get('Push to homebrew-tap'))], + { cwd: dir, env: pushEnv, encoding: 'utf8' }); + }; + return { dir, checksums, values, rendered, push, read: file => readFileSync(path.join(dir, 'homebrew-tap', file), 'utf8') }; } @@ -208,6 +223,9 @@ test('Homebrew checksum step supplies every value consumed by its renderer', () assert.match(content, /version "0\.5\.2"/); assert.doesNotMatch(content, /version "0\.3\.2"|PLACEHOLDER|SHA256_/); } + const pushed = run.push(); + assert.equal(pushed.status, 0, pushed.stderr); + assert.match(pushed.stdout, /COMMIT \[-m\] \[hypercolor: update to 0\.5\.2\] \[-m\] \[Update formula and cask/); // Homebrew never asks the release for an Intel macOS artifact. const { job, workflow } = homebrewJob(); assert.doesNotMatch(job, /macos-amd64|x86_64|MACOS_AMD64/i); @@ -232,11 +250,27 @@ test('a release without a notarized macOS build advances Linux and carries macOS assert.match(macDownload, new RegExp(`sha256 "${'9'.repeat(64)}"`)); assert.match(formula, new RegExp(`sha256 "${fixtureSha(linuxAssets[0])}"`)); assert.equal(run.read('Casks/hypercolor-app.rb'), publishedTap.cask); + const pushed = run.push(); + assert.equal(pushed.status, 0, pushed.stderr); + assert.match(pushed.stdout, /\[Linux moves to 0\.5\.2; macOS and the cask stay on the last notarized build\.\]/); } finally { rmSync(run.dir, { recursive: true, force: true }); } }); +test('a failed macOS download stops the job instead of carrying macOS forward', () => { + for (const failing of macosAssets) { + const run = runHomebrewJob({ assets: [...linuxAssets, ...macosAssets], published: publishedTap, + failDownloads: [failing] }); + try { + assert.notEqual(run.checksums.status, 0, `${failing} download failure must stop the step`); + assert.equal(run.rendered, undefined); + } finally { + rmSync(run.dir, { recursive: true, force: true }); + } + } +}); + test('a release with only part of its macOS assets never reaches the renderer', () => { for (const partial of macosAssets) { const run = runHomebrewJob({ assets: [...linuxAssets, partial], published: publishedTap });