Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 57 additions & 25 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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

Expand All @@ -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]"
Expand All @@ -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 ───────────────────────────────────────
Expand Down
15 changes: 10 additions & 5 deletions docs/development/RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 3 additions & 1 deletion packaging/homebrew/hypercolor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
82 changes: 69 additions & 13 deletions scripts/homebrew-formula.mjs
Original file line number Diff line number Diff line change
@@ -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 \
Expand All @@ -12,6 +16,13 @@
// --macos-arm64 <sha256> --dmg-arm64 <sha256> \
// --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 <sha256> --linux-arm64 <sha256> \
// --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';
Expand All @@ -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 {}
Expand All @@ -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],
]) {
Expand All @@ -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) {
Expand All @@ -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);
Expand Down
Loading
Loading