From 633b0abe988964499e309185a2655b8d659c29b0 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 15 Sep 2026 11:04:39 -0700 Subject: [PATCH 1/6] feat(homebrew): carry macOS stanzas forward when a release ships none The renderer required all four platform digests plus both DMG digests, so a stable tag without signed macOS artifacts could not update the tap at all. Linux stanzas now always come from the published release; macOS stanzas come from the same release when it shipped the complete signed set, and are otherwise read back from the formula already in the tap with their own version line, the shape the tap carried at 0.5.1. The cask is only rendered when both DMGs exist. A release that publishes only part of the macOS set is still rejected, so the tap advances all of macOS or none of it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013dXbUNEe4yxrGhQLXbzRou --- packaging/homebrew/hypercolor.rb | 1 + scripts/homebrew-formula.mjs | 182 ++++++++++++++++++------ scripts/tests/homebrew-formula.test.mjs | 148 +++++++++++++++++-- 3 files changed, 274 insertions(+), 57 deletions(-) diff --git a/packaging/homebrew/hypercolor.rb b/packaging/homebrew/hypercolor.rb index f4db7d9de..5a0d19621 100644 --- a/packaging/homebrew/hypercolor.rb +++ b/packaging/homebrew/hypercolor.rb @@ -25,6 +25,7 @@ def message license "Apache-2.0" on_macos do + version "MACOS_VERSION_PLACEHOLDER" depends_on macos: :sequoia depends_on MacosVersionRequirement diff --git a/scripts/homebrew-formula.mjs b/scripts/homebrew-formula.mjs index 13f25769a..297af8e4c 100644 --- a/scripts/homebrew-formula.mjs +++ b/scripts/homebrew-formula.mjs @@ -1,25 +1,35 @@ #!/usr/bin/env node -// Render the Homebrew formula for a stable Hypercolor release. +// Render the Homebrew formula and cask 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. +// Linux stanzas always come from the release that was just published. macOS +// stanzas come from the same release when it shipped signed macOS artifacts, +// and are otherwise carried forward from the formula already in the tap, so +// a Linux-only tag never points macOS users at artifacts that do not exist. +// A release that publishes only part of the macOS set is rejected: the tap +// advances all of macOS or none of it. // // node scripts/homebrew-formula.mjs \ -// --version 0.5.0 \ +// --version 0.5.2 \ // --linux-amd64 --linux-arm64 \ // --template packaging/homebrew/hypercolor.rb \ -// --macos-amd64 --macos-arm64 \ -// --dmg-arm64 --dmg-x86_64 \ -// --cask-template packaging/homebrew/hypercolor-app.rb \ -// --output hypercolor.rb --cask-output hypercolor-app.rb +// --current homebrew-tap/Formula/hypercolor.rb \ +// --output homebrew-tap/Formula/hypercolor.rb \ +// [--macos-amd64 --macos-arm64 \ +// --dmg-arm64 --dmg-x86_64 \ +// --cask-template packaging/homebrew/hypercolor-app.rb \ +// --cask-output homebrew-tap/Casks/hypercolor-app.rb] -import { readFileSync, writeFileSync } from 'node:fs'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; const VERSION_PATTERN = /^\d+\.\d+\.\d+$/; const SHA256_PATTERN = /^[0-9a-f]{64}$/; -const MACOS_SHAS = { amd64: 'SHA256_MACOS_AMD64', arm64: 'SHA256_MACOS_ARM64' }; +const MACOS_ARCHES = [ + { arch: 'arm64', placeholder: 'SHA256_MACOS_ARM64', guard: 'Hardware::CPU.arm?' }, + { arch: 'amd64', placeholder: 'SHA256_MACOS_AMD64', guard: 'Hardware::CPU.intel?' }, +]; const LINUX_SHAS = { amd64: 'SHA256_LINUX_AMD64', arm64: 'SHA256_LINUX_ARM64' }; +const MACOS_FLAGS = ['macos-amd64', 'macos-arm64', 'dmg-arm64', 'dmg-x86_64', 'cask-template', 'cask-output']; class FormulaError extends Error {} @@ -43,25 +53,95 @@ function requirePlaceholder(template, placeholder) { } } -/** Render all architectures from the checksums of one published release. */ +function macosBlock(formula) { + const match = formula.match(/^ on_macos do\n([\s\S]*?)^ end\n/m); + return match ? match[1] : undefined; +} + +/** + * Read the macOS stanzas a published formula carries. + * + * Returns undefined when the formula has no on_macos block with a tarball + * url, otherwise the version and every architecture sha it publishes. + */ +export function readCurrentMacos(formula) { + const block = macosBlock(formula); + if (block === undefined) return undefined; + const shas = {}; + for (const { arch } of MACOS_ARCHES) { + const pattern = new RegExp(`hypercolor-[^"\\n]*-macos-${arch}\\.tar\\.gz"\\s*\\n\\s*sha256 "([0-9a-f]{64})"`); + const found = block.match(pattern); + if (found) shas[arch] = found[1]; + } + if (Object.keys(shas).length === 0) return undefined; + const blockVersion = block.match(/^\s*version "([^"]+)"/m)?.[1]; + const topVersion = formula.match(/^ version "([^"]+)"/m)?.[1]; + const version = requireVersion(blockVersion ?? topVersion, 'current macOS version'); + return { version, shas }; +} + +function renderMacos(template, macos) { + const block = macosBlock(template); + if (block === undefined) throw new FormulaError('template has no on_macos block'); + const whole = ` on_macos do\n${block} end\n`; + if (macos === undefined) { + // Drop the artifact block and the blank line that followed it. + return template.replace(`${whole}\n`, '').replace(whole, ''); + } + requireVersion(macos.version, 'macOS version'); + let rendered = block.replace('MACOS_VERSION_PLACEHOLDER', macos.version); + const present = MACOS_ARCHES.filter(({ arch }) => macos.shas[arch] !== undefined); + if (present.length === 0) throw new FormulaError('macOS stanzas need at least one architecture sha256'); + const branches = present.map(({ arch, guard }, index) => { + const keyword = index === 0 ? 'if' : 'elsif'; + const sha = requireSha(macos.shas[arch], `macos ${arch} sha256`); + return ` ${keyword} ${guard}\n url "https://github.com/hyperb1iss/hypercolor/releases/download/v#{version}/hypercolor-#{version}-macos-${arch}.tar.gz"\n sha256 "${sha}"\n`; + }); + const conditional = rendered.match(/^ if Hardware::CPU[\s\S]*?^ end\n/m); + if (!conditional) throw new FormulaError('template on_macos block has no architecture conditional'); + rendered = rendered.replace(conditional[0], `${branches.join('')} end\n`); + for (const { placeholder } of MACOS_ARCHES) { + if (rendered.includes(placeholder)) throw new FormulaError(`${placeholder} survived rendering`); + } + return template.replace(whole, ` on_macos do\n${rendered} end\n`); +} + +/** + * Render the formula text. + * + * `linux` maps amd64 and arm64 to the published tarball digests. `macos` is + * `{ version, shas }`: the release's own digests when it shipped macOS, the + * value of readCurrentMacos for the tap's formula when it did not, or + * undefined when the tap has never published a macOS build. + */ export function renderFormula({ template, version, linux, macos }) { requireVersion(version, 'version'); - for (const placeholder of ['VERSION_PLACEHOLDER', ...Object.values(LINUX_SHAS), ...Object.values(MACOS_SHAS)]) { + for (const placeholder of ['VERSION_PLACEHOLDER', 'MACOS_VERSION_PLACEHOLDER', ...Object.values(LINUX_SHAS)]) { requirePlaceholder(template, placeholder); } - let formula = template.replace('VERSION_PLACEHOLDER', version); - for (const [platform, checksums, placeholders] of [ - ['linux', linux, LINUX_SHAS], ['macos', macos, MACOS_SHAS], - ]) { - for (const [arch, placeholder] of Object.entries(placeholders)) { - formula = formula.replace(placeholder, requireSha(checksums?.[arch], `${platform} ${arch} sha256`)); - } + let formula = renderMacos(template, macos); + formula = formula.replace(' version "VERSION_PLACEHOLDER"', ` version "${version}"`); + for (const [arch, placeholder] of Object.entries(LINUX_SHAS)) { + formula = formula.replace(placeholder, requireSha(linux?.[arch], `linux ${arch} sha256`)); } const leftover = formula.match(/[A-Z0-9_]*PLACEHOLDER|SHA256_[A-Z0-9_]+/); if (leftover) throw new FormulaError(`${leftover[0]} survived rendering`); return formula; } +/** Render the desktop app cask from both notarized DMG digests. */ +export function renderCask({ template, version, arm64, x86_64 }) { + requireVersion(version, 'version'); + const values = { VERSION_PLACEHOLDER: version, + SHA256_MACOS_APP_ARM64: requireSha(arm64, 'DMG arm64 sha256'), + SHA256_MACOS_APP_X86_64: requireSha(x86_64, 'DMG x86_64 sha256') }; + for (const [placeholder, value] of Object.entries(values)) { + requirePlaceholder(template, placeholder); + template = template.replace(placeholder, value); + } + return template; +} + function parseArgs(argv) { const args = {}; for (let index = 0; index < argv.length; index += 1) { @@ -75,40 +155,56 @@ function parseArgs(argv) { return args; } +/** + * Decide where the macOS stanzas come from. + * + * Every macOS flag present means the release shipped the complete signed + * set and the tap advances with it. None present means carry the tap's + * current stanzas forward. Anything in between is a partial release. + */ +function resolveMacos(args) { + const given = MACOS_FLAGS.filter((flag) => args[flag] !== undefined); + if (given.length === MACOS_FLAGS.length) { + return { + formula: { version: args.version, shas: { amd64: args['macos-amd64'], arm64: args['macos-arm64'] } }, + cask: { arm64: args['dmg-arm64'], x86_64: args['dmg-x86_64'] }, + }; + } + if (given.length > 0) { + const missing = MACOS_FLAGS.filter((flag) => args[flag] === undefined).map((flag) => `--${flag}`); + throw new FormulaError(`partial macOS release: ${missing.join(', ')} missing; publish all macOS artifacts or none`); + } + const current = args.current !== undefined && existsSync(args.current) + ? readCurrentMacos(readFileSync(args.current, 'utf8')) + : undefined; + return { formula: current, cask: undefined }; +} + export function main(argv) { const args = parseArgs(argv); - for (const required of ['version', 'linux-amd64', 'linux-arm64', 'macos-amd64', 'macos-arm64', - 'dmg-arm64', 'dmg-x86_64', 'template', 'output', 'cask-template', 'cask-output']) { + for (const required of ['version', 'linux-amd64', 'linux-arm64', 'template', 'output']) { if (args[required] === undefined) throw new FormulaError(`--${required} is required`); } - const template = readFileSync(args.template, 'utf8'); - const macos = { - amd64: requireSha(args['macos-amd64'], 'macOS amd64 sha256'), - arm64: requireSha(args['macos-arm64'], 'macOS arm64 sha256'), - }; + const macos = resolveMacos(args); const formula = renderFormula({ - template, + template: readFileSync(args.template, 'utf8'), version: args.version, linux: { amd64: args['linux-amd64'], arm64: args['linux-arm64'] }, - macos, + macos: macos.formula, }); - const cask = renderCask({ template: readFileSync(args['cask-template'], 'utf8'), - version: args.version, arm64: args['dmg-arm64'], x86_64: args['dmg-x86_64'] }); + const cask = macos.cask === undefined ? undefined : renderCask({ + template: readFileSync(args['cask-template'], 'utf8'), + version: args.version, arm64: macos.cask.arm64, x86_64: macos.cask.x86_64 }); writeFileSync(args.output, formula); - writeFileSync(args['cask-output'], cask); - console.log(`wrote formula and cask for ${args.version}`); -} - -export function renderCask({ template, version, arm64, x86_64 }) { - requireVersion(version, 'version'); - const values = { VERSION_PLACEHOLDER: version, - SHA256_MACOS_APP_ARM64: requireSha(arm64, 'DMG arm64 sha256'), - SHA256_MACOS_APP_X86_64: requireSha(x86_64, 'DMG x86_64 sha256') }; - for (const [placeholder, value] of Object.entries(values)) { - requirePlaceholder(template, placeholder); - template = template.replace(placeholder, value); + if (cask !== undefined) writeFileSync(args['cask-output'], cask); + if (macos.cask !== undefined) { + console.log(`wrote formula and cask for ${args.version}`); + } else if (macos.formula === undefined) { + console.log(`wrote formula for ${args.version}: Linux only, the tap has no macOS build to carry`); + } else { + const arches = Object.keys(macos.formula.shas).join(', '); + console.log(`wrote formula for ${args.version}: macOS stanzas carried at ${macos.formula.version} for ${arches}`); } - return template; } if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { diff --git a/scripts/tests/homebrew-formula.test.mjs b/scripts/tests/homebrew-formula.test.mjs index 4c75df232..a5d8fa7e2 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 { readCurrentMacos, renderCask, renderFormula } from '../homebrew-formula.mjs'; const repo = fileURLToPath(new URL('../../', import.meta.url)); const script = path.join(repo, 'scripts/homebrew-formula.mjs'); @@ -15,12 +15,37 @@ const template = readFileSync(templatePath, 'utf8'); const caskTemplate = readFileSync(caskPath, 'utf8'); const sha = seed => seed.repeat(64); const linux = { amd64: sha('a'), arm64: sha('b') }; -const macos = { amd64: sha('c'), arm64: sha('d') }; +// A release that shipped the complete signed macOS set alongside Linux. +const macos = { version: '0.5.2', shas: { amd64: sha('c'), arm64: sha('d') } }; + +// The formula the tap carried before the public lane owned Linux: one +// top-level version and a single macOS tarball. +const legacyTap = `class Hypercolor < Formula + desc "Open-source RGB lighting orchestration engine" + homepage "https://github.com/hyperb1iss/hypercolor" + version "0.3.2" + license "Apache-2.0" + + on_macos do + if Hardware::CPU.arm? + url "https://github.com/hyperb1iss/hypercolor/releases/download/v#{version}/hypercolor-#{version}-macos-arm64.tar.gz" + sha256 "${sha('c')}" + end + end + + on_linux do + if Hardware::CPU.intel? + url "https://github.com/hyperb1iss/hypercolor/releases/download/v#{version}/hypercolor-#{version}-linux-amd64.tar.gz" + sha256 "${sha('d')}" + end + end +end +`; test('all four formula downloads use the same version and their own checksum', () => { const formula = renderFormula({ template, version: '0.5.2', linux, macos }); - assert.deepEqual([...formula.matchAll(/version "([^"]+)"/g)].map(match => match[1]), ['0.5.2']); - for (const [platform, checksums] of Object.entries({ linux, macos })) { + assert.deepEqual([...formula.matchAll(/version "([^"]+)"/g)].map(match => match[1]), ['0.5.2', '0.5.2']); + for (const [platform, checksums] of Object.entries({ linux, macos: macos.shas })) { for (const [arch, checksum] of Object.entries(checksums)) { assert.ok(formula.includes(`-${platform}-${arch}.tar.gz"\n sha256 "${checksum}"`)); } @@ -44,15 +69,65 @@ test('formula uses valid Homebrew requirements and service paths', () => { test('formula rejects incomplete releases and unsupported version forms', () => { assert.throws(() => renderFormula({ template, version: '0.5.2-rc.1', linux, macos }), /stable X\.Y\.Z/); - for (const platform of ['linux', 'macos']) { - for (const arch of ['amd64', 'arm64']) { - const inputs = { template, version: '0.5.2', linux, macos }; - inputs[platform] = { ...inputs[platform], [arch]: undefined }; - assert.throws(() => renderFormula(inputs), new RegExp(`${platform} ${arch} sha256`)); - } + for (const arch of ['amd64', 'arm64']) { + assert.throws(() => renderFormula({ template, version: '0.5.2', linux: { ...linux, [arch]: undefined }, macos }), + new RegExp(`linux ${arch} sha256`)); + assert.throws(() => renderFormula({ template, version: '0.5.2', linux, + macos: { version: '0.5.2', shas: { ...macos.shas, [arch]: 'nope' } } }), new RegExp(`macos ${arch} sha256`)); } - assert.throws(() => renderFormula({ template: template.replace('SHA256_MACOS_AMD64', ''), - version: '0.5.2', linux, macos }), /missing the SHA256_MACOS_AMD64/); + assert.throws(() => renderFormula({ template, version: '0.5.2', linux, macos: { version: '0.5.2', shas: {} } }), + /at least one architecture/); + assert.throws(() => renderFormula({ template, version: '0.5.2', linux, macos: { version: '0.5.2-rc.1', shas: macos.shas } }), + /macOS version/); + assert.throws(() => renderFormula({ template: template.replace('SHA256_LINUX_ARM64', ''), version: '0.5.2', linux, macos }), + /missing the SHA256_LINUX_ARM64/); + assert.throws(() => renderFormula({ template: template.replace('MACOS_VERSION_PLACEHOLDER', ''), version: '0.5.2', linux, macos }), + /missing the MACOS_VERSION_PLACEHOLDER/); +}); + +test('readCurrentMacos reads a legacy single-version formula', () => { + assert.deepEqual(readCurrentMacos(legacyTap), { version: '0.3.2', shas: { arm64: sha('c') } }); +}); + +test('readCurrentMacos prefers the version declared inside on_macos', () => { + const rendered = renderFormula({ template, version: '0.5.2', linux, + macos: { version: '0.3.2', shas: { arm64: sha('c'), amd64: sha('e') } } }); + assert.deepEqual(readCurrentMacos(rendered), { version: '0.3.2', shas: { arm64: sha('c'), amd64: sha('e') } }); +}); + +test('readCurrentMacos returns undefined without a macOS tarball', () => { + const linuxOnly = renderFormula({ template, version: '0.5.2', linux, macos: undefined }); + assert.equal(readCurrentMacos(linuxOnly), undefined); + assert.equal(readCurrentMacos('class Hypercolor < Formula\n version "0.1.0"\nend\n'), undefined); + assert.throws(() => readCurrentMacos(legacyTap.replace('version "0.3.2"', 'version "0.3.2-rc.1"')), /current macOS version/); +}); + +test('Linux stanzas take the release and macOS stanzas are carried forward', () => { + const formula = renderFormula({ template, version: '0.5.2', linux, macos: readCurrentMacos(legacyTap) }); + assert.match(formula, /^ version "0\.5\.2"$/m); + assert.match(formula, /^ version "0\.3\.2"$/m); + assert.ok(formula.includes(`hypercolor-#{version}-linux-amd64.tar.gz"\n sha256 "${sha('a')}"`)); + assert.ok(formula.includes(`hypercolor-#{version}-linux-arm64.tar.gz"\n sha256 "${sha('b')}"`)); + assert.ok(formula.includes(`hypercolor-#{version}-macos-arm64.tar.gz"\n sha256 "${sha('c')}"`)); + assert.ok(!formula.includes('macos-amd64'), 'an architecture the tap never published is left out'); + assert.match(formula, /depends_on macos: :sequoia/); + assert.doesNotMatch(formula, /PLACEHOLDER|SHA256_/); +}); + +test('both macOS architectures render as an if/elsif chain', () => { + const formula = renderFormula({ template, version: '0.5.2', linux, + macos: { version: '0.4.0', shas: { arm64: sha('c'), amd64: sha('e') } } }); + const block = formula.match(/^ on_macos do\n([\s\S]*?)^ end\n/m)[1]; + assert.match(block, / if Hardware::CPU\.arm\?\n url [^\n]*macos-arm64[^\n]*\n sha256 "c{64}"\n elsif Hardware::CPU\.intel\?\n url [^\n]*macos-amd64[^\n]*\n sha256 "e{64}"\n end\n/); +}); + +test('a tap without macOS artifacts renders a Linux-only formula', () => { + const formula = renderFormula({ template, version: '0.5.2', linux, macos: undefined }); + assert.ok(!formula.includes('macos-arm64.tar.gz')); + assert.ok(!formula.includes('MacosVersionRequirement\n\n if'), 'the artifact block is gone'); + assert.match(formula, /on_macos do\n service do/, 'the macOS service stanza stays for the signed lane'); + assert.match(formula, /on_linux do\n service do/); + assert.doesNotMatch(formula, /PLACEHOLDER|SHA256_/); }); test('cask requires both published DMG checksums', () => { @@ -70,7 +145,7 @@ test('CLI validates both packages before writing either output', () => { const caskOutput = path.join(dir, 'hypercolor-app.rb'); const args = [script, '--version', '0.5.2', '--template', templatePath, '--cask-template', caskPath, '--linux-amd64', linux.amd64, '--linux-arm64', linux.arm64, - '--macos-amd64', macos.amd64, '--macos-arm64', macos.arm64, + '--macos-amd64', macos.shas.amd64, '--macos-arm64', macos.shas.arm64, '--dmg-arm64', sha('e'), '--dmg-x86_64', 'invalid', '--output', formulaOutput, '--cask-output', caskOutput]; const invalid = spawnSync(process.execPath, args, { encoding: 'utf8' }); @@ -81,9 +156,54 @@ test('CLI validates both packages before writing either output', () => { args[args.indexOf('invalid')] = sha('f'); const valid = spawnSync(process.execPath, args, { encoding: 'utf8' }); assert.equal(valid.status, 0, valid.stderr); + assert.match(valid.stdout, /wrote formula and cask for 0\.5\.2/); assert.equal(readFileSync(formulaOutput, 'utf8'), renderFormula({ template, version: '0.5.2', linux, macos })); assert.equal(readFileSync(caskOutput, 'utf8'), renderCask({ template: caskTemplate, version: '0.5.2', arm64: sha('e'), x86_64: sha('f') })); } finally { rmSync(dir, { recursive: true, force: true }); } }); + +test('CLI carries the tap macOS stanzas forward when the release shipped none', () => { + const dir = mkdtempSync(path.join(tmpdir(), 'homebrew-formula-')); + try { + const current = path.join(dir, 'current.rb'); + const output = path.join(dir, 'hypercolor.rb'); + const caskOutput = path.join(dir, 'hypercolor-app.rb'); + writeFileSync(current, legacyTap); + const base = [script, '--version', '0.5.2', '--template', templatePath, + '--linux-amd64', linux.amd64, '--linux-arm64', linux.arm64, '--output', output]; + const carried = spawnSync(process.execPath, [...base, '--current', current], { encoding: 'utf8' }); + assert.equal(carried.status, 0, carried.stderr); + assert.match(carried.stdout, /macOS stanzas carried at 0\.3\.2 for arm64/); + assert.equal(readFileSync(output, 'utf8'), renderFormula({ template, version: '0.5.2', linux, macos: readCurrentMacos(legacyTap) })); + assert.equal(existsSync(caskOutput), false, 'the cask is left alone when no DMG was published'); + + rmSync(current); + const fresh = spawnSync(process.execPath, [...base, '--current', current], { encoding: 'utf8' }); + assert.equal(fresh.status, 0, fresh.stderr); + assert.match(fresh.stdout, /Linux only, the tap has no macOS build to carry/); + assert.equal(readFileSync(output, 'utf8'), renderFormula({ template, version: '0.5.2', linux, macos: undefined })); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('CLI rejects a partial macOS release instead of advancing the tap', () => { + const dir = mkdtempSync(path.join(tmpdir(), 'homebrew-formula-')); + try { + const output = path.join(dir, 'hypercolor.rb'); + const partial = spawnSync(process.execPath, [script, '--version', '0.5.2', '--template', templatePath, + '--linux-amd64', linux.amd64, '--linux-arm64', linux.arm64, '--output', output, + '--macos-amd64', macos.shas.amd64, '--macos-arm64', macos.shas.arm64], { encoding: 'utf8' }); + assert.equal(partial.status, 1); + assert.match(partial.stderr, /partial macOS release: --dmg-arm64, --dmg-x86_64, --cask-template, --cask-output missing/); + assert.equal(existsSync(output), false); + + const missing = spawnSync(process.execPath, [script, '--version', '0.5.2'], { encoding: 'utf8' }); + assert.equal(missing.status, 1); + assert.match(missing.stderr, /--linux-amd64 is required/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); From 6d415ebd21527d6b42f6bad2c91bd3bdc6349b72 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 15 Sep 2026 11:04:39 -0700 Subject: [PATCH 2/6] feat(ci): make the macOS release lanes conditional on signing credentials The Release workflow refused to tag when any APPLE_* secret was missing, and the tag lane's credential gate, macOS matrix entries, and Homebrew job all assumed signed macOS artifacts exist. No Apple credentials are configured yet, so every release was blocked on macOS. release-credentials now probes the seven secrets and selects the release matrices from .github/release-matrix.json: macOS entries build only when every secret is present, and a missing one drops them with a warning instead of failing. The Release workflow reports the same status on dry runs and real cuts. update-homebrew reads the published assets and only tracks macOS when the whole signed set shipped, carrying the tap's current macOS build forward otherwise and refusing a partial set. create-release uploads onto an existing GitHub Release and the PyPI publish skips an already-published version, so re-dispatching the tag lane after the credentials land adds macOS to a release that shipped without it. Public CI still never publishes an unsigned macOS artifact. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013dXbUNEe4yxrGhQLXbzRou --- .github/release-matrix.json | 60 +++++++ .github/workflows/ci.yml | 167 +++++++++++-------- .github/workflows/release.yml | 21 ++- docs/development/RELEASING.md | 35 ++-- scripts/tests/macos-release.test.mjs | 208 ++++++++++++++++++------ scripts/tests/release-workflow.test.mjs | 23 ++- 6 files changed, 370 insertions(+), 144 deletions(-) create mode 100644 .github/release-matrix.json diff --git a/.github/release-matrix.json b/.github/release-matrix.json new file mode 100644 index 000000000..8b93f1f3a --- /dev/null +++ b/.github/release-matrix.json @@ -0,0 +1,60 @@ +{ + "native": [ + { + "target": "windows-x64", + "os": "windows-latest", + "rust-target": "x86_64-pc-windows-msvc", + "bundles": "nsis", + "artifact-kind": "nsis", + "cask_arch": "", + "artifact-path": "target/release/bundle/nsis/*.exe\ncrates/hypercolor-app/target/release/bundle/nsis/*.exe\n", + "signing": false + }, + { + "target": "macos-arm64", + "os": "macos-26", + "rust-target": "aarch64-apple-darwin", + "bundles": "app", + "artifact-kind": "dmg", + "cask_arch": "arm64", + "artifact-path": "target/aarch64-apple-darwin/release/bundle/dmg/*.dmg*\n", + "signing": true + }, + { + "target": "macos-x64", + "os": "macos-26-intel", + "rust-target": "x86_64-apple-darwin", + "bundles": "app", + "artifact-kind": "dmg", + "cask_arch": "x86_64", + "artifact-path": "target/x86_64-apple-darwin/release/bundle/dmg/*.dmg*\n", + "signing": true + } + ], + "release": [ + { + "target": "linux-amd64", + "os": "ubuntu-latest", + "rust-target": "x86_64-unknown-linux-gnu", + "signing": false + }, + { + "target": "linux-arm64", + "os": "ubuntu-24.04-arm", + "rust-target": "aarch64-unknown-linux-gnu", + "signing": false + }, + { + "target": "macos-arm64", + "os": "macos-26", + "rust-target": "aarch64-apple-darwin", + "signing": true + }, + { + "target": "macos-amd64", + "os": "macos-26-intel", + "rust-target": "x86_64-apple-darwin", + "signing": true + } + ] +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 636304f2e..85f82e94f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1516,9 +1516,14 @@ jobs: startsWith(github.ref, 'refs/tags/') || (github.event_name == 'workflow_dispatch' && inputs.release_artifacts == 'full') runs-on: ubuntu-latest + outputs: + macos: ${{ steps.probe.outputs.macos }} + native-matrix: ${{ steps.probe.outputs.native_matrix }} + release-matrix: ${{ steps.probe.outputs.release_matrix }} steps: - uses: actions/checkout@v7 - - name: Verify signing credentials before building release artifacts + - name: Probe signing credentials and select release lanes + id: probe env: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} @@ -1527,7 +1532,30 @@ jobs: APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} APPLE_API_KEY_CONTENT: ${{ secrets.APPLE_API_KEY_CONTENT }} - run: bash scripts/with-macos-signing.sh true + run: | + set -euo pipefail + # The macOS lanes sign and notarize, so they only run when every + # Apple secret is configured. Without them the release ships Linux + # and Windows, and the Homebrew tap keeps its current macOS build. + missing=() + for name in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY \ + APPLE_TEAM_ID APPLE_API_KEY_ID APPLE_API_ISSUER APPLE_API_KEY_CONTENT; do + [[ -n "${!name:-}" ]] || missing+=("${name}") + done + if (( ${#missing[@]} )); then + macos=false + echo "::warning::macOS signing credentials are not configured (missing ${missing[*]}); this release ships Linux and Windows only." + else + bash scripts/with-macos-signing.sh true + macos=true + fi + echo "macos=${macos}" >> "$GITHUB_OUTPUT" + for lane in native release; do + matrix="$(jq -c --arg lane "${lane}" --argjson macos "${macos}" \ + '[.[$lane][] | select($macos or (.signing | not)) | del(.signing)]' \ + .github/release-matrix.json)" + echo "${lane}_matrix=${matrix}" >> "$GITHUB_OUTPUT" + done build-native-app: name: Native App (${{ matrix.target }}) @@ -1539,32 +1567,7 @@ jobs: strategy: fail-fast: false matrix: - include: - - target: windows-x64 - os: windows-latest - rust-target: x86_64-pc-windows-msvc - bundles: nsis - artifact-kind: nsis - cask_arch: "" - artifact-path: | - target/release/bundle/nsis/*.exe - crates/hypercolor-app/target/release/bundle/nsis/*.exe - - target: macos-arm64 - os: macos-26 - rust-target: aarch64-apple-darwin - bundles: app - artifact-kind: dmg - cask_arch: arm64 - artifact-path: | - target/aarch64-apple-darwin/release/bundle/dmg/*.dmg* - - target: macos-x64 - os: macos-26-intel - rust-target: x86_64-apple-darwin - bundles: app - artifact-kind: dmg - cask_arch: x86_64 - artifact-path: | - target/x86_64-apple-darwin/release/bundle/dmg/*.dmg* + include: ${{ fromJSON(needs.release-credentials.outputs.native-matrix) }} runs-on: ${{ matrix.os }} env: # Absolute on purpose. Cargo resolves a relative CARGO_TARGET_DIR @@ -1897,19 +1900,7 @@ jobs: strategy: fail-fast: false matrix: - include: - - target: linux-amd64 - os: ubuntu-latest - rust-target: x86_64-unknown-linux-gnu - - target: linux-arm64 - os: ubuntu-24.04-arm - rust-target: aarch64-unknown-linux-gnu - - target: macos-arm64 - os: macos-26 - rust-target: aarch64-apple-darwin - - target: macos-amd64 - os: macos-26-intel - rust-target: x86_64-apple-darwin + include: ${{ fromJSON(needs.release-credentials.outputs.release-matrix) }} # A full workflow_dispatch on the default branch warms these release # shapes. Tags restore that trusted cache; they never publish cache state. timeout-minutes: 120 @@ -2176,7 +2167,14 @@ jobs: args+=(--prerelease) fi - gh release create "${args[@]}" "${files[@]}" + # A tag lane re-run after the macOS signing credentials land adds + # the signed artifacts to the release the first run created. + if gh release view "${GITHUB_REF_NAME}" --repo "${{ github.repository }}" >/dev/null 2>&1; then + echo "Release ${GITHUB_REF_NAME} exists; uploading ${#files[@]} artifacts onto it" + gh release upload "${GITHUB_REF_NAME}" --repo "${{ github.repository }}" --clobber "${files[@]}" + else + gh release create "${args[@]}" "${files[@]}" + fi # ── Publish npm Packages ─────────────────────────────────────── # Uses npm trusted publishing (OIDC): no token, and provenance is @@ -2255,6 +2253,7 @@ jobs: - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 with: + skip-existing: true packages-dir: dist/ # ── Update AUR Package ──────────────────────────────────────── @@ -2386,23 +2385,53 @@ jobs: run: | set -euo pipefail mkdir -p release-artifacts - for platform in linux-amd64 linux-arm64 macos-amd64 macos-arm64; do - tarball="hypercolor-${VERSION}-${platform}.tar.gz" + assets="$(gh release view "v${VERSION}" --repo "${{ github.repository }}" \ + --json assets --jq '.assets[].name')" + published() { grep -qxF "$1" <<<"${assets}"; } + digest() { gh release download "v${VERSION}" \ --repo "${{ github.repository }}" \ - --pattern "${tarball}" \ + --pattern "$1" \ --dir release-artifacts - sha=$(sha256sum "release-artifacts/${tarball}" | cut -d' ' -f1) + sha256sum "release-artifacts/$1" | cut -d' ' -f1 + } + for platform in linux-amd64 linux-arm64; do + sha="$(digest "hypercolor-${VERSION}-${platform}.tar.gz")" echo "sha256_${platform//-/_}=${sha}" >> "$GITHUB_OUTPUT" echo " ${platform}: ${sha}" done - for arch in arm64 x86_64; do - dmg="Hypercolor-${VERSION}-${arch}.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_${arch}=${sha}" >> "$GITHUB_OUTPUT" + # The signed lane publishes macOS as one set. A tag that shipped + # none keeps the tap's current macOS build; a tag that shipped + # part of the set is a broken release, not a Linux-only one. + macos_assets=( + "hypercolor-${VERSION}-macos-amd64.tar.gz" + "hypercolor-${VERSION}-macos-arm64.tar.gz" + "Hypercolor-${VERSION}-arm64.dmg" + "Hypercolor-${VERSION}-x86_64.dmg" + ) + present=0 + for asset in "${macos_assets[@]}"; do + if published "${asset}"; then present=$((present + 1)); fi done + if (( present == 0 )); then + echo "macos=false" >> "$GITHUB_OUTPUT" + echo " macOS: no artifacts published; the tap keeps its current macOS build" + elif (( present < ${#macos_assets[@]} )); then + echo "::error::v${VERSION} published ${present} of ${#macos_assets[@]} macOS artifacts; refusing to advance the tap" + exit 1 + else + echo "macos=true" >> "$GITHUB_OUTPUT" + for platform in macos-amd64 macos-arm64; do + sha="$(digest "hypercolor-${VERSION}-${platform}.tar.gz")" + echo "sha256_${platform//-/_}=${sha}" >> "$GITHUB_OUTPUT" + echo " ${platform}: ${sha}" + done + for arch in arm64 x86_64; do + sha="$(digest "Hypercolor-${VERSION}-${arch}.dmg")" + echo "sha256_dmg_${arch}=${sha}" >> "$GITHUB_OUTPUT" + echo " dmg ${arch}: ${sha}" + done + fi - name: Verify Homebrew tap token env: @@ -2433,6 +2462,7 @@ jobs: - name: Render formula and cask env: VERSION: ${{ steps.version.outputs.version }} + MACOS_PUBLISHED: ${{ steps.checksums.outputs.macos }} SHA256_LINUX_AMD64: ${{ steps.checksums.outputs.sha256_linux_amd64 }} SHA256_LINUX_ARM64: ${{ steps.checksums.outputs.sha256_linux_arm64 }} SHA256_MACOS_AMD64: ${{ steps.checksums.outputs.sha256_macos_amd64 }} @@ -2442,18 +2472,25 @@ 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-amd64 "$SHA256_MACOS_AMD64" \ - --macos-arm64 "$SHA256_MACOS_ARM64" \ - --dmg-arm64 "$SHA256_DMG_ARM64" \ - --dmg-x86_64 "$SHA256_DMG_X86_64" \ - --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 + --current homebrew-tap/Formula/hypercolor.rb + --output homebrew-tap/Formula/hypercolor.rb + ) + if [[ "${MACOS_PUBLISHED}" == "true" ]]; then + args+=( + --macos-amd64 "$SHA256_MACOS_AMD64" + --macos-arm64 "$SHA256_MACOS_ARM64" + --dmg-arm64 "$SHA256_DMG_ARM64" + --dmg-x86_64 "$SHA256_DMG_X86_64" + --cask-template packaging/homebrew/hypercolor-app.rb + --cask-output homebrew-tap/Casks/hypercolor-app.rb + ) + fi + node scripts/homebrew-formula.mjs "${args[@]}" echo "Rendered formula:" cat homebrew-tap/Formula/hypercolor.rb @@ -2481,7 +2518,7 @@ jobs: fi git commit \ -m "hypercolor: update to ${VERSION}" \ - -m "Update formula and cask from the published release checksums." + -m "Update the Homebrew packages from the published v${VERSION} release. macOS stanzas advance only when the release shipped its signed macOS artifacts; otherwise they are carried forward." git push # ── Update Nix Release Pin ─────────────────────────────────────── diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7157ee4e9..86abd8809 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -49,8 +49,7 @@ jobs: ref: main fetch-depth: 0 - - name: Require macOS signing credentials - if: inputs.dry_run == false + - name: Report macOS signing credentials env: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} @@ -61,16 +60,22 @@ jobs: APPLE_API_KEY_CONTENT: ${{ secrets.APPLE_API_KEY_CONTENT }} run: | set -euo pipefail - missing=0 + # Linux and Windows never need these. The tag lane skips the macOS + # builds when any are missing and the Homebrew tap keeps its + # current macOS build, so a missing secret narrows the release + # instead of blocking it. + missing=() for name in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD \ APPLE_SIGNING_IDENTITY APPLE_TEAM_ID APPLE_API_KEY_ID \ APPLE_API_ISSUER APPLE_API_KEY_CONTENT; do - if [[ -z "${!name}" ]]; then - echo "Missing required repository secret: ${name}" >&2 - missing=1 - fi + [[ -n "${!name:-}" ]] || missing+=("${name}") done - exit "${missing}" + if (( ${#missing[@]} )); then + echo "macOS signing credentials are not configured; this release ships Linux and Windows only. Missing: ${missing[*]}" >&2 + echo "::warning::macOS signing credentials are not configured (missing ${missing[*]}); this release ships Linux and Windows only." + else + echo "macOS signing credentials are configured; the tag lane will sign and notarize macOS artifacts." + fi - name: Require passing CI for the release source env: diff --git a/docs/development/RELEASING.md b/docs/development/RELEASING.md index 43d5f99be..309c54a47 100644 --- a/docs/development/RELEASING.md +++ b/docs/development/RELEASING.md @@ -45,16 +45,31 @@ dist-tag), publishes the Python client to PyPI (stable only), and updates the AUR metadata (stable only). The tag lane also updates the Homebrew tap: `update-homebrew` renders -`packaging/homebrew/hypercolor.rb` with `scripts/homebrew-formula.mjs`, -filling the Linux stanzas from the tarballs it just published and carrying -the macOS stanzas forward from the formula already in -`hyperb1iss/homebrew-tap`, so Linux users track every stable tag while macOS -users keep the last accepted build until the signed lane promotes a newer one. - -Public CI ships no macOS artifacts: macOS binaries require Developer ID -signing that repository runners cannot perform, so signed macOS tarballs and -the `hypercolor-app` cask are produced, attached, and promoted into the tap -through the signed acceptance checkpoint below. +`packaging/homebrew/hypercolor.rb` and `hypercolor-app.rb` with +`scripts/homebrew-formula.mjs`, filling the Linux stanzas from the tarballs +it just published. When the release also shipped the signed macOS set (both +standalone tarballs and both DMGs), the macOS stanzas and the cask advance +with it; otherwise the macOS stanzas are carried forward from the formula +already in `hyperb1iss/homebrew-tap` and the cask is left alone, so Linux +users track every stable tag while macOS users keep the last signed build. +A release that published only part of the macOS set fails the job instead +of advancing the tap. + +The macOS lanes are conditional on the seven `APPLE_*` repository secrets. +`release-credentials` probes them and selects the release matrices from +`.github/release-matrix.json`: with every secret present the macOS +standalone tarballs and the signed, notarized DMGs build alongside Linux and +Windows; with any missing, the macOS entries are dropped and the run warns +that the release ships Linux and Windows only. The Release workflow reports +the same status on both the dry run and the real cut so the gap is visible +before anything is tagged. Public CI never publishes an unsigned macOS +artifact in either mode. + +To add macOS to a release that shipped without it, configure the secrets +and re-dispatch **CI/CD** on the existing tag with `release_artifacts: full`. +`create-release` uploads the new artifacts onto the existing GitHub Release, +`update-homebrew` advances the macOS stanzas and the cask, and the npm, PyPI, +AUR, and Nix jobs recognise the already-published version and do nothing. ## Signed macOS acceptance checkpoint diff --git a/scripts/tests/macos-release.test.mjs b/scripts/tests/macos-release.test.mjs index 4157e2914..23d7b927d 100644 --- a/scripts/tests/macos-release.test.mjs +++ b/scripts/tests/macos-release.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; -import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync } from 'node:fs'; +import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { createHash } from 'node:crypto'; import { tmpdir } from 'node:os'; import path from 'node:path'; @@ -57,25 +57,96 @@ 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', () => { - 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]; - assert.ok(job, 'Homebrew publication job exists'); - const steps = new Map([...job.matchAll(/^ - name: (.+)\n([\s\S]*?)(?=^ - |$(?![\s\S]))/gm)] +const workflowText = readFileSync(new URL('../../.github/workflows/ci.yml', import.meta.url), 'utf8'); + +function jobSteps(id) { + const job = workflowText.match(new RegExp(`^ ${id}:\\n([\\s\\S]*?)(?=^ [a-z][\\w-]*:|$(?![\\s\\S]))`, 'm'))?.[1]; + assert.ok(job, `${id} job exists`); + return new Map([...job.matchAll(/^ - name: (.+)\n([\s\S]*?)(?=^ - |$(?![\s\S]))/gm)] .map(([, name, body]) => [name, body])); - const shell = body => { - assert.ok(body, 'expected workflow step exists'); - const run = body.match(/^ run: \|\n([\s\S]*)/m)?.[1]; - assert.ok(run, 'step has a shell body'); - return run.replace(/^ /gm, '').replaceAll('${{ github.repository }}', 'hyperb1iss/hypercolor'); - }; - const dir = mkdtempSync(path.join(tmpdir(), 'homebrew-workflow-')); +} + +function shell(body) { + assert.ok(body, 'expected workflow step exists'); + const run = body.match(/^ run: \|\n([\s\S]*)/m)?.[1]; + assert.ok(run, 'step has a shell body'); + return run.replace(/^ /gm, '').replaceAll('${{ github.repository }}', 'hyperb1iss/hypercolor'); +} + +const readOutputs = file => Object.fromEntries(readFileSync(file, 'utf8').trim().split('\n') + .filter(Boolean).map(line => [line.slice(0, line.indexOf('=')), line.slice(line.indexOf('=') + 1)])); + +const appleSecrets = ['APPLE_CERTIFICATE', 'APPLE_CERTIFICATE_PASSWORD', 'APPLE_SIGNING_IDENTITY', + 'APPLE_TEAM_ID', 'APPLE_API_KEY_ID', 'APPLE_API_ISSUER', 'APPLE_API_KEY_CONTENT']; + +test('credential probe drops the macOS lanes when any Apple secret is missing', () => { + const repo = fileURLToPath(new URL('../../', import.meta.url)); + const probe = shell(jobSteps('release-credentials').get('Probe signing credentials and select release lanes')); + const matrices = JSON.parse(readFileSync(path.join(repo, '.github/release-matrix.json'), 'utf8')); + assert.ok(matrices.native.some(entry => entry.signing) && matrices.release.some(entry => entry.signing)); + assert.ok(matrices.native.some(entry => !entry.signing) && matrices.release.some(entry => !entry.signing)); + const dir = mkdtempSync(path.join(tmpdir(), 'release-credentials-')); 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() { + const run = (env) => { + const output = path.join(dir, `outputs-${Math.random()}`); + const result = spawnSync('bash', ['-c', probe], { cwd: repo, encoding: 'utf8', + env: { ...process.env, ...env, GITHUB_OUTPUT: output } }); + assert.equal(result.status, 0, result.stderr); + return { result, outputs: readOutputs(output) }; + }; + const complete = run(Object.fromEntries(appleSecrets.map(name => [name, 'fixture-only']))); + assert.equal(complete.outputs.macos, 'true'); + assert.doesNotMatch(complete.result.stdout, /::warning::/); + for (const lane of ['native', 'release']) { + const selected = JSON.parse(complete.outputs[`${lane}_matrix`]); + assert.deepEqual(selected.map(entry => entry.target), matrices[lane].map(entry => entry.target)); + assert.ok(selected.every(entry => !('signing' in entry)), 'the selector key never reaches the matrix'); + } + const missing = run(Object.fromEntries(appleSecrets.map(name => [name, name === 'APPLE_TEAM_ID' ? '' : 'fixture-only']))); + assert.equal(missing.outputs.macos, 'false'); + assert.match(missing.result.stdout, /^::warning::.*missing APPLE_TEAM_ID\b.*Linux and Windows only/m); + for (const lane of ['native', 'release']) { + const selected = JSON.parse(missing.outputs[`${lane}_matrix`]); + assert.deepEqual(selected.map(entry => entry.target), + matrices[lane].filter(entry => !entry.signing).map(entry => entry.target)); + assert.ok(selected.length > 0, `${lane} still builds its unsigned platforms`); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } + for (const [id, output] of [['build-native-app', 'native-matrix'], ['build-release', 'release-matrix']]) { + const job = workflowText.match(new RegExp(`^ ${id}:\\n([\\s\\S]*?)(?=^ [a-z][\\w-]*:)`, 'm'))[1]; + assert.ok(job.includes(`include: \${{ fromJSON(needs.release-credentials.outputs.${output}) }}`), `${id} takes its matrix from the probe`); + } +}); + +test('Homebrew step tracks macOS only when the release published the whole signed set', () => { + const repo = fileURLToPath(new URL('../../', import.meta.url)); + const steps = jobSteps('update-homebrew'); + const checksums = shell(steps.get('Download release tarballs and compute checksums')); + const renderStep = steps.get('Render formula and cask'); + const render = shell(renderStep); + const sha = seed => seed.repeat(64); + // The tap before this release: Linux at 0.5.1, macOS carried at 0.3.2. + const currentFormula = `class Hypercolor < Formula + version "0.5.1" + + on_macos do + version "0.3.2" + if Hardware::CPU.arm? + url "https://github.com/hyperb1iss/hypercolor/releases/download/v#{version}/hypercolor-#{version}-macos-arm64.tar.gz" + sha256 "${sha('c')}" + end + end +end +`; + const currentCask = 'cask "hypercolor-app" do\n version "0.3.2"\nend\n'; + 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-amd64.tar.gz', 'hypercolor-0.5.2-macos-arm64.tar.gz', + 'Hypercolor-0.5.2-arm64.dmg', 'Hypercolor-0.5.2-x86_64.dmg']; + // Substitute only the GitHub transport; execute the workflow shell itself. + const gh = `gh() { + if [[ "$1 $2" == "release view" ]]; then printf '%s\\n' $ASSETS; return 0; fi local artifact='' directory='' while (( $# )); do case "$1" in @@ -85,43 +156,74 @@ test('Homebrew checksum step supplies every value consumed by its renderer', () shift done test -n "$artifact" && test -n "$directory" || return 99 + grep -qxF "$artifact" <<<"$(printf '%s\\n' $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' }); - 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_amd64: 'hypercolor-0.5.2-macos-amd64.tar.gz', - sha256_macos_arm64: 'hypercolor-0.5.2-macos-arm64.tar.gz', - sha256_dmg_arm64: 'Hypercolor-0.5.2-arm64.dmg', - sha256_dmg_x86_64: 'Hypercolor-0.5.2-x86_64.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 scenario = (assets, check) => { + 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, ASSETS: assets.join(' ') }; + 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'), currentFormula); + writeFileSync(path.join(dir, 'homebrew-tap/Casks/hypercolor-app.rb'), currentCask); + 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 downloaded = spawnSync('bash', ['-c', gh + checksums], { cwd: dir, env, encoding: 'utf8' }); + check(downloaded, () => { + const values = readOutputs(output); + // GitHub materialises every env line, so an unset output arrives as + // an empty string rather than an unbound variable. + for (const [, name, key] of renderStep.matchAll(/^ (SHA256_\w+): \$\{\{ steps.checksums.outputs.(\w+) \}\}/gm)) { + env[name] = values[key] ?? ''; + } + env.MACOS_PUBLISHED = values.macos; + const rendered = spawnSync('bash', ['-c', render], { cwd: dir, env, encoding: 'utf8' }); + assert.equal(rendered.status, 0, rendered.stderr); + return { values, formula: readFileSync(path.join(dir, 'homebrew-tap/Formula/hypercolor.rb'), 'utf8'), + cask: readFileSync(path.join(dir, 'homebrew-tap/Casks/hypercolor-app.rb'), 'utf8') }; + }); + } finally { + rmSync(dir, { recursive: true, force: true }); } - 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]; - } - 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); - for (const file of ['Formula/hypercolor.rb', 'Casks/hypercolor-app.rb']) { - const content = readFileSync(path.join(dir, 'homebrew-tap', file), 'utf8'); - assert.match(content, /version "0\.5\.2"/); - assert.doesNotMatch(content, /PLACEHOLDER|SHA256_/); - } - 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 }); - } + }; + const digest = asset => createHash('sha256').update(`fixture:${asset}`).digest('hex'); + + scenario([...linuxAssets, ...macosAssets], (downloaded, renderTap) => { + assert.equal(downloaded.status, 0, downloaded.stderr); + const { values, formula, cask } = renderTap(); + assert.equal(values.macos, 'true'); + assert.deepEqual(Object.keys(values).sort(), ['macos', 'sha256_dmg_arm64', 'sha256_dmg_x86_64', + 'sha256_linux_amd64', 'sha256_linux_arm64', 'sha256_macos_amd64', 'sha256_macos_arm64']); + assert.equal(values.sha256_macos_arm64, digest('hypercolor-0.5.2-macos-arm64.tar.gz')); + assert.equal(values.sha256_dmg_x86_64, digest('Hypercolor-0.5.2-x86_64.dmg')); + assert.deepEqual([...formula.matchAll(/version "([^"]+)"/g)].map(match => match[1]), ['0.5.2', '0.5.2']); + assert.match(cask, /version "0\.5\.2"/); + for (const file of [formula, cask]) assert.doesNotMatch(file, /PLACEHOLDER|SHA256_/); + }); + + scenario(linuxAssets, (downloaded, renderTap) => { + assert.equal(downloaded.status, 0, downloaded.stderr); + assert.match(downloaded.stdout, /keeps its current macOS build/); + const { values, formula, cask } = renderTap(); + assert.equal(values.macos, 'false'); + assert.deepEqual(Object.keys(values).sort(), ['macos', 'sha256_linux_amd64', 'sha256_linux_arm64']); + assert.equal(values.sha256_linux_amd64, digest('hypercolor-0.5.2-linux-amd64.tar.gz')); + assert.match(formula, /^ version "0\.5\.2"$/m); + assert.match(formula, /^ version "0\.3\.2"$/m); + assert.ok(formula.includes(`macos-arm64.tar.gz"\n sha256 "${sha('c')}"`), 'macOS stanza carried forward'); + assert.ok(!formula.includes('macos-amd64')); + assert.equal(cask, currentCask, 'the cask is untouched without a new DMG'); + }); + + scenario([...linuxAssets, macosAssets[0], macosAssets[2]], (downloaded) => { + assert.equal(downloaded.status, 1); + assert.match(downloaded.stdout + downloaded.stderr, /published 2 of 4 macOS artifacts; refusing to advance the tap/); + }); + + const aur = workflowText.match(/^ update-aur:\n([\s\S]*?)(?=^ [a-z][\w-]*:)/m)[1]; + assert.doesNotMatch(aur, /macos-|\.dmg/); }); diff --git a/scripts/tests/release-workflow.test.mjs b/scripts/tests/release-workflow.test.mjs index 9126f620c..bb24d518e 100644 --- a/scripts/tests/release-workflow.test.mjs +++ b/scripts/tests/release-workflow.test.mjs @@ -105,20 +105,27 @@ esac } }); -test('signing preflight accepts complete credentials and reports only missing names', () => { +test('signing report accepts complete credentials and lists only the missing names', () => { const names = [ 'APPLE_CERTIFICATE', 'APPLE_CERTIFICATE_PASSWORD', 'APPLE_SIGNING_IDENTITY', 'APPLE_TEAM_ID', 'APPLE_API_KEY_ID', 'APPLE_API_ISSUER', 'APPLE_API_KEY_CONTENT', ]; - const env = { ...process.env, ...Object.fromEntries(names.map((name) => [name, 'private-test-value'])) }; - const complete = spawnSync('bash', ['-c', stepScript('Require macOS signing credentials')], { encoding: 'utf8', env }); + const env = { ...process.env, ...Object.fromEntries(names.map(name => [name, 'fixture'])) }; + const script = stepScript('Report macOS signing credentials'); + const complete = spawnSync('bash', ['-c', script], { encoding: 'utf8', env }); assert.equal(complete.status, 0, complete.stderr); - const missing = spawnSync('bash', ['-c', stepScript('Require macOS signing credentials')], { - encoding: 'utf8', env: { ...env, APPLE_CERTIFICATE: '' }, + assert.match(complete.stdout, /credentials are configured/); + assert.equal(complete.stderr, ''); + // A missing secret narrows the release to Linux and Windows; it never blocks the cut. + const missing = spawnSync('bash', ['-c', script], { + encoding: 'utf8', env: { ...env, APPLE_CERTIFICATE: '', APPLE_API_ISSUER: '' }, }); - assert.notEqual(missing.status, 0); - assert.equal(missing.stderr.trim(), 'Missing required repository secret: APPLE_CERTIFICATE'); - assert.doesNotMatch(missing.stdout + missing.stderr, /private-test-value/); + assert.equal(missing.status, 0, missing.stderr); + assert.match(missing.stderr, /Linux and Windows only\. Missing: APPLE_CERTIFICATE APPLE_API_ISSUER$/m); + assert.match(missing.stdout, /^::warning::.*missing APPLE_CERTIFICATE APPLE_API_ISSUER/m); + assert.doesNotMatch(missing.stderr, /APPLE_TEAM_ID/); + assert.doesNotMatch(readFileSync(new URL('../../.github/workflows/release.yml', import.meta.url), 'utf8'), + /name: Report macOS signing credentials\n if:/, 'the report runs on dry runs too'); }); test('dispatch resumes the prepared tag and refuses a moved tag', () => { From eaeddc28acb2f9131cb2c9a231637c5d042431b9 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 15 Sep 2026 11:04:39 -0700 Subject: [PATCH 3/6] fix(packaging): drop the systemd CPU and memory caps from the daemon units Both packaged units carried CPUQuota=25% and MemoryMax=512M, and dist.sh copies that unit into the tarball, so the release installer, the deb, the AUR package, and Homebrew all shipped it. CPUQuota=25% is a quarter of one core, not a quarter of the machine, which starves a 60fps compositor plus in-process Servo on every packaged install, and the daemon idles near 330MB RSS, so the memory ceiling was an OOM kill waiting for a busy scene. The NixOS module already omitted both. Resource ceilings are a product baseline; the units now run unconstrained and rely on the watchdog and Restart=on-failure for runaway recovery. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013dXbUNEe4yxrGhQLXbzRou --- docs/design/13-performance.md | 2 +- packaging/systemd/user/hypercolor.service | 4 ---- packaging/systemd/user/hypercolor.service.system | 4 ---- 3 files changed, 1 insertion(+), 9 deletions(-) diff --git a/docs/design/13-performance.md b/docs/design/13-performance.md index 85eb1829a..9b7c40034 100644 --- a/docs/design/13-performance.md +++ b/docs/design/13-performance.md @@ -1247,7 +1247,7 @@ PrivateTmp=yes NoNewPrivileges=yes ``` -`CPUQuota=25%` is a hard backstop: even if Hypercolor goes haywire, it cannot consume more than 25% of system CPU. `MemoryMax=512M` kills the process if memory exceeds half a gig — something is very wrong at that point. +The `CPUQuota=25%` and `MemoryMax=512M` lines above were the original backstop and no longer ship. `CPUQuota=25%` is a quarter of one core, not a quarter of the machine, which starves the 60fps compositor plus Servo on every packaged install, and the daemon idles near 330MB RSS with Servo in-process, so the memory ceiling was an OOM kill waiting for a busy scene. Resource ceilings are a product baseline (see CLAUDE.md), so the packaged units now run unconstrained and rely on the watchdog and `Restart=on-failure` for runaway recovery. --- diff --git a/packaging/systemd/user/hypercolor.service b/packaging/systemd/user/hypercolor.service index 4fb7da6ec..bfc8f1160 100644 --- a/packaging/systemd/user/hypercolor.service +++ b/packaging/systemd/user/hypercolor.service @@ -14,10 +14,6 @@ Environment=HYPERCOLOR_LOG=info Environment=RUST_BACKTRACE=1 Environment=HYPERCOLOR_SERVICE_IDENTITY=user_service:systemd:hypercolor.service -# Resource limits -MemoryMax=512M -CPUQuota=25% - # Security hardening ProtectHome=read-only ProtectSystem=strict diff --git a/packaging/systemd/user/hypercolor.service.system b/packaging/systemd/user/hypercolor.service.system index db4e80035..d3d53a560 100644 --- a/packaging/systemd/user/hypercolor.service.system +++ b/packaging/systemd/user/hypercolor.service.system @@ -16,10 +16,6 @@ Environment=HYPERCOLOR_LOG=info Environment=RUST_BACKTRACE=1 Environment=HYPERCOLOR_SERVICE_IDENTITY=user_service:systemd:hypercolor.service -# Resource limits -MemoryMax=512M -CPUQuota=25% - # Security hardening ProtectHome=read-only ProtectSystem=strict From db28dbfaab119aac6e5829cd4e0f751dfa78d171 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 15 Sep 2026 11:04:39 -0700 Subject: [PATCH 4/6] docs(install): stop promising macOS DMGs on every release The README, download page, and install guides told macOS readers to grab Hypercolor-.dmg from the releases page, but no release since v0.3.2 has carried a macOS asset and the tap cask is pinned there. The pages now say macOS builds ship only when the signed lane runs and can lag Linux and Windows, point at the tap as the source of truth, and keep the DMG instructions for releases that did ship one. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013dXbUNEe4yxrGhQLXbzRou --- README.md | 18 ++++++++++-------- docs/content/download.md | 10 +++++++--- docs/content/guide/choose-your-install.md | 3 ++- docs/content/guide/installation.md | 5 +++-- 4 files changed, 22 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index bfe0a871b..ab9ef6995 100644 --- a/README.md +++ b/README.md @@ -353,18 +353,20 @@ Duplication and is enabled by default. ### Install on macOS -Download -`Hypercolor--arm64.dmg` for Apple Silicon or the `-x86_64.dmg` build for -Intel from the -[GitHub releases page](https://github.com/hyperb1iss/hypercolor/releases). Drag -the app into `/Applications` and launch. Minimum macOS 15.2 (Sequoia). - -Or via Homebrew Cask: +macOS builds need Developer ID signing and notarization, so they ship only +when the signed lane runs and can lag the Linux and Windows releases. The +Homebrew tap always points at the newest macOS build for each package: ```bash -brew install --cask hyperb1iss/tap/hypercolor-app +brew install hyperb1iss/tap/hypercolor # daemon, CLI, and TUI +brew install --cask hyperb1iss/tap/hypercolor-app # desktop app ``` +Minimum macOS 15.2 (Sequoia). Check `brew info hyperb1iss/tap/hypercolor` for +the macOS version the tap currently serves; the +[GitHub releases page](https://github.com/hyperb1iss/hypercolor/releases) +carries the DMGs for releases that shipped a signed macOS build. + Hue, WLED, Nanoleaf, Govee, and USB-HID lighting all work out of the box. Hypercolor asks for Microphone, Screen Recording, or Input Monitoring access only when you explicitly enable the matching audio, screen, or keyboard feature. Pointer-only effects do not need diff --git a/docs/content/download.md b/docs/content/download.md index 3188328bb..573c1c757 100644 --- a/docs/content/download.md +++ b/docs/content/download.md @@ -126,13 +126,17 @@ Choose "More info" and then "Run anyway" to continue. ## macOS -Download the DMG for Apple Silicon or Intel from the release page, then drag -Hypercolor into Applications. You can also install the desktop app with -Homebrew: +macOS builds need Developer ID signing and notarization, so they ship only +when the signed lane runs and can lag the Linux and Windows releases. The +Homebrew tap always points at the newest macOS build: ```bash brew install --cask hyperb1iss/tap/hypercolor-app ``` +Releases that shipped a signed macOS build also carry the DMG for Apple +Silicon or Intel on the release page: download it and drag Hypercolor into +Applications. + The `hypercolor` formula installs the CLI and daemon. See [Choose your install](@/guide/choose-your-install.md) for the tradeoffs. diff --git a/docs/content/guide/choose-your-install.md b/docs/content/guide/choose-your-install.md index 3bba7ded4..e99cf24a0 100644 --- a/docs/content/guide/choose-your-install.md +++ b/docs/content/guide/choose-your-install.md @@ -111,7 +111,8 @@ PawnIO setup was skipped or failed, re-run it from Settings → Device Discovery Download `Hypercolor--arm64.dmg` (Apple Silicon) or `-x86_64.dmg` (Intel) from the [download page](@/download.md), drag the app into `/Applications`, and -launch. Minimum macOS 15.2 (Sequoia). +launch. Minimum macOS 15.2 (Sequoia). macOS builds ship only when the signed +lane runs, so the newest DMG can be older than the Linux and Windows releases. Hypercolor requests Screen Recording permission when you enable screen capture. For system audio, follow [Audio setup](@/guide/audio-setup.md). diff --git a/docs/content/guide/installation.md b/docs/content/guide/installation.md index 93c585444..ff7e39c1e 100644 --- a/docs/content/guide/installation.md +++ b/docs/content/guide/installation.md @@ -126,8 +126,9 @@ later from Settings → Device Discovery → Hardware Support. ## macOS Download the signed DMG from -the [download page](@/download.md). Open the DMG, drag Hypercolor to -Applications, and launch it. The app registers a LaunchAgent for autostart and +the [download page](@/download.md). macOS builds ship only when the signed +lane runs, so the newest DMG can be older than the Linux and Windows +releases. Open the DMG, drag Hypercolor to Applications, and launch it. The app registers a LaunchAgent for autostart and supervises the daemon; no terminal setup is required. {% %} From 80d3599e74f3ffaf0d5258797cf659cf605cf1d0 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 15 Sep 2026 11:13:29 -0700 Subject: [PATCH 5/6] test(packaging): follow the release matrix file and carry-forward template The hypercolor-app packaging tests asserted the macOS matrix entries inline in ci.yml and that the formula template carried no macOS version placeholder. Both moved: the entries live in .github/release-matrix.json so the credential probe can filter them, and the on_macos block carries its own version line so a Linux-only release can carry the tap's macOS build forward. rust-test gates both release builders, so these three assertions would have failed the very tag run the change exists to make possible. The template header now describes the carry-forward instead of claiming every platform renders from the same release. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013dXbUNEe4yxrGhQLXbzRou --- .../hypercolor-app/tests/packaging_tests.rs | 39 ++++++++++++++----- packaging/homebrew/hypercolor.rb | 5 ++- 2 files changed, 33 insertions(+), 11 deletions(-) diff --git a/crates/hypercolor-app/tests/packaging_tests.rs b/crates/hypercolor-app/tests/packaging_tests.rs index b138f56cf..4df13801d 100644 --- a/crates/hypercolor-app/tests/packaging_tests.rs +++ b/crates/hypercolor-app/tests/packaging_tests.rs @@ -6,6 +6,7 @@ const GET_INSTALLER: &str = include_str!("../../../scripts/get-hypercolor.sh"); const HOMEBREW_FORMULA: &str = include_str!("../../../packaging/homebrew/hypercolor.rb"); const HOMEBREW_CASK: &str = include_str!("../../../packaging/homebrew/hypercolor-app.rb"); const CI_WORKFLOW: &str = include_str!("../../../.github/workflows/ci.yml"); +const RELEASE_MATRIX: &str = include_str!("../../../.github/release-matrix.json"); const JUSTFILE: &str = include_str!("../../../justfile"); const WINDOWS_INSTALLER_SCRIPT: &str = include_str!("../../../scripts/build-windows-installer.ps1"); const DIST_SH: &str = include_str!("../../../scripts/dist.sh"); @@ -347,10 +348,21 @@ fn curl_installer_compares_macos_versions_by_numeric_component() { #[test] fn macos_packaging_and_installers_cover_both_architectures() { - assert!(CI_WORKFLOW.contains("target: macos-arm64")); - assert!(CI_WORKFLOW.contains("target: macos-x64")); - assert!(CI_WORKFLOW.contains("rust-target: aarch64-apple-darwin")); - assert!(CI_WORKFLOW.contains("rust-target: x86_64-apple-darwin")); + // The release matrices live in release-matrix.json so the credential + // probe can drop the macOS lanes when the Apple secrets are missing. + assert!(RELEASE_MATRIX.contains(r#""target": "macos-arm64""#)); + assert!(RELEASE_MATRIX.contains(r#""target": "macos-x64""#)); + assert!(RELEASE_MATRIX.contains(r#""target": "macos-amd64""#)); + assert!(RELEASE_MATRIX.contains(r#""rust-target": "aarch64-apple-darwin""#)); + assert!(RELEASE_MATRIX.contains(r#""rust-target": "x86_64-apple-darwin""#)); + assert!( + CI_WORKFLOW + .contains("include: ${{ fromJSON(needs.release-credentials.outputs.native-matrix) }}") + ); + assert!( + CI_WORKFLOW + .contains("include: ${{ fromJSON(needs.release-credentials.outputs.release-matrix) }}") + ); for expected in ["macos-arm64", "macos-amd64"] { assert!(GET_INSTALLER.contains(expected)); @@ -358,8 +370,8 @@ fn macos_packaging_and_installers_cover_both_architectures() { assert!(HOMEBREW_FORMULA.contains(expected)); } - assert!(CI_WORKFLOW.contains("os: macos-26")); - assert!(CI_WORKFLOW.contains("os: macos-26-intel")); + assert!(RELEASE_MATRIX.contains(r#""os": "macos-26""#)); + assert!(RELEASE_MATRIX.contains(r#""os": "macos-26-intel""#)); assert!(HOMEBREW_FORMULA.contains("SHA256_MACOS_AMD64")); assert!(HOMEBREW_FORMULA.contains("keep_alive successful_exit: false")); assert!(HOMEBREW_FORMULA.contains(r#""--macos-owner", "homebrew""#)); @@ -762,9 +774,12 @@ fn homebrew_cask_template_targets_normalized_macos_dmg_names() { #[test] fn release_ci_publishes_signed_macos_apps() { - assert!(CI_WORKFLOW.contains("cask_arch: arm64")); - assert!(CI_WORKFLOW.contains("cask_arch: x86_64")); - assert!(CI_WORKFLOW.contains("artifact-kind: dmg")); + assert!(RELEASE_MATRIX.contains(r#""cask_arch": "arm64""#)); + assert!(RELEASE_MATRIX.contains(r#""cask_arch": "x86_64""#)); + assert!(RELEASE_MATRIX.contains(r#""artifact-kind": "dmg""#)); + // Only the lanes that sign carry the marker the probe filters on. + assert_eq!(RELEASE_MATRIX.matches(r#""signing": true"#).count(), 4); + assert!(CI_WORKFLOW.contains("Probe signing credentials and select release lanes")); assert!(CI_WORKFLOW.contains("Build signed and notarized macOS app")); assert!(CI_WORKFLOW.contains("Verify signed macOS app")); assert!(CI_WORKFLOW.contains("-name '*.dmg'")); @@ -1514,7 +1529,11 @@ fn release_ci_updates_formula_and_cask_from_the_same_release() { "update-homebrew must update every artifact: missing {required}" ); } - assert!(!HOMEBREW_FORMULA.contains("MACOS_VERSION_PLACEHOLDER")); + // The on_macos block carries its own version so a Linux-only release can + // carry the tap's macOS build forward instead of pointing at nothing. + assert!(HOMEBREW_FORMULA.contains("MACOS_VERSION_PLACEHOLDER")); + assert!(job_body.contains("--current homebrew-tap/Formula/hypercolor.rb")); + assert!(job_body.contains("keeps its current macOS build")); assert!(HOMEBREW_FORMULA.contains("SHA256_MACOS_ARM64")); assert!(HOMEBREW_CASK.contains("SHA256_MACOS_APP_ARM64")); } diff --git a/packaging/homebrew/hypercolor.rb b/packaging/homebrew/hypercolor.rb index 5a0d19621..e8a6b1547 100644 --- a/packaging/homebrew/hypercolor.rb +++ b/packaging/homebrew/hypercolor.rb @@ -3,7 +3,10 @@ # Homebrew formula for Hypercolor. # -# scripts/homebrew-formula.mjs renders every platform from the same release. +# scripts/homebrew-formula.mjs fills the Linux stanzas from the release it +# just published. macOS stanzas come from the same release when it shipped +# the signed macOS set, and are otherwise carried forward from the formula +# already in the tap, with their own version line inside on_macos. class Hypercolor < Formula # Sequoia's symbolic version cannot distinguish 15.0 from the 15.2 floor. From c5835e4e92d2c9062898cbfd12decaaff4809aa0 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 15 Sep 2026 11:13:29 -0700 Subject: [PATCH 6/6] fix(ci): keep published assets when the tag lane is re-run A re-run that clobbered every artifact would replace the Linux and Windows assets with rebuilt files carrying new digests, because dist.sh does not produce byte-identical archives. The tap formula committed by the first run, the AUR and Nix pins, and anyone who verified a download would all break until a second publish wave landed. create-release now uploads only the assets the release does not already carry, so a re-run after the Apple credentials land adds the macOS set and leaves the rest untouched. RELEASING.md describes that behaviour instead of claiming the AUR and Nix jobs ignore a re-run. The two design docs that still listed the systemd caps as shipping config carry the retirement note. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013dXbUNEe4yxrGhQLXbzRou --- .github/workflows/ci.yml | 24 +++++++++++++++++++---- docs/design/14-desktop-integration.md | 2 ++ docs/design/25-distribution-and-applet.md | 2 ++ docs/development/RELEASING.md | 7 ++++--- 4 files changed, 28 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 85f82e94f..da72301a0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2168,10 +2168,26 @@ jobs: fi # A tag lane re-run after the macOS signing credentials land adds - # the signed artifacts to the release the first run created. - if gh release view "${GITHUB_REF_NAME}" --repo "${{ github.repository }}" >/dev/null 2>&1; then - echo "Release ${GITHUB_REF_NAME} exists; uploading ${#files[@]} artifacts onto it" - gh release upload "${GITHUB_REF_NAME}" --repo "${{ github.repository }}" --clobber "${files[@]}" + # the signed artifacts to the release the first run created. Assets + # already on the release keep their published digests: a rebuild is + # not byte-identical, and the tap, AUR, and Nix pins point at the + # first run's checksums. + if existing="$(gh release view "${GITHUB_REF_NAME}" --repo "${{ github.repository }}" \ + --json assets --jq '.assets[].name' 2>/dev/null)"; then + new_files=() + for file in "${files[@]}"; do + if grep -qxF "$(basename "${file}")" <<<"${existing}"; then + echo " keeping published $(basename "${file}")" + else + new_files+=("${file}") + fi + done + if (( ${#new_files[@]} == 0 )); then + echo "Release ${GITHUB_REF_NAME} already carries every artifact from this run" + else + echo "Release ${GITHUB_REF_NAME} exists; adding ${#new_files[@]} new artifacts" + gh release upload "${GITHUB_REF_NAME}" --repo "${{ github.repository }}" "${new_files[@]}" + fi else gh release create "${args[@]}" "${files[@]}" fi diff --git a/docs/design/14-desktop-integration.md b/docs/design/14-desktop-integration.md index 0ab2754ae..47b55097f 100644 --- a/docs/design/14-desktop-integration.md +++ b/docs/design/14-desktop-integration.md @@ -90,6 +90,8 @@ Environment=RUST_BACKTRACE=1 WantedBy=default.target ``` +> The `MemoryMax=512M` and `CPUQuota=25%` lines above no longer ship. `CPUQuota=25%` is a quarter of one core, which starved the compositor on every packaged install, and the memory ceiling sat one busy scene above the daemon's idle footprint. See design 13 for the retirement note. + ### 1.2 Socket Activation systemd opens the HTTP port and hands the file descriptor to Hypercolor on first connection. This eliminates port conflicts and enables on-demand startup. diff --git a/docs/design/25-distribution-and-applet.md b/docs/design/25-distribution-and-applet.md index 2ac45d4ef..8f5fd71f0 100644 --- a/docs/design/25-distribution-and-applet.md +++ b/docs/design/25-distribution-and-applet.md @@ -171,6 +171,8 @@ NoNewPrivileges=true WantedBy=default.target ``` +> The `MemoryMax=512M` and `CPUQuota=25%` lines above no longer ship. `CPUQuota=25%` is a quarter of one core, which starved the compositor on every packaged install, and the memory ceiling sat one busy scene above the daemon's idle footprint. See design 13 for the retirement note. + **Key changes from current:** - `Type=notify` with `WatchdogSec=30` — daemon sends `sd_notify` heartbeats every 15s diff --git a/docs/development/RELEASING.md b/docs/development/RELEASING.md index 309c54a47..1fa0aaea0 100644 --- a/docs/development/RELEASING.md +++ b/docs/development/RELEASING.md @@ -67,9 +67,10 @@ artifact in either mode. To add macOS to a release that shipped without it, configure the secrets and re-dispatch **CI/CD** on the existing tag with `release_artifacts: full`. -`create-release` uploads the new artifacts onto the existing GitHub Release, -`update-homebrew` advances the macOS stanzas and the cask, and the npm, PyPI, -AUR, and Nix jobs recognise the already-published version and do nothing. +`create-release` adds only the assets the release does not already carry, so +the Linux and Windows artifacts keep their published digests and the AUR and +Nix pins stay put; `update-homebrew` advances the macOS stanzas and the cask; +the npm and PyPI jobs skip the already-published version. ## Signed macOS acceptance checkpoint