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
14 changes: 13 additions & 1 deletion .agents/skills/release/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ workspace, set its `private` and changesets policy explicitly and update `flake.
| `Native release artifact` | CLI was published | Six signed/tested zips, checksums, provenance |
| `Publish native release assets` | native builds passed | All-or-nothing immutable upload with `manifest.json` |
| `Redeploy CDN` + verify | native assets published | Webhook may retry; verification is the hard gate |
| `Update Homebrew tap` | CLI was published | App token scoped to `homebrew-tap` contents |
| `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 |
| `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 |
| `Release lane summary` | always | One table with provenance state; fails when an expected enabled lane failed or skipped |

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

Homebrew now installs the native `pythinker` binary on macOS and Linux, without Node.js.
54 changes: 52 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -494,7 +494,11 @@ jobs:
permissions:
contents: read
name: Update Homebrew tap
needs: release
# The formula installs the native tarballs, so it can only point at them
# once publish-native-assets has put them on the release.
needs:
- release
- publish-native-assets
if: >-
needs.release.outputs.pythinker_native_release == 'true'
&& vars.RELEASE_LANE_BREW != 'disabled'
Expand Down Expand Up @@ -526,6 +530,51 @@ jobs:
TAP_GITHUB_TOKEN: ${{ steps.tap-token.outputs.token }}
run: node scripts/release/update-brew-formula.mjs

# Installs the bumped formula from the public tap on a real Homebrew, the
# way users get it, and checks the binary reports the released version.
# Homebrew relocates and may re-sign what it installs, so this is the only
# proof the native binary survives a keg on each OS.
verify-brew-install:
timeout-minutes: 20
name: Verify Homebrew install (${{ matrix.os }})
needs:
- update-brew-tap
permissions:
contents: read
strategy:
fail-fast: false
matrix:
os: [macos-latest, ubuntu-latest]
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pinned from v6.0.2
with:
persist-credentials: false
sparse-checkout: apps/pythinker-code/package.json
sparse-checkout-cone-mode: false

- name: Install pythinker-code from PyModel/tap
shell: bash
env:
HOMEBREW_NO_AUTO_UPDATE: '1'
HOMEBREW_NO_INSTALL_CLEANUP: '1'
run: |
set -euo pipefail
if [ -x /home/linuxbrew/.linuxbrew/bin/brew ]; then
eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)"
fi
expected="$(jq -r .version apps/pythinker-code/package.json)"
brew tap pymodel/tap
brew install --formula pymodel/tap/pythinker-code
brew test pymodel/tap/pythinker-code
actual="$("$(brew --prefix)/bin/pythinker" --version)"
if [ "$actual" != "$expected" ]; then
echo "::error::Homebrew installed pythinker $actual, expected $expected."
exit 1
fi
echo "Homebrew installs pythinker $actual on ${{ matrix.os }}."

deploy-docs:
name: Deploy docs
needs: release
Expand Down Expand Up @@ -663,6 +712,7 @@ jobs:
- redeploy-cdn
- verify-cdn-release
- update-brew-tap
- verify-brew-install
runs-on: ubuntu-latest
permissions:
contents: read
Expand All @@ -687,7 +737,7 @@ jobs:
CDN_DEPLOY_RESULT: ${{ needs.redeploy-cdn.result }}
CDN_VERIFY_RESULT: ${{ needs.verify-cdn-release.result }}
BREW_ENABLED: ${{ vars.RELEASE_LANE_BREW != 'disabled' }}
BREW_RESULT: ${{ needs.update-brew-tap.result }}
BREW_RESULT: ${{ needs.verify-brew-install.result }}
DESKTOP_EXPECTED: ${{ needs.release.outputs.desktop_version_bumped }}
DESKTOP_ENABLED: ${{ vars.RELEASE_LANE_DESKTOP != 'disabled' }}
DESKTOP_RESULT: ${{ needs.cut-desktop-tag.result }}
Expand Down
24 changes: 20 additions & 4 deletions apps/pythinker-code/src/cli/update/source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,7 @@ function loadSeaModule(): NodeSeaModule | null {
return cachedSea;
}

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

/**
* True for a self-updating native install: a packaged native binary that no
* package manager owns. A native binary Homebrew installed lives in its
* Cellar; staging or swapping it there would desync Homebrew's records, so
* `brew upgrade` stays its only update path.
*/
export function detectNativeInstall(
execPath: string = process.execPath,
isSea: () => boolean = isSeaBinary,
): boolean {
return isSea() && classifyByPathHeuristic(execPath) !== 'homebrew';
}

// Path heuristic markers (compared in lowercase; both forward and backward slashes accepted).
const PNPM_PATH_SEGMENT = 'pnpm/global/';
const YARN_PATH_SEGMENTS = ['.config/yarn/global/', '/.yarn/global/'];
Expand Down Expand Up @@ -70,6 +82,7 @@ export interface DetectInstallSourceDeps {
readonly getPackageRoot: () => string;
readonly getGlobalPrefix: () => Promise<string>;
readonly detectNative: () => boolean;
readonly execPath: string;
readonly platform: NodeJS.Platform;
}

Expand Down Expand Up @@ -153,11 +166,14 @@ export async function detectInstallSource(
getGlobalPrefix:
deps.getGlobalPrefix ??
(() => npmGlobalPrefix(platform)),
detectNative: deps.detectNative ?? detectNativeInstall,
detectNative: deps.detectNative ?? isSeaBinary,
execPath: deps.execPath ?? process.execPath,
platform,
};

if (resolved.detectNative()) return 'native';
if (resolved.detectNative()) {
return classifyByPathHeuristic(resolved.execPath) === 'homebrew' ? 'homebrew' : 'native';
}

const packageRoot = resolved.getPackageRoot();
const heuristic = classifyByPathHeuristic(packageRoot);
Expand Down
30 changes: 30 additions & 0 deletions apps/pythinker-code/test/cli/update/source.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
classifyByPathHeuristic,
classifyInstallSource,
detectInstallSource,
detectNativeInstall,
} from '#/cli/update/source';
import { resolveCommandPath } from '#/utils/process/resolve-command';

Expand Down Expand Up @@ -158,6 +159,18 @@ describe('detectInstallSource', () => {
).resolves.toBe('native');
});

it('returns homebrew for a native binary that Homebrew installed in its Cellar', async () => {
await expect(
detectInstallSource({
getPackageRoot: () => '/opt/homebrew/Cellar/pythinker-code/2.5.0/bin',
getGlobalPrefix: async () => '/opt/homebrew',
detectNative: () => true,
execPath: '/opt/homebrew/Cellar/pythinker-code/2.5.0/bin/pythinker',
platform: 'darwin',
}),
).resolves.toBe('homebrew');
});

it('returns unsupported when nothing matches', async () => {
await expect(
detectInstallSource({
Expand Down Expand Up @@ -197,3 +210,20 @@ describe('detectInstallSource', () => {
expect(resolveCommandPath).toHaveBeenCalledWith('npm');
});
});

describe('detectNativeInstall', () => {
it('is true for a native binary outside any package manager', () => {
expect(detectNativeInstall('/Users/someone/.local/bin/pythinker', () => true)).toBe(true);
});

it('is false for a native binary that Homebrew owns, so it never stages or swaps itself', () => {
expect(detectNativeInstall('/opt/homebrew/Cellar/pythinker-code/2.5.0/bin/pythinker', () => true)).toBe(false);
expect(
detectNativeInstall('/home/linuxbrew/.linuxbrew/Cellar/pythinker-code/2.5.0/bin/pythinker', () => true),
).toBe(false);
});

it('is false when the process is not a native binary', () => {
expect(detectNativeInstall('/Users/someone/.local/bin/pythinker', () => false)).toBe(false);
});
});
4 changes: 4 additions & 0 deletions scripts/release/release-workflows.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ void test('release workflow uses full push-boundary lane signals and isolated jo
assert.match(workflow, /APPLE_CERTIFICATE_P12: \$\{\{ secrets\.MAC_CSC_LINK \}\}/u);
assert.match(workflow, /APPLE_NOTARIZATION_KEY_P8: \$\{\{ secrets\.APPLE_API_KEY_P8 \}\}/u);
assert.match(workflow, /^ update-brew-tap:\n timeout-minutes: 20$/mu);
const brewJob = workflow.slice(workflow.indexOf(' update-brew-tap:'), workflow.indexOf(' verify-brew-install:'));
assert.match(brewJob, /needs:\n - release\n - publish-native-assets\n/u);
assert.match(workflow, /^ verify-brew-install:/mu);
assert.match(workflow, /BREW_RESULT: \$\{\{ needs\.verify-brew-install\.result \}\}/u);
});

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