Skip to content

Commit d1b4e58

Browse files
authored
feat(release): ship the Homebrew formula from native release tarballs (#356)
## Requirement or Bug Ship the Homebrew formula as the native binary, the way CodexBar's tap does: per-platform release tarballs, bumped by the release run, verified by a real install. Stacked on #355 (shares `update-brew-formula.mjs`). ## Bug Reproduction Steps N/A (feature). ## Root Cause N/A (feature). ## Code Changes - `update-brew-formula.mjs`: `renderFormula` writes the whole formula from a template instead of patching one `url` line with a regex. The template has `on_macos`/`on_linux` × `Hardware::CPU.arm?`, one native `pythinker-code-<target>.tar.gz` url + sha256 per target, `bin.install "pythinker"`, and a `test do` that asserts `pythinker --version`. The sha256 comes from the downloaded bytes, not from the sidecars. All four downloads share one 600 s poll that retries on 404. After the push, the script reads `Formula/pythinker-code.rb` back from the tap's `main` and fails if it differs. A rejected push gets up to 3 attempts with `pull --rebase` between them. - `release.yml`: - `update-brew-tap` now also needs `publish-native-assets`. On 2.4.1 the tarballs appeared about 13 min after `release` finished. - New `verify-brew-install` job (macos-latest + ubuntu-latest) runs `brew tap`, `brew install`, `brew test` and compares `--version` with `package.json`. - `Release lane summary` reads `BREW_RESULT` from `verify-brew-install`. - `cli/update/source.ts`: a native binary under a Homebrew Cellar counts as a `homebrew` install. `detectNativeInstall()` returns false there, so the binary never stages or swaps itself inside the Cellar, and `brew upgrade` is its update path. Every caller of `detectNativeInstall()` (update download, startup swap, source detection) goes through this one function. - `.agents/skills/release/SKILL.md`: the brew section now describes this flow. How this differs from CodexBar: CodexBar dispatches a tap-side workflow with a PAT. This PR keeps the existing GitHub App token, which pushes the formula directly. That needs no new secret and no workflow in the second repo. The substance is the same: a native per-platform formula, a 404 retry on assets, and a content check after the bump. ## Behavior Changes and Affected Users | Behavior | Before | After | Who relies on the old behavior | Escape hatch | |---|---|---|---|---| | What `brew install pymodel/tap/pythinker-code` installs | npm tarball + `depends_on "node"` | native binary, no Node.js | Homebrew users on macOS and Linux | `npm i -g @pymodel/pythinker-code` | | `node` formula after `brew upgrade` | a dependency | orphaned (`brew autoremove` may delete it) | users who rely on brew-managed `node` only through this formula | `brew install node` | | opentui caveat in the formula | printed | removed (the native binary bundles it) | nobody | n/a | | Install source of a native binary in `/opt/homebrew/Cellar/…` or `/home/linuxbrew/.linuxbrew/Cellar/…` | `native` (would stage and swap itself) | `homebrew` (shows the `brew upgrade` hint) | nobody; only this PR puts native binaries into the Cellar | n/a | | Release lane brew result | the tap push job | the real install on macOS + Ubuntu | release operators | `RELEASE_LANE_BREW=disabled` (unchanged) | Inventory of the old npm-formula path and where each item went: | Old item | New place | |---|---| | `depends_on "node"` | dropped; the binary bundles Node | | opentui caveat | dropped | | npm tarball sha256 from the registry | sha256 of each release tarball from the downloaded bytes | | npm 404 poll (600 s) | the same poll, shared across the 4 release tarballs | | `--version` test | kept, now on the native binary | Test coverage: - `source.test.ts`: Cellar paths on macOS and Linux → `homebrew`, and `detectNativeInstall` returns false. Both failed before the change. - `update-brew-formula.test.mjs`: formula pairs in order, a missing target throws, an invalid sha256 throws. - `release-workflows.test.mjs`: the brew needs and `BREW_RESULT` wiring. - Manual check with real Homebrew on the 2.4.1 tarballs, done with `brew install` + `brew test`: - macOS arm64: the Developer ID signature stays intact. - Linux x64 (`homebrew/brew` container): `--version` prints 2.4.1. - `pnpm test:release` 58/58; app update suites 329/329; `tsc`, lint, actionlint clean. ## Checklist - [x] I have read the [CONTRIBUTING](https://github.com/PyModel/pythinker-code/blob/main/CONTRIBUTING.md) document. - [x] I have linked a related issue (external PRs: issue must have a maintainer's `/approve`). - [x] I have added tests that prove my feature works. - [x] The behavior-change table above is complete, and every removed behavior or flipped default is named in the changeset and either has an escape hatch or was explicitly approved by a maintainer in this PR. - [x] Ran `gen-changesets` skill, or this PR needs no changeset. - [x] Ran `gen-docs` skill, or this PR needs no doc update.
1 parent 34b854a commit d1b4e58

8 files changed

Lines changed: 315 additions & 83 deletions

File tree

‎.agents/skills/release/SKILL.md‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@ workspace, set its `private` and changesets policy explicitly and update `flake.
4343
| `Native release artifact` | CLI was published | Six signed/tested zips, checksums, provenance |
4444
| `Publish native release assets` | native builds passed | All-or-nothing immutable upload with `manifest.json` |
4545
| `Redeploy CDN` + verify | native assets published | Webhook may retry; verification is the hard gate |
46-
| `Update Homebrew tap` | CLI was published | App token scoped to `homebrew-tap` contents |
46+
| `Update Homebrew tap` | native assets published | Renders `Formula/pythinker-code.rb` from the four native `.tar.gz` (macOS/Linux × arm64/x64), hashes downloaded bytes, pushes with an App token scoped to `homebrew-tap` contents, reads the formula back from the tap |
47+
| `Verify Homebrew install` | tap updated | `brew install` + `brew test` from `pymodel/tap` on macOS and Linux; `pythinker --version` must equal the release. This is the Homebrew lane result in the summary |
4748
| `Release lane summary` | always | One table with provenance state; fails when an expected enabled lane failed or skipped |
4849

4950
Set `RELEASE_LANE_DESKTOP`, `RELEASE_LANE_VSCODE`, `RELEASE_LANE_CDN`, or
@@ -70,6 +71,17 @@ otherwise errors.
7071
`beta`/`dev` tags). A mismatch means the checkout in the job predates the release commit or npm
7172
propagation lag — check `npm view @pymodel/pythinker-code dist-tags` before touching anything.
7273
Dokploy deploy specifics: see memory `cdn-dokploy-deploy-pipeline`.
74+
- **Homebrew lane red.** `Update Homebrew tap` polls each native tarball for 10 minutes, so a
75+
failure there means the release has no tarball for that target: check `Publish native release
76+
assets` first. `Verify Homebrew install` red with the bump green means the formula installs but the
77+
binary fails in a keg on that OS; reproduce with `HOMEBREW_NO_AUTOREMOVE=1 brew install
78+
pymodel/tap/pythinker-code` (plain `brew uninstall` afterwards autoremoves orphaned dependencies).
79+
A native binary under a Homebrew `Cellar/` reports install source `homebrew` and never
80+
self-updates; `brew upgrade pythinker-code` is its only update path.
81+
- **Native update 404s.** `verify-release-consistency.mjs` HEADs every URL in the CDN `latest.json`
82+
and every file the release `manifest.json` names. A red gate lists the missing assets; the
83+
updater fetches exactly those URLs from the GitHub release (`pythinkerCodeReleaseAssetUrl`). The
84+
CDN has no `/binaries/` route — it answers unknown paths with the site HTML and HTTP 200.
7385
- **`pnpm install` fails in CI or locally.** `engine-strict=true` + Node `>=24.15.0` — check
7486
`.nvmrc` before debugging anything else.
7587
- **Identity freeze / version rewind.** Copying another product's `CHANGELOG.md`, `package.json`

‎.changeset/brew-native-formula.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@pymodel/pythinker-code': minor
3+
---
4+
5+
Homebrew now installs the native `pythinker` binary on macOS and Linux, without Node.js.

‎.github/workflows/release.yml‎

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -494,7 +494,11 @@ jobs:
494494
permissions:
495495
contents: read
496496
name: Update Homebrew tap
497-
needs: release
497+
# The formula installs the native tarballs, so it can only point at them
498+
# once publish-native-assets has put them on the release.
499+
needs:
500+
- release
501+
- publish-native-assets
498502
if: >-
499503
needs.release.outputs.pythinker_native_release == 'true'
500504
&& vars.RELEASE_LANE_BREW != 'disabled'
@@ -526,6 +530,51 @@ jobs:
526530
TAP_GITHUB_TOKEN: ${{ steps.tap-token.outputs.token }}
527531
run: node scripts/release/update-brew-formula.mjs
528532

533+
# Installs the bumped formula from the public tap on a real Homebrew, the
534+
# way users get it, and checks the binary reports the released version.
535+
# Homebrew relocates and may re-sign what it installs, so this is the only
536+
# proof the native binary survives a keg on each OS.
537+
verify-brew-install:
538+
timeout-minutes: 20
539+
name: Verify Homebrew install (${{ matrix.os }})
540+
needs:
541+
- update-brew-tap
542+
permissions:
543+
contents: read
544+
strategy:
545+
fail-fast: false
546+
matrix:
547+
os: [macos-latest, ubuntu-latest]
548+
runs-on: ${{ matrix.os }}
549+
steps:
550+
- name: Checkout
551+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pinned from v6.0.2
552+
with:
553+
persist-credentials: false
554+
sparse-checkout: apps/pythinker-code/package.json
555+
sparse-checkout-cone-mode: false
556+
557+
- name: Install pythinker-code from PyModel/tap
558+
shell: bash
559+
env:
560+
HOMEBREW_NO_AUTO_UPDATE: '1'
561+
HOMEBREW_NO_INSTALL_CLEANUP: '1'
562+
run: |
563+
set -euo pipefail
564+
if [ -x /home/linuxbrew/.linuxbrew/bin/brew ]; then
565+
eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)"
566+
fi
567+
expected="$(jq -r .version apps/pythinker-code/package.json)"
568+
brew tap pymodel/tap
569+
brew install --formula pymodel/tap/pythinker-code
570+
brew test pymodel/tap/pythinker-code
571+
actual="$("$(brew --prefix)/bin/pythinker" --version)"
572+
if [ "$actual" != "$expected" ]; then
573+
echo "::error::Homebrew installed pythinker $actual, expected $expected."
574+
exit 1
575+
fi
576+
echo "Homebrew installs pythinker $actual on ${{ matrix.os }}."
577+
529578
deploy-docs:
530579
name: Deploy docs
531580
needs: release
@@ -663,6 +712,7 @@ jobs:
663712
- redeploy-cdn
664713
- verify-cdn-release
665714
- update-brew-tap
715+
- verify-brew-install
666716
runs-on: ubuntu-latest
667717
permissions:
668718
contents: read
@@ -687,7 +737,7 @@ jobs:
687737
CDN_DEPLOY_RESULT: ${{ needs.redeploy-cdn.result }}
688738
CDN_VERIFY_RESULT: ${{ needs.verify-cdn-release.result }}
689739
BREW_ENABLED: ${{ vars.RELEASE_LANE_BREW != 'disabled' }}
690-
BREW_RESULT: ${{ needs.update-brew-tap.result }}
740+
BREW_RESULT: ${{ needs.verify-brew-install.result }}
691741
DESKTOP_EXPECTED: ${{ needs.release.outputs.desktop_version_bumped }}
692742
DESKTOP_ENABLED: ${{ vars.RELEASE_LANE_DESKTOP != 'disabled' }}
693743
DESKTOP_RESULT: ${{ needs.cut-desktop-tag.result }}

‎apps/pythinker-code/src/cli/update/source.ts‎

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,7 @@ function loadSeaModule(): NodeSeaModule | null {
2626
return cachedSea;
2727
}
2828

29-
/** Runtime SEA detection — true when running as a packaged native binary. */
30-
export function detectNativeInstall(): boolean {
29+
function isSeaBinary(): boolean {
3130
const sea = loadSeaModule();
3231
if (sea === null) return false;
3332
try {
@@ -37,6 +36,19 @@ export function detectNativeInstall(): boolean {
3736
}
3837
}
3938

39+
/**
40+
* True for a self-updating native install: a packaged native binary that no
41+
* package manager owns. A native binary Homebrew installed lives in its
42+
* Cellar; staging or swapping it there would desync Homebrew's records, so
43+
* `brew upgrade` stays its only update path.
44+
*/
45+
export function detectNativeInstall(
46+
execPath: string = process.execPath,
47+
isSea: () => boolean = isSeaBinary,
48+
): boolean {
49+
return isSea() && classifyByPathHeuristic(execPath) !== 'homebrew';
50+
}
51+
4052
// Path heuristic markers (compared in lowercase; both forward and backward slashes accepted).
4153
const PNPM_PATH_SEGMENT = 'pnpm/global/';
4254
const YARN_PATH_SEGMENTS = ['.config/yarn/global/', '/.yarn/global/'];
@@ -70,6 +82,7 @@ export interface DetectInstallSourceDeps {
7082
readonly getPackageRoot: () => string;
7183
readonly getGlobalPrefix: () => Promise<string>;
7284
readonly detectNative: () => boolean;
85+
readonly execPath: string;
7386
readonly platform: NodeJS.Platform;
7487
}
7588

@@ -153,11 +166,14 @@ export async function detectInstallSource(
153166
getGlobalPrefix:
154167
deps.getGlobalPrefix ??
155168
(() => npmGlobalPrefix(platform)),
156-
detectNative: deps.detectNative ?? detectNativeInstall,
169+
detectNative: deps.detectNative ?? isSeaBinary,
170+
execPath: deps.execPath ?? process.execPath,
157171
platform,
158172
};
159173

160-
if (resolved.detectNative()) return 'native';
174+
if (resolved.detectNative()) {
175+
return classifyByPathHeuristic(resolved.execPath) === 'homebrew' ? 'homebrew' : 'native';
176+
}
161177

162178
const packageRoot = resolved.getPackageRoot();
163179
const heuristic = classifyByPathHeuristic(packageRoot);

‎apps/pythinker-code/test/cli/update/source.test.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
classifyByPathHeuristic,
55
classifyInstallSource,
66
detectInstallSource,
7+
detectNativeInstall,
78
} from '#/cli/update/source';
89
import { resolveCommandPath } from '#/utils/process/resolve-command';
910

@@ -158,6 +159,18 @@ describe('detectInstallSource', () => {
158159
).resolves.toBe('native');
159160
});
160161

162+
it('returns homebrew for a native binary that Homebrew installed in its Cellar', async () => {
163+
await expect(
164+
detectInstallSource({
165+
getPackageRoot: () => '/opt/homebrew/Cellar/pythinker-code/2.5.0/bin',
166+
getGlobalPrefix: async () => '/opt/homebrew',
167+
detectNative: () => true,
168+
execPath: '/opt/homebrew/Cellar/pythinker-code/2.5.0/bin/pythinker',
169+
platform: 'darwin',
170+
}),
171+
).resolves.toBe('homebrew');
172+
});
173+
161174
it('returns unsupported when nothing matches', async () => {
162175
await expect(
163176
detectInstallSource({
@@ -197,3 +210,20 @@ describe('detectInstallSource', () => {
197210
expect(resolveCommandPath).toHaveBeenCalledWith('npm');
198211
});
199212
});
213+
214+
describe('detectNativeInstall', () => {
215+
it('is true for a native binary outside any package manager', () => {
216+
expect(detectNativeInstall('/Users/someone/.local/bin/pythinker', () => true)).toBe(true);
217+
});
218+
219+
it('is false for a native binary that Homebrew owns, so it never stages or swaps itself', () => {
220+
expect(detectNativeInstall('/opt/homebrew/Cellar/pythinker-code/2.5.0/bin/pythinker', () => true)).toBe(false);
221+
expect(
222+
detectNativeInstall('/home/linuxbrew/.linuxbrew/Cellar/pythinker-code/2.5.0/bin/pythinker', () => true),
223+
).toBe(false);
224+
});
225+
226+
it('is false when the process is not a native binary', () => {
227+
expect(detectNativeInstall('/Users/someone/.local/bin/pythinker', () => false)).toBe(false);
228+
});
229+
});

‎scripts/release/release-workflows.test.mjs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ void test('release workflow uses full push-boundary lane signals and isolated jo
3232
assert.match(workflow, /APPLE_CERTIFICATE_P12: \$\{\{ secrets\.MAC_CSC_LINK \}\}/u);
3333
assert.match(workflow, /APPLE_NOTARIZATION_KEY_P8: \$\{\{ secrets\.APPLE_API_KEY_P8 \}\}/u);
3434
assert.match(workflow, /^ update-brew-tap:\n timeout-minutes: 20$/mu);
35+
const brewJob = workflow.slice(workflow.indexOf(' update-brew-tap:'), workflow.indexOf(' verify-brew-install:'));
36+
assert.match(brewJob, /needs:\n - release\n - publish-native-assets\n/u);
37+
assert.match(workflow, /^ verify-brew-install:/mu);
38+
assert.match(workflow, /BREW_RESULT: \$\{\{ needs\.verify-brew-install\.result \}\}/u);
3539
});
3640

3741
void test('VS Code release supports isolated recovery and attests verified VSIX files', () => {

0 commit comments

Comments
 (0)