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
11 changes: 9 additions & 2 deletions .agents/skills/release/SKILL.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,8 +18,11 @@ mechanics and failure modes.
- **Private lanes use the push boundary.** `publishedPackages` only lists packages published to npm.
Desktop and VS Code are private workspaces, so `detect-lane-bumps.mjs` compares their versions at
`github.event.before` and `github.sha`.
- **Desktop is tag-driven.** The required `cut-desktop-tag` job creates `desktop-v<version>`, which
starts `desktop-release.yml`.
- **Desktop Stable and Beta are tag-driven.** The required `cut-desktop-tag` job creates
`desktop-v<version>`, which starts `desktop-release.yml`; prerelease versions publish to the
explicit Beta feed.
- **Desktop Nightly is default-branch driven.** `nightly.yml` calls `desktop-release.yml` after each
scheduled main build and publishes a signed Nightly prerelease only when the main commit changed.
- **VS Code is isolated.** `vscode-release.yml` supports `workflow_call` and version-checked manual
dispatch. Existing registry versions are skipped by the publisher scripts, so recovery is safe.

Expand All@@ -35,6 +38,7 @@ workspace, set its `private` and changesets policy explicitly and update `flake.
|---|---|---|
| `Release` | every main push after CI + Nix | Detect lane versions, build, run changesets |
| `Cut desktop release tag` | desktop version changed | Required and idempotent; App token makes the tag trigger the desktop workflow |
| `Desktop Nightly` | scheduled main build | Reusable workflow; signed assets and the explicit Nightly update feed |
| `Publish VS Code extension` | extension version changed | Reusable workflow; six VSIX targets, both registries, provenance |
| `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` |
Expand All@@ -56,6 +60,8 @@ otherwise errors.
genuinely half-published release — read the log; do not blind-rerun.
- **Version PR looks wrong.** Never patch the `changeset-release/main` branch by hand. Fix or add
changesets on `main`; the next workflow run regenerates the PR.
- **Beta or Nightly checks Stable.** GitHub does not infer update channels. Confirm the release is a
prerelease and contains `beta*.yml` or `nightly*.yml`; do not rename Stable manifests.
- **Native builder fails after npm publish succeeded.** npm state is final. Re-run failed jobs from
the same run before any assets upload. A complete asset set is an idempotent no-op. A partial set
must not be filled from a rebuild; keep it or publish a new patch version.
Expand DownExpand Up@@ -83,6 +89,7 @@ otherwise errors.

```bash
gh run list --workflow=release.yml --branch=main -L 3 # workflow health
gh run list --workflow=nightly.yml --branch=main -L 3 # Nightly desktop health
gh pr list --search 'ci: release packages in:title' --state open
pnpm release:status # all live lanes
npm view @pymodel/pythinker-code dist-tags --json
Expand Down
5 changes: 5 additions & 0 deletions .changeset/bright-desktops-update.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@pymodel/pythinker-desktop": minor
---

Add signed Beta and Nightly desktop update feeds.
5 changes: 5 additions & 0 deletions .changeset/fix-restore-crash-loop.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@pymodel/pythinker-code": patch
---

Fix repeated server crashes when resuming a session that stopped during a turn.
176 changes: 119 additions & 57 deletions .github/workflows/desktop-release.yml

Large diffs are not rendered by default.

20 changes: 18 additions & 2 deletions .github/workflows/nightly.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@ name: Nightly

on:
schedule:
- cron: '0 3 * * *'
- cron: '17 3 * * *'
workflow_dispatch:

concurrency:
Expand DownExpand Up@@ -61,9 +61,18 @@ jobs:
fi
pnpm changeset publish --tag dev --no-git-tag

desktop-nightly:
name: Publish desktop Nightly
if: github.repository_owner == 'PyModel'
uses: ./.github/workflows/desktop-release.yml
with:
publish_nightly: true
secrets: inherit

release-reconciliation:
name: Reconcile release lanes
if: github.repository_owner == 'PyModel'
needs: [publish, desktop-nightly]
if: always() && github.repository_owner == 'PyModel'
runs-on: ubuntu-latest
permissions:
contents: read
Expand All@@ -72,6 +81,7 @@ jobs:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pinned from v6.0.2
with:
fetch-depth: 0
persist-credentials: false

- name: Setup Node.js
Expand All@@ -83,11 +93,17 @@ jobs:
id: release-status
env:
GITHUB_TOKEN: ${{ github.token }}
UPSTREAM_OK: ${{ needs.publish.result == 'success' && needs.desktop-nightly.result == 'success' }}
shell: bash
run: |
set +e
node scripts/release/release-status.mjs > "$RUNNER_TEMP/release-status.md" 2>&1
status=$?
if [ "$UPSTREAM_OK" != 'true' ]; then
printf '\nNightly publishers did not both succeed. CLI: %s. Desktop: %s.\n' \
'${{ needs.publish.result }}' '${{ needs.desktop-nightly.result }}' >> "$RUNNER_TEMP/release-status.md"
status=1
fi
set -e
cat "$RUNNER_TEMP/release-status.md" >> "$GITHUB_STEP_SUMMARY"
echo "exit_code=$status" >> "$GITHUB_OUTPUT"
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -72,7 +72,7 @@ Run `pnpm run dist:win` on a native Windows x64 host; cross-building from macOS

Windows artifacts are signed through Azure Artifact Signing when `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`, `AZURE_SIGNING_ENDPOINT`, `AZURE_SIGNING_ACCOUNT`, `AZURE_SIGNING_CERT_PROFILE`, and `AZURE_SIGNING_PUBLISHER_NAME` are all set; they are unsigned when neither signing method is set. The credential variables are read from the environment; the four `AZURE_SIGNING_*` variables map to `azureSignOptions.endpoint`, `azureSignOptions.codeSigningAccountName`, `azureSignOptions.certificateProfileName`, and `azureSignOptions.publisherName`, respectively. Setting only part of either signing method, or setting both methods, is a hard error.

Tagged releases require one complete Windows signing method. CI verifies the installer and packaged app with electron-updater's Authenticode verifier before upload. Both platform jobs also recompute every size and SHA-512 value in `latest.yml` or `latest-mac.yml`. The final job downloads the draft assets and repeats both manifest checks before publication. Manual workflow runs remain private workflow artifacts and cannot publish an unsigned build.
Tagged releases require one complete Windows signing method. CI verifies the installer and packaged app with electron-updater's Authenticode verifier before upload. Both platform jobs also recompute every size and SHA-512 value in the selected channel manifest (`latest`, `beta`, or `nightly`). The final job downloads the draft assets and repeats both manifest checks before publication. Manual workflow runs remain private workflow artifacts and cannot publish an unsigned build.

## Known limitations

Expand Down
221 changes: 221 additions & 0 deletions apps/desktop/scripts/desktop-release.mjs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
#!/usr/bin/env node

import { appendFileSync, readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

const channels = new Set(['stable', 'beta', 'nightly']);
const events = new Set(['push', 'workflow_call', 'workflow_dispatch']);
const versionPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/u;

function desktopChannel(value) {
if (typeof value !== 'string' || !channels.has(value)) {
throw new Error('Desktop release channel must be stable, beta, or nightly.');
}
return value;
}

function parseDesktopVersion(value) {
const match = typeof value === 'string' ? versionPattern.exec(value) : null;
if (match === null) {
throw new Error(`Invalid desktop release version: ${String(value)}`);
}
const major = Number(match[1]);
const minor = Number(match[2]);
const patch = Number(match[3]);
if (![major, minor, patch].every(Number.isSafeInteger)) {
throw new Error(`Desktop release version is outside the safe integer range: ${value}`);
}
return { major, minor, patch, prerelease: match[4] };
}

function desktopVersion(value) {
parseDesktopVersion(value);
return value;
}

function versionChannel(version) {
const prerelease = parseDesktopVersion(version).prerelease;
if (prerelease === undefined) return 'stable';
const channel = prerelease.split('.')[0];
if (channel === 'beta' || channel === 'nightly') return channel;
throw new Error(`Unsupported desktop release channel in ${version}.`);
}

export function desktopReleaseChannel(version) {
return versionChannel(version);
}

function normalizedCommitCount(value) {
const count = typeof value === 'number' ? value : Number(value);
if (!Number.isSafeInteger(count) || count < 1 || String(count) !== String(value)) {
throw new Error(`Invalid main commit count: ${String(value)}`);
}
return count;
}

export function desktopManifestName(channel, platform) {
const resolvedChannel = desktopChannel(channel);
if (platform !== 'mac' && platform !== 'win') throw new Error(`Unsupported desktop platform: ${platform}`);
const prefix = resolvedChannel === 'stable' ? 'latest' : resolvedChannel;
return platform === 'mac' ? `${prefix}-mac.yml` : `${prefix}.yml`;
}

export function nightlyDesktopVersion(packageVersion, commitCount) {
const parsed = parseDesktopVersion(packageVersion);
const patch = parsed.prerelease === undefined ? parsed.patch + 1 : parsed.patch;
if (!Number.isSafeInteger(patch)) throw new Error(`Cannot derive a Nightly version from ${packageVersion}.`);
const next = `${parsed.major}.${parsed.minor}.${patch}`;
return `${next}-nightly.${normalizedCommitCount(commitCount)}`;
}

function previewVersion(packageVersion, channel, commitCount) {
if (channel === 'nightly') return nightlyDesktopVersion(packageVersion, commitCount);
if (versionChannel(packageVersion) === channel) return packageVersion;
const parsed = parseDesktopVersion(packageVersion);
const patch = parsed.prerelease === undefined ? parsed.patch + 1 : parsed.patch;
if (!Number.isSafeInteger(patch)) throw new Error(`Cannot derive a ${channel} version from ${packageVersion}.`);
const next = `${parsed.major}.${parsed.minor}.${patch}`;
if (channel === 'stable') return next;
return `${next}-beta.${normalizedCommitCount(commitCount)}`;
}

export function resolveDesktopRelease(options) {
if (typeof options !== 'object' || options === null) throw new Error('Desktop release options are required.');
const eventName = options.eventName;
if (typeof eventName !== 'string' || !events.has(eventName)) {
throw new Error(`Unsupported desktop release event: ${String(eventName)}`);
}
const packageVersion = desktopVersion(options.packageVersion);
let channel;
let version;
let publish;

if (eventName === 'push') {
channel = versionChannel(packageVersion);
if (channel === 'nightly') {
throw new Error('Nightly desktop releases must come from the scheduled workflow.');
}
version = packageVersion;
publish = true;
const expectedTag = `desktop-v${version}`;
if (options.tagName !== expectedTag) {
throw new Error(`Tag ${String(options.tagName)} does not match apps/desktop/package.json (${version}).`);
}
} else if (eventName === 'workflow_call') {
if (options.publishNightly !== true) {
throw new Error('Reusable desktop releases require explicit Nightly publishing permission.');
}
channel = 'nightly';
version = nightlyDesktopVersion(packageVersion, options.commitCount);
publish = true;
} else {
channel = desktopChannel(options.requestedChannel);
version = previewVersion(packageVersion, channel, options.commitCount);
publish = false;
}

return {
channel,
feedChannel: channel === 'stable' ? 'latest' : channel,
macManifest: desktopManifestName(channel, 'mac'),
prerelease: channel !== 'stable',
publish,
releaseTag: `v${version}`,
version,
winManifest: desktopManifestName(channel, 'win'),
};
}

export function configureDesktopPackage(value, version, channel) {
const resolvedVersion = desktopVersion(version);
const resolvedChannel = desktopChannel(channel);
if (versionChannel(resolvedVersion) !== resolvedChannel) {
throw new Error(`Desktop version ${resolvedVersion} does not belong to the ${resolvedChannel} channel.`);
}
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new Error('Desktop package metadata must be an object.');
}
const build = value.build;
if (typeof build !== 'object' || build === null || Array.isArray(build)) {
throw new Error('Desktop package build metadata must be an object.');
}
const publish = build.publish;
if (!Array.isArray(publish) || publish.length === 0) {
throw new Error('Desktop package must define a publish provider.');
}
const provider = publish[0];
if (typeof provider !== 'object' || provider === null || Array.isArray(provider) || provider.provider !== 'github') {
throw new Error('Desktop release publishing must use the GitHub provider.');
}
return {
...value,
version: resolvedVersion,
build: {
...build,
publish: [
{
...provider,
channel: resolvedChannel === 'stable' ? 'latest' : resolvedChannel,
releaseType: resolvedChannel === 'stable' ? 'release' : 'prerelease',
},
...publish.slice(1),
],
},
};
}

function writeOutputs(result) {
const lines = [
`version=${result.version}`,
`tag=${result.releaseTag}`,
`channel=${result.channel}`,
`feed_channel=${result.feedChannel}`,
`mac_manifest=${result.macManifest}`,
`win_manifest=${result.winManifest}`,
`publish=${String(result.publish)}`,
`prerelease=${String(result.prerelease)}`,
];
const output = process.env.GITHUB_OUTPUT;
if (output === undefined || output === '') {
for (const line of lines) process.stdout.write(`${line}\n`);
} else {
appendFileSync(output, `${lines.join('\n')}\n`);
}
}

function main() {
const [command, ...args] = process.argv.slice(2);
if (command === 'resolve' && args.length === 6) {
const [eventName, packageVersion, requestedChannel, tagName, commitCount, publishNightlyValue] = args;
if (!['', 'false', 'true'].includes(publishNightlyValue)) {
throw new Error(`Invalid Nightly publishing permission: ${publishNightlyValue}`);
}
writeOutputs(resolveDesktopRelease({
eventName,
packageVersion,
publishNightly: publishNightlyValue === 'true',
requestedChannel: requestedChannel === '' ? undefined : requestedChannel,
tagName: tagName === '' ? undefined : tagName,
commitCount,
}));
return;
}
if (command === 'configure' && args.length === 3) {
const [path, version, channel] = args;
const packagePath = resolve(path);
const configured = configureDesktopPackage(JSON.parse(readFileSync(packagePath, 'utf8')), version, channel);
writeFileSync(packagePath, `${JSON.stringify(configured, null, 2)}\n`, 'utf8');
return;
}
throw new Error('Usage: desktop-release.mjs resolve <event> <package-version> <channel> <tag> <commit-count> <publish-nightly> | configure <package-json> <version> <channel>');
}

if (process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
try {
main();
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
}
}
19 changes: 13 additions & 6 deletions apps/desktop/scripts/verify-update-manifest.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@ import { getFileList, parseUpdateInfo } from 'electron-updater/out/providers/Pro

export interface VerifyUpdateManifestOptions {
readonly artifactsDir: string
readonly channel?: 'stable' | 'beta' | 'nightly'
readonly expectedVersion: string
readonly platform: 'mac' | 'win'
}
Expand All@@ -28,7 +29,8 @@ async function sha512(path: string): Promise<string> {

/** Validate version, file references, sizes, checksums, aliases, and release date. */
export async function verifyUpdateManifest(options: VerifyUpdateManifestOptions): Promise<void> {
const manifestName = options.platform === 'mac' ? 'latest-mac.yml' : 'latest.yml'
const prefix = options.channel === undefined || options.channel === 'stable' ? 'latest' : options.channel
const manifestName = options.platform === 'mac' ? `${prefix}-mac.yml` : `${prefix}.yml`
const manifestPath = join(options.artifactsDir, manifestName)
const raw = await readFile(manifestPath, 'utf8')
const info = parseUpdateInfo(raw, manifestName, pathToFileURL(manifestPath))
Expand DownExpand Up@@ -89,12 +91,17 @@ export async function verifyUpdateManifest(options: VerifyUpdateManifestOptions)
}

async function main(): Promise<void> {
const [platform, artifactsDir, expectedVersion] = process.argv.slice(2)
if ((platform !== 'mac' && platform !== 'win') || artifactsDir === undefined || expectedVersion === undefined) {
throw new Error('Usage: verify-update-manifest.ts <mac|win> <artifacts-directory> <version>')
const [platform, artifactsDir, expectedVersion, channel = 'stable'] = process.argv.slice(2)
if (
(platform !== 'mac' && platform !== 'win')
|| artifactsDir === undefined
|| expectedVersion === undefined
|| (channel !== 'stable' && channel !== 'beta' && channel !== 'nightly')
) {
throw new Error('Usage: verify-update-manifest.ts <mac|win> <artifacts-directory> <version> [stable|beta|nightly]')
}
await verifyUpdateManifest({ artifactsDir: resolve(artifactsDir), expectedVersion, platform })
console.log(`${platform} update manifest verified for ${expectedVersion}`)
await verifyUpdateManifest({ artifactsDir: resolve(artifactsDir), channel, expectedVersion, platform })
console.log(`${platform} ${channel} update manifest verified for ${expectedVersion}`)
}

const invokedPath = process.argv[1]
Expand Down
Loading
Loading