From bdac3629697fa31708bd5d6047b1c6a7d7c9efae Mon Sep 17 00:00:00 2001 From: Nghia Tran Date: Sun, 12 Jul 2026 21:08:18 +0200 Subject: [PATCH] Add Homebrew Cask release support --- .env.example | 3 + README.md | 6 +- scripts/nodejs/index.js | 7 + scripts/release-lib.mjs | 36 +++++ scripts/release.mjs | 286 ++++++++++++++++++++++++++++++++++++- tests/release-lib.test.mjs | 38 ++++- 6 files changed, 366 insertions(+), 10 deletions(-) diff --git a/.env.example b/.env.example index 008760c..5a3af38 100644 --- a/.env.example +++ b/.env.example @@ -27,3 +27,6 @@ TCPVIEWER_R2_PUBLIC_BASE_URL=https://downloads.example.com TCPVIEWER_PUBLISH_RELEASE_TO_BACKEND=false TCPVIEWER_RELEASE_BACKEND_URL= TCPVIEWER_RELEASE_BACKEND_SCRIPT_SECRET= + +# Required only when a production release also creates a Homebrew Cask update PR. +TCPVIEWER_HOMEBREW_CASK_REPO=/path/to/homebrew-cask diff --git a/README.md b/README.md index f8d592a..2479551 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,11 @@ For production releases, add a matching entry to `ReleaseNote.json`, then run: npm run release ``` -Choose `beta` or `production` when prompted. Production releases also create the Sparkle appcast, push the `v` tag, and publish the GitHub release. Artifacts are written under: +Choose `beta` or `production` when prompted. Production releases also create the Sparkle appcast, push the `v` tag, and publish the GitHub release. They can optionally update `tcpviewer.rb` in the configured `TCPVIEWER_HOMEBREW_CASK_REPO` checkout and open a pull request from `ProxymanApp/homebrew-cask` to `Homebrew/homebrew-cask`. The checkout must be clean, on `master`, and in sync with `origin/master` before the release starts. + +For non-interactive production releases, pass `--bump-homebrew` to enable the cask PR or `--no-bump-homebrew` to skip it. `--yes` skips the Homebrew update unless `--bump-homebrew` is also present. + +Artifacts are written under: ```bash ~/Desktop/tcpviewer-production/ diff --git a/scripts/nodejs/index.js b/scripts/nodejs/index.js index ba87d99..9872109 100644 --- a/scripts/nodejs/index.js +++ b/scripts/nodejs/index.js @@ -20,6 +20,10 @@ function parseArgs(argv) { args.betaName = arg.slice("--beta-name=".length).trim(); } else if (arg === "--yes" || arg === "-y") { args.yes = true; + } else if (arg === "--bump-homebrew") { + args.bumpHomebrew = true; + } else if (arg === "--no-bump-homebrew") { + args.bumpHomebrew = false; } } return args; @@ -103,6 +107,9 @@ function buildReleaseArgs(type, args) { if (args.yes) { releaseArgs.push("--yes"); } + if (typeof args.bumpHomebrew === "boolean") { + releaseArgs.push(args.bumpHomebrew ? "--bump-homebrew" : "--no-bump-homebrew"); + } return releaseArgs; } diff --git a/scripts/release-lib.mjs b/scripts/release-lib.mjs index 4ea2cc3..574ad14 100644 --- a/scripts/release-lib.mjs +++ b/scripts/release-lib.mjs @@ -326,6 +326,42 @@ export function normalizeBetaDMGCustomName(value) { return normalizeFileNameSegment(normalized, "Beta DMG custom name"); } +export function makeHomebrewCaskBranchName({ version, buildNumber }) { + const safeVersion = normalizeFileNameSegment(version, "Homebrew Cask version"); + const safeBuildNumber = normalizeFileNameSegment(buildNumber, "Homebrew Cask build number"); + return `tcpviewer-${safeVersion}-${safeBuildNumber}`; +} + +export function parseHomebrewCaskVersion(content) { + const match = String(content).match(/^ version "([^"]+),([^"]+)"$/m); + if (!match) { + throw new Error("TCP Viewer Homebrew Cask must contain one version with a version and build number."); + } + + return { version: match[1], buildNumber: match[2] }; +} + +export function updateHomebrewCaskContent(content, { version, buildNumber, sha256 }) { + const current = String(content); + parseHomebrewCaskVersion(current); + const safeVersion = normalizeFileNameSegment(version, "Homebrew Cask version"); + const safeBuildNumber = normalizeFileNameSegment(buildNumber, "Homebrew Cask build number"); + const safeSHA256 = String(sha256 ?? "").trim().toLowerCase(); + if (!/^[a-f0-9]{64}$/.test(safeSHA256)) { + throw new Error("Homebrew Cask SHA-256 must contain 64 hexadecimal characters."); + } + + const versionMatches = current.match(/^ version ".*"$/gm) ?? []; + const shaMatches = current.match(/^ sha256 ".*"$/gm) ?? []; + if (versionMatches.length !== 1 || shaMatches.length !== 1) { + throw new Error("TCP Viewer Homebrew Cask must contain exactly one version and one SHA-256 stanza."); + } + + return current + .replace(versionMatches[0], ` version "${safeVersion},${safeBuildNumber}"`) + .replace(shaMatches[0], ` sha256 "${safeSHA256}"`); +} + export function makeR2ObjectKey({ releaseType, version, buildNumber, timestamp, fileName }) { const safeFileName = validateDMGFileName(fileName ?? makeDMGFileName({ version, buildNumber })); diff --git a/scripts/release.mjs b/scripts/release.mjs index a774a17..c6d5289 100644 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -24,6 +24,7 @@ import { findReleaseNote, generateAppcastXML, isRetryableHTTPStatus, + makeHomebrewCaskBranchName, makeGitHubReleaseTagName, makeBetaDMGFileName, makeDMGFileName, @@ -37,6 +38,7 @@ import { normalizeSparklePrivateKey, parseBuildSettings, parseEnvFile, + parseHomebrewCaskVersion, parseReleaseNotes, parseSparkleSignatureOutput, publicR2URL, @@ -45,7 +47,8 @@ import { releaseBackendRequiredEnvNames, releaseNotesToMarkdown, requiredEnvNames, - signR2Request + signR2Request, + updateHomebrewCaskContent } from "./release-lib.mjs"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -55,6 +58,10 @@ const r2UploadMaxAttempts = 3; const r2UploadConfirmationAttempts = 3; const r2UploadAttemptTimeoutMs = 300_000; const r2UploadConfirmationTimeoutMs = 30_000; +const homebrewCaskForkRepo = "ProxymanApp/homebrew-cask"; +const homebrewCaskUpstreamRepo = "Homebrew/homebrew-cask"; +const homebrewCaskDefaultBranch = "master"; +const homebrewCaskRelativePath = "Casks/t/tcpviewer.rb"; async function main() { const args = parseArgs(process.argv.slice(2)); @@ -62,6 +69,8 @@ async function main() { if (!["beta", "production"].includes(releaseType)) { throw new Error("Release type must be beta or production."); } + const bumpHomebrew = releaseType === "production" + && (args.bumpHomebrew ?? (args.yes ? false : await askHomebrewCaskBump())); const { env, envFileExists } = await loadReleaseEnv(); const settings = await readXcodeBuildSettings(env); @@ -89,6 +98,9 @@ async function main() { const downloadURL = publicR2URL(env.TCPVIEWER_R2_PUBLIC_BASE_URL, releaseObjectKey); const outputDir = releaseOutputDir({ releaseType, version, buildNumber, timestamp }); const releaseBackend = resolveReleaseBackend({ env, releaseType }); + const homebrewRelease = bumpHomebrew + ? resolveHomebrewRelease({ env, version, buildNumber }) + : null; console.log(`Preparing ${releaseType} release ${version} (${buildNumber})`); if (!envFileExists) { @@ -106,7 +118,17 @@ async function main() { }; } - await preflight({ env, releaseType, objectKey, settings, releaseBackend, version, buildNumber, githubRelease }); + await preflight({ + env, + releaseType, + objectKey, + settings, + releaseBackend, + version, + buildNumber, + githubRelease, + homebrewRelease + }); printReleaseSummary({ releaseType, @@ -119,7 +141,8 @@ async function main() { envFileExists, releaseNote, githubRelease, - releaseBackend + releaseBackend, + homebrewRelease }); if (!args.yes && !await askReleaseConfirmation()) { console.log("Release cancelled."); @@ -158,6 +181,9 @@ async function main() { dmgPath, outputDir }); + if (homebrewRelease) { + await createHomebrewCaskPullRequest({ homebrewRelease, dmgPath }); + } } console.log(`${releaseType === "production" ? "Production" : "BETA"} release is ready: ${downloadURL}`); @@ -182,7 +208,17 @@ async function loadReleaseEnv() { }; } -async function preflight({ env, releaseType, objectKey, settings, releaseBackend, version, buildNumber, githubRelease }) { +async function preflight({ + env, + releaseType, + objectKey, + settings, + releaseBackend, + version, + buildNumber, + githubRelease, + homebrewRelease +}) { // Keep preflight strict because it runs before long signing and notarization work. const missing = missingRequiredEnv(env, requiredEnvNames(releaseType)); if (missing.length) { @@ -190,6 +226,9 @@ async function preflight({ env, releaseType, objectKey, settings, releaseBackend } await requireTool("git", ["--version"]); + if (homebrewRelease) { + await preflightHomebrewRelease(homebrewRelease); + } await requireTool("node", ["--version"]); await requireTool("npm", ["--version"]); await ensureCreateDMGTool(); @@ -309,6 +348,219 @@ async function createGitHubRelease({ githubRelease, releaseNote, dmgPath, output console.log(`GitHub release created: ${releaseURL}`); } +// Resolve the fixed cask and branch locations before release preflight starts. +function resolveHomebrewRelease({ env, version, buildNumber }) { + const configuredPath = String(env.TCPVIEWER_HOMEBREW_CASK_REPO ?? "").trim(); + if (!configuredPath) { + throw new Error("TCPVIEWER_HOMEBREW_CASK_REPO is required when Homebrew Cask publishing is selected."); + } + + const repoPath = configuredPath === "~" + ? homedir() + : configuredPath.startsWith("~/") + ? path.join(homedir(), configuredPath.slice(2)) + : path.resolve(configuredPath); + return { + version, + buildNumber, + repoPath, + caskPath: path.join(repoPath, homebrewCaskRelativePath), + branchName: makeHomebrewCaskBranchName({ version, buildNumber }) + }; +} + +// Reject an unsafe or stale fork checkout before any release side effects occur. +async function preflightHomebrewRelease(homebrewRelease) { + await requireTool("brew", ["--version"]); + await requireTool("gh", ["--version"]); + await requireTool("gh", ["auth", "status"]); + + let repoStat; + try { + repoStat = await stat(homebrewRelease.repoPath); + } catch { + throw new Error(`Homebrew Cask repository was not found: ${homebrewRelease.repoPath}`); + } + if (!repoStat.isDirectory()) { + throw new Error(`Homebrew Cask repository is not a directory: ${homebrewRelease.repoPath}`); + } + + const repoRootResult = await runCommand("git", ["rev-parse", "--show-toplevel"], { + cwd: homebrewRelease.repoPath, + capture: true + }); + if (path.resolve(repoRootResult.stdout.trim()) !== path.resolve(homebrewRelease.repoPath)) { + throw new Error(`TCPVIEWER_HOMEBREW_CASK_REPO must point to the repository root: ${homebrewRelease.repoPath}`); + } + + const originURL = (await runCommand("git", ["remote", "get-url", "origin"], { + cwd: homebrewRelease.repoPath, + capture: true + })).stdout.trim(); + if (githubRepoFromRemoteURL(originURL) !== homebrewCaskForkRepo.toLowerCase()) { + throw new Error(`Homebrew Cask origin must be ${homebrewCaskForkRepo}; found ${originURL}.`); + } + + const parentRepo = (await runCommand("gh", [ + "api", + `repos/${homebrewCaskForkRepo}`, + "--jq", + ".parent.full_name" + ], { capture: true })).stdout.trim(); + if (parentRepo !== homebrewCaskUpstreamRepo) { + throw new Error(`${homebrewCaskForkRepo} must be a fork of ${homebrewCaskUpstreamRepo}.`); + } + try { + await runCommand("gh", [ + "api", + `repos/${homebrewCaskUpstreamRepo}/contents/${homebrewCaskRelativePath}`, + "--silent" + ], { capture: true }); + } catch { + throw new Error( + `TCP Viewer is not present in ${homebrewCaskUpstreamRepo}; merge the initial cask before enabling release bumps.` + ); + } + + await runCommand("git", ["fetch", "origin", homebrewCaskDefaultBranch, "--prune"], { + cwd: homebrewRelease.repoPath, + capture: true + }); + await ensureHomebrewGitReady(homebrewRelease); + + let caskContent; + try { + caskContent = await readFile(homebrewRelease.caskPath, "utf8"); + } catch { + throw new Error(`TCP Viewer Homebrew Cask was not found: ${homebrewRelease.caskPath}`); + } + const currentVersion = parseHomebrewCaskVersion(caskContent); + if ( + currentVersion.version === homebrewRelease.version + && currentVersion.buildNumber === homebrewRelease.buildNumber + ) { + throw new Error(`Homebrew Cask already contains ${homebrewRelease.version},${homebrewRelease.buildNumber}.`); + } + + await ensureHomebrewBranchDoesNotExist(homebrewRelease); + await runCommand("brew", ["style", homebrewRelease.caskPath]); + console.log(`Homebrew Cask check passed for ${homebrewRelease.repoPath}.`); +} + +// Require a clean fork default branch that exactly matches its remote tracking branch. +async function ensureHomebrewGitReady(homebrewRelease) { + const status = await runCommand("git", ["status", "--porcelain"], { + cwd: homebrewRelease.repoPath, + capture: true + }); + if (status.stdout.trim()) { + throw new Error("Homebrew Cask working tree must be clean before a production release."); + } + + const branch = (await runCommand("git", ["branch", "--show-current"], { + cwd: homebrewRelease.repoPath, + capture: true + })).stdout.trim(); + if (branch !== homebrewCaskDefaultBranch) { + throw new Error(`Homebrew Cask repository must be on ${homebrewCaskDefaultBranch}; current branch is ${branch || "detached HEAD"}.`); + } + + const counts = (await runCommand("git", [ + "rev-list", + "--left-right", + "--count", + `HEAD...origin/${homebrewCaskDefaultBranch}` + ], { cwd: homebrewRelease.repoPath, capture: true })).stdout.trim().split(/\s+/); + if (counts[0] !== "0" || counts[1] !== "0") { + throw new Error(`Homebrew Cask ${homebrewCaskDefaultBranch} must be in sync with origin/${homebrewCaskDefaultBranch}.`); + } +} + +async function ensureHomebrewBranchDoesNotExist(homebrewRelease) { + try { + await runCommand("git", ["show-ref", "--verify", "--quiet", `refs/heads/${homebrewRelease.branchName}`], { + cwd: homebrewRelease.repoPath, + capture: true + }); + throw new Error(`Homebrew Cask branch already exists locally: ${homebrewRelease.branchName}`); + } catch (error) { + if (String(error.message).startsWith("Homebrew Cask branch already exists")) { + throw error; + } + } + + const remoteBranch = await runCommand("git", [ + "ls-remote", + "--heads", + "origin", + `refs/heads/${homebrewRelease.branchName}` + ], { cwd: homebrewRelease.repoPath, capture: true }); + if (remoteBranch.stdout.trim()) { + throw new Error(`Homebrew Cask branch already exists on origin: ${homebrewRelease.branchName}`); + } +} + +// Publish the minimal version and checksum change from the maintained fork. +async function createHomebrewCaskPullRequest({ homebrewRelease, dmgPath }) { + const sha256 = await sha256File(dmgPath); + const currentContent = await readFile(homebrewRelease.caskPath, "utf8"); + const updatedContent = updateHomebrewCaskContent(currentContent, { + version: homebrewRelease.version, + buildNumber: homebrewRelease.buildNumber, + sha256 + }); + + await runCommand("git", ["switch", "-c", homebrewRelease.branchName], { + cwd: homebrewRelease.repoPath + }); + try { + await writeFile(homebrewRelease.caskPath, updatedContent); + await runCommand("brew", ["style", homebrewRelease.caskPath]); + await runCommand("git", ["add", homebrewCaskRelativePath], { cwd: homebrewRelease.repoPath }); + await runCommand("git", ["diff", "--cached", "--check"], { cwd: homebrewRelease.repoPath }); + await runCommand("git", ["commit", "-m", `tcpviewer ${homebrewRelease.version}`], { + cwd: homebrewRelease.repoPath + }); + await runCommand("git", ["push", "--set-upstream", "origin", homebrewRelease.branchName], { + cwd: homebrewRelease.repoPath + }); + + const result = await runCommand("gh", [ + "pr", + "create", + "--repo", + homebrewCaskUpstreamRepo, + "--base", + homebrewCaskDefaultBranch, + "--head", + `ProxymanApp:${homebrewRelease.branchName}`, + "--title", + `tcpviewer ${homebrewRelease.version}`, + "--body", + [ + "Built and tested locally on macOS.", + "", + "AI-assisted release automation updated the version and checksum; the maintainer verified the release artifact." + ].join("\n") + ], { capture: true }); + const pullRequestURL = result.stdout.trim(); + console.log(`Homebrew Cask pull request created: ${pullRequestURL}`); + } catch (error) { + throw new Error( + `Homebrew Cask update failed. Branch ${homebrewRelease.branchName} was kept for recovery.\n${error.message}` + ); + } + + await runCommand("git", ["switch", homebrewCaskDefaultBranch], { cwd: homebrewRelease.repoPath }); +} + +function githubRepoFromRemoteURL(remoteURL) { + const match = String(remoteURL).trim().match( + /^(?:git@github\.com:|ssh:\/\/git@github\.com\/|https:\/\/github\.com\/)([^/]+\/[^/]+?)(?:\.git)?$/i + ); + return match ? match[1].toLowerCase() : null; +} + async function signDMG({ env, dmgPath, settings }) { const signUpdatePath = await findSparkleSignUpdate(env, settings); const keyDir = await mkdtemp(path.join(tmpdir(), "tcpviewer-sparkle-")); @@ -898,10 +1150,10 @@ function isNativeModuleVersionMismatch(error) { return message.includes("NODE_MODULE_VERSION") || message.includes("ERR_DLOPEN_FAILED"); } -function runCommand(command, args, { env = {}, capture = false } = {}) { +function runCommand(command, args, { env = {}, capture = false, cwd = repoRoot } = {}) { return new Promise((resolve, reject) => { const child = spawn(command, args, { - cwd: repoRoot, + cwd, env: { ...process.env, ...env }, stdio: capture ? ["ignore", "pipe", "pipe"] : "inherit" }); @@ -961,6 +1213,20 @@ async function askBetaDMGCustomName(version, buildNumber) { return requirePromptValue(response.customName, "Beta DMG custom name"); } +async function askHomebrewCaskBump() { + const response = await prompts({ + type: "confirm", + name: "bumpHomebrew", + message: "Create a Homebrew Cask update pull request after this production release?", + initial: false + }); + + if (typeof response.bumpHomebrew !== "boolean") { + throw new Error("Homebrew Cask selection was cancelled."); + } + return response.bumpHomebrew; +} + async function askReleaseConfirmation() { const response = await prompts({ type: "confirm", @@ -994,7 +1260,8 @@ function printReleaseSummary({ envFileExists, releaseNote, githubRelease, - releaseBackend + releaseBackend, + homebrewRelease }) { console.log(""); console.log("Release summary:"); @@ -1014,6 +1281,7 @@ function printReleaseSummary({ if (githubRelease) { console.log(`- GitHub release: ${githubRelease.repo} ${githubRelease.tagName}`); } + console.log(`- Homebrew Cask PR: ${homebrewRelease ? `enabled (${homebrewRelease.repoPath})` : "disabled"}`); } function validateRequiredBuildSettings(settings) { @@ -1033,6 +1301,10 @@ function parseArgs(argv) { args.betaName = arg.slice("--beta-name=".length); } else if (arg === "--yes" || arg === "-y") { args.yes = true; + } else if (arg === "--bump-homebrew") { + args.bumpHomebrew = true; + } else if (arg === "--no-bump-homebrew") { + args.bumpHomebrew = false; } } return args; diff --git a/tests/release-lib.test.mjs b/tests/release-lib.test.mjs index a044520..c27db31 100644 --- a/tests/release-lib.test.mjs +++ b/tests/release-lib.test.mjs @@ -11,6 +11,7 @@ import { makeBetaDMGFileName, makeDMGFileName, makeGitHubReleaseTagName, + makeHomebrewCaskBranchName, makeR2ObjectURL, makeR2ObjectKey, makeR2StorageObjectKey, @@ -20,6 +21,7 @@ import { normalizeReleaseBackendURL, normalizeSparklePrivateKey, parseEnvFile, + parseHomebrewCaskVersion, parseReleaseNotes, parseSparkleSignatureOutput, publicR2URL, @@ -29,7 +31,8 @@ import { releaseNotesToMarkdown, releaseNotesToHTML, requiredEnvNames, - signR2Request + signR2Request, + updateHomebrewCaskContent } from "../scripts/release-lib.mjs"; test("parses and validates release notes", () => { @@ -180,6 +183,36 @@ test("builds versioned DMG file names", () => { ); }); +test("updates only Homebrew Cask release metadata", () => { + const cask = [ + 'cask "tcpviewer" do', + ' version "1.9.0,30"', + ` sha256 "${"a".repeat(64)}"`, + "", + ' app "TCP Viewer.app"', + "end", + "" + ].join("\n"); + const updated = updateHomebrewCaskContent(cask, { + version: "1.10.0", + buildNumber: "31", + sha256: "B".repeat(64) + }); + + assert.deepEqual(parseHomebrewCaskVersion(updated), { version: "1.10.0", buildNumber: "31" }); + assert.match(updated, new RegExp(`sha256 "${"b".repeat(64)}"`)); + assert.match(updated, /app "TCP Viewer\.app"/); + assert.equal(makeHomebrewCaskBranchName({ version: "1.10.0", buildNumber: "31" }), "tcpviewer-1.10.0-31"); + assert.throws( + () => updateHomebrewCaskContent(cask, { version: "../1.10.0", buildNumber: "31", sha256: "b".repeat(64) }), + /Homebrew Cask version/ + ); + assert.throws( + () => updateHomebrewCaskContent(cask, { version: "1.10.0", buildNumber: "31", sha256: "invalid" }), + /SHA-256/ + ); +}); + test("parses xcconfig-style env files and redacts secrets", () => { const parsed = parseEnvFile([ "TCPVIEWER_APPCAST_URL=https:/$()/updates.example.com/appcast.xml", @@ -218,7 +251,8 @@ test("documents required release env values with safe placeholders", () => { const documentedNames = [ ...requiredEnvNames("production"), "TCPVIEWER_PUBLISH_RELEASE_TO_BACKEND", - ...releaseBackendRequiredEnvNames + ...releaseBackendRequiredEnvNames, + "TCPVIEWER_HOMEBREW_CASK_REPO" ]; assert.doesNotMatch(content, /^\s*\/\//m);