diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 35ca034f7..46c103f4b 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: >- + !cancelled() && + 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,38 @@ 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 + # 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 - name: Verify Homebrew tap token env: @@ -2935,16 +2957,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 @@ -2956,11 +2982,15 @@ 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 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 +3000,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..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 @@ -76,8 +77,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..dc28b94c9 100644 --- a/scripts/tests/macos-ci-coverage.test.mjs +++ b/scripts/tests/macos-ci-coverage.test.mjs @@ -79,9 +79,16 @@ 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. + // !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, /!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 c688ad92e..2e5590685 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,170 @@ 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, failDownloads = [] }) { + 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'), FIXTURE_FAILING: failDownloads.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 + if [[ -n "$FIXTURE_FAILING" ]] && grep -qxF "$artifact" <<<"$FIXTURE_FAILING"; then return 1; fi + 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' }); + } + // 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') }; +} + +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_/); } + 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); 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); + 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 }); + 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 }); + } } });