Skip to content

Commit 7403218

Browse files
committed
feat(desktop): publish beta and nightly update feeds
Complete BN3-BN5 from the desktop release plan. Use explicit update channels and reuse the signed draft-first workflow so Beta tags and scheduled Nightly builds produce verifiable update manifests.
1 parent 57bb4e4 commit 7403218

110 files changed

Lines changed: 855 additions & 198 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.agents/skills/release/SKILL.md

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

@@ -35,6 +38,7 @@ workspace, set its `private` and changesets policy explicitly and update `flake.
3538
|---|---|---|
3639
| `Release` | every main push after CI + Nix | Detect lane versions, build, run changesets |
3740
| `Cut desktop release tag` | desktop version changed | Required and idempotent; App token makes the tag trigger the desktop workflow |
41+
| `Desktop Nightly` | scheduled main build | Reusable workflow; signed assets and the explicit Nightly update feed |
3842
| `Publish VS Code extension` | extension version changed | Reusable workflow; six VSIX targets, both registries, provenance |
3943
| `Native release artifact` | CLI was published | Six signed/tested zips, checksums, provenance |
4044
| `Publish native release assets` | native builds passed | All-or-nothing immutable upload with `manifest.json` |
@@ -56,6 +60,8 @@ otherwise errors.
5660
genuinely half-published release — read the log; do not blind-rerun.
5761
- **Version PR looks wrong.** Never patch the `changeset-release/main` branch by hand. Fix or add
5862
changesets on `main`; the next workflow run regenerates the PR.
63+
- **Beta or Nightly checks Stable.** GitHub does not infer update channels. Confirm the release is a
64+
prerelease and contains `beta*.yml` or `nightly*.yml`; do not rename Stable manifests.
5965
- **Native builder fails after npm publish succeeded.** npm state is final. Re-run failed jobs from
6066
the same run before any assets upload. A complete asset set is an idempotent no-op. A partial set
6167
must not be filled from a rebuild; keep it or publish a new patch version.
@@ -83,6 +89,7 @@ otherwise errors.
8389

8490
```bash
8591
gh run list --workflow=release.yml --branch=main -L 3 # workflow health
92+
gh run list --workflow=nightly.yml --branch=main -L 3 # Nightly desktop health
8693
gh pr list --search 'ci: release packages in:title' --state open
8794
pnpm release:status # all live lanes
8895
npm view @pymodel/pythinker-code dist-tags --json
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pymodel/pythinker-desktop": minor
3+
---
4+
5+
Add signed Beta and Nightly desktop update feeds.

.github/workflows/desktop-release.yml

Lines changed: 119 additions & 57 deletions
Large diffs are not rendered by default.

.github/workflows/nightly.yml

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ name: Nightly
22

33
on:
44
schedule:
5-
- cron: '0 3 * * *'
5+
- cron: '17 3 * * *'
66
workflow_dispatch:
77

88
concurrency:
@@ -61,9 +61,18 @@ jobs:
6161
fi
6262
pnpm changeset publish --tag dev --no-git-tag
6363
64+
desktop-nightly:
65+
name: Publish desktop Nightly
66+
if: github.repository_owner == 'PyModel'
67+
uses: ./.github/workflows/desktop-release.yml
68+
with:
69+
publish_nightly: true
70+
secrets: inherit
71+
6472
release-reconciliation:
6573
name: Reconcile release lanes
66-
if: github.repository_owner == 'PyModel'
74+
needs: [publish, desktop-nightly]
75+
if: always() && github.repository_owner == 'PyModel'
6776
runs-on: ubuntu-latest
6877
permissions:
6978
contents: read
@@ -72,6 +81,7 @@ jobs:
7281
- name: Checkout
7382
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pinned from v6.0.2
7483
with:
84+
fetch-depth: 0
7585
persist-credentials: false
7686

7787
- name: Setup Node.js
@@ -83,11 +93,17 @@ jobs:
8393
id: release-status
8494
env:
8595
GITHUB_TOKEN: ${{ github.token }}
96+
UPSTREAM_OK: ${{ needs.publish.result == 'success' && needs.desktop-nightly.result == 'success' }}
8697
shell: bash
8798
run: |
8899
set +e
89100
node scripts/release/release-status.mjs > "$RUNNER_TEMP/release-status.md" 2>&1
90101
status=$?
102+
if [ "$UPSTREAM_OK" != 'true' ]; then
103+
printf '\nNightly publishers did not both succeed. CLI: %s. Desktop: %s.\n' \
104+
'${{ needs.publish.result }}' '${{ needs.desktop-nightly.result }}' >> "$RUNNER_TEMP/release-status.md"
105+
status=1
106+
fi
91107
set -e
92108
cat "$RUNNER_TEMP/release-status.md" >> "$GITHUB_STEP_SUMMARY"
93109
echo "exit_code=$status" >> "$GITHUB_OUTPUT"

apps/desktop/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ Run `pnpm run dist:win` on a native Windows x64 host; cross-building from macOS
7272

7373
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.
7474

75-
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.
75+
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.
7676

7777
## Known limitations
7878

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
#!/usr/bin/env node
2+
3+
import { appendFileSync, readFileSync, writeFileSync } from 'node:fs';
4+
import { resolve } from 'node:path';
5+
import { fileURLToPath } from 'node:url';
6+
7+
const channels = new Set(['stable', 'beta', 'nightly']);
8+
const events = new Set(['push', 'workflow_call', 'workflow_dispatch']);
9+
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;
10+
11+
function desktopChannel(value) {
12+
if (typeof value !== 'string' || !channels.has(value)) {
13+
throw new Error('Desktop release channel must be stable, beta, or nightly.');
14+
}
15+
return value;
16+
}
17+
18+
function parseDesktopVersion(value) {
19+
const match = typeof value === 'string' ? versionPattern.exec(value) : null;
20+
if (match === null) {
21+
throw new Error(`Invalid desktop release version: ${String(value)}`);
22+
}
23+
const major = Number(match[1]);
24+
const minor = Number(match[2]);
25+
const patch = Number(match[3]);
26+
if (![major, minor, patch].every(Number.isSafeInteger)) {
27+
throw new Error(`Desktop release version is outside the safe integer range: ${value}`);
28+
}
29+
return { major, minor, patch, prerelease: match[4] };
30+
}
31+
32+
function desktopVersion(value) {
33+
parseDesktopVersion(value);
34+
return value;
35+
}
36+
37+
function versionChannel(version) {
38+
const prerelease = parseDesktopVersion(version).prerelease;
39+
if (prerelease === undefined) return 'stable';
40+
const channel = prerelease.split('.')[0];
41+
if (channel === 'beta' || channel === 'nightly') return channel;
42+
throw new Error(`Unsupported desktop release channel in ${version}.`);
43+
}
44+
45+
export function desktopReleaseChannel(version) {
46+
return versionChannel(version);
47+
}
48+
49+
function normalizedCommitCount(value) {
50+
const count = typeof value === 'number' ? value : Number(value);
51+
if (!Number.isSafeInteger(count) || count < 1 || String(count) !== String(value)) {
52+
throw new Error(`Invalid main commit count: ${String(value)}`);
53+
}
54+
return count;
55+
}
56+
57+
export function desktopManifestName(channel, platform) {
58+
const resolvedChannel = desktopChannel(channel);
59+
if (platform !== 'mac' && platform !== 'win') throw new Error(`Unsupported desktop platform: ${platform}`);
60+
const prefix = resolvedChannel === 'stable' ? 'latest' : resolvedChannel;
61+
return platform === 'mac' ? `${prefix}-mac.yml` : `${prefix}.yml`;
62+
}
63+
64+
export function nightlyDesktopVersion(packageVersion, commitCount) {
65+
const parsed = parseDesktopVersion(packageVersion);
66+
const patch = parsed.prerelease === undefined ? parsed.patch + 1 : parsed.patch;
67+
if (!Number.isSafeInteger(patch)) throw new Error(`Cannot derive a Nightly version from ${packageVersion}.`);
68+
const next = `${parsed.major}.${parsed.minor}.${patch}`;
69+
return `${next}-nightly.${normalizedCommitCount(commitCount)}`;
70+
}
71+
72+
function previewVersion(packageVersion, channel, commitCount) {
73+
if (channel === 'nightly') return nightlyDesktopVersion(packageVersion, commitCount);
74+
if (versionChannel(packageVersion) === channel) return packageVersion;
75+
const parsed = parseDesktopVersion(packageVersion);
76+
const patch = parsed.prerelease === undefined ? parsed.patch + 1 : parsed.patch;
77+
if (!Number.isSafeInteger(patch)) throw new Error(`Cannot derive a ${channel} version from ${packageVersion}.`);
78+
const next = `${parsed.major}.${parsed.minor}.${patch}`;
79+
if (channel === 'stable') return next;
80+
return `${next}-beta.${normalizedCommitCount(commitCount)}`;
81+
}
82+
83+
export function resolveDesktopRelease(options) {
84+
if (typeof options !== 'object' || options === null) throw new Error('Desktop release options are required.');
85+
const eventName = options.eventName;
86+
if (typeof eventName !== 'string' || !events.has(eventName)) {
87+
throw new Error(`Unsupported desktop release event: ${String(eventName)}`);
88+
}
89+
const packageVersion = desktopVersion(options.packageVersion);
90+
let channel;
91+
let version;
92+
let publish;
93+
94+
if (eventName === 'push') {
95+
channel = versionChannel(packageVersion);
96+
if (channel === 'nightly') {
97+
throw new Error('Nightly desktop releases must come from the scheduled workflow.');
98+
}
99+
version = packageVersion;
100+
publish = true;
101+
const expectedTag = `desktop-v${version}`;
102+
if (options.tagName !== expectedTag) {
103+
throw new Error(`Tag ${String(options.tagName)} does not match apps/desktop/package.json (${version}).`);
104+
}
105+
} else if (eventName === 'workflow_call') {
106+
if (options.publishNightly !== true) {
107+
throw new Error('Reusable desktop releases require explicit Nightly publishing permission.');
108+
}
109+
channel = 'nightly';
110+
version = nightlyDesktopVersion(packageVersion, options.commitCount);
111+
publish = true;
112+
} else {
113+
channel = desktopChannel(options.requestedChannel);
114+
version = previewVersion(packageVersion, channel, options.commitCount);
115+
publish = false;
116+
}
117+
118+
return {
119+
channel,
120+
feedChannel: channel === 'stable' ? 'latest' : channel,
121+
macManifest: desktopManifestName(channel, 'mac'),
122+
prerelease: channel !== 'stable',
123+
publish,
124+
releaseTag: `v${version}`,
125+
version,
126+
winManifest: desktopManifestName(channel, 'win'),
127+
};
128+
}
129+
130+
export function configureDesktopPackage(value, version, channel) {
131+
const resolvedVersion = desktopVersion(version);
132+
const resolvedChannel = desktopChannel(channel);
133+
if (versionChannel(resolvedVersion) !== resolvedChannel) {
134+
throw new Error(`Desktop version ${resolvedVersion} does not belong to the ${resolvedChannel} channel.`);
135+
}
136+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
137+
throw new Error('Desktop package metadata must be an object.');
138+
}
139+
const build = value.build;
140+
if (typeof build !== 'object' || build === null || Array.isArray(build)) {
141+
throw new Error('Desktop package build metadata must be an object.');
142+
}
143+
const publish = build.publish;
144+
if (!Array.isArray(publish) || publish.length === 0) {
145+
throw new Error('Desktop package must define a publish provider.');
146+
}
147+
const provider = publish[0];
148+
if (typeof provider !== 'object' || provider === null || Array.isArray(provider) || provider.provider !== 'github') {
149+
throw new Error('Desktop release publishing must use the GitHub provider.');
150+
}
151+
return {
152+
...value,
153+
version: resolvedVersion,
154+
build: {
155+
...build,
156+
publish: [
157+
{
158+
...provider,
159+
channel: resolvedChannel === 'stable' ? 'latest' : resolvedChannel,
160+
releaseType: resolvedChannel === 'stable' ? 'release' : 'prerelease',
161+
},
162+
...publish.slice(1),
163+
],
164+
},
165+
};
166+
}
167+
168+
function writeOutputs(result) {
169+
const lines = [
170+
`version=${result.version}`,
171+
`tag=${result.releaseTag}`,
172+
`channel=${result.channel}`,
173+
`feed_channel=${result.feedChannel}`,
174+
`mac_manifest=${result.macManifest}`,
175+
`win_manifest=${result.winManifest}`,
176+
`publish=${String(result.publish)}`,
177+
`prerelease=${String(result.prerelease)}`,
178+
];
179+
const output = process.env.GITHUB_OUTPUT;
180+
if (output === undefined || output === '') {
181+
for (const line of lines) process.stdout.write(`${line}\n`);
182+
} else {
183+
appendFileSync(output, `${lines.join('\n')}\n`);
184+
}
185+
}
186+
187+
function main() {
188+
const [command, ...args] = process.argv.slice(2);
189+
if (command === 'resolve' && args.length === 6) {
190+
const [eventName, packageVersion, requestedChannel, tagName, commitCount, publishNightlyValue] = args;
191+
if (!['', 'false', 'true'].includes(publishNightlyValue)) {
192+
throw new Error(`Invalid Nightly publishing permission: ${publishNightlyValue}`);
193+
}
194+
writeOutputs(resolveDesktopRelease({
195+
eventName,
196+
packageVersion,
197+
publishNightly: publishNightlyValue === 'true',
198+
requestedChannel: requestedChannel === '' ? undefined : requestedChannel,
199+
tagName: tagName === '' ? undefined : tagName,
200+
commitCount,
201+
}));
202+
return;
203+
}
204+
if (command === 'configure' && args.length === 3) {
205+
const [path, version, channel] = args;
206+
const packagePath = resolve(path);
207+
const configured = configureDesktopPackage(JSON.parse(readFileSync(packagePath, 'utf8')), version, channel);
208+
writeFileSync(packagePath, `${JSON.stringify(configured, null, 2)}\n`, 'utf8');
209+
return;
210+
}
211+
throw new Error('Usage: desktop-release.mjs resolve <event> <package-version> <channel> <tag> <commit-count> <publish-nightly> | configure <package-json> <version> <channel>');
212+
}
213+
214+
if (process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
215+
try {
216+
main();
217+
} catch (error) {
218+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
219+
process.exitCode = 1;
220+
}
221+
}

apps/desktop/scripts/verify-update-manifest.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { getFileList, parseUpdateInfo } from 'electron-updater/out/providers/Pro
99

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

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

9193
async function main(): Promise<void> {
92-
const [platform, artifactsDir, expectedVersion] = process.argv.slice(2)
93-
if ((platform !== 'mac' && platform !== 'win') || artifactsDir === undefined || expectedVersion === undefined) {
94-
throw new Error('Usage: verify-update-manifest.ts <mac|win> <artifacts-directory> <version>')
94+
const [platform, artifactsDir, expectedVersion, channel = 'stable'] = process.argv.slice(2)
95+
if (
96+
(platform !== 'mac' && platform !== 'win')
97+
|| artifactsDir === undefined
98+
|| expectedVersion === undefined
99+
|| (channel !== 'stable' && channel !== 'beta' && channel !== 'nightly')
100+
) {
101+
throw new Error('Usage: verify-update-manifest.ts <mac|win> <artifacts-directory> <version> [stable|beta|nightly]')
95102
}
96-
await verifyUpdateManifest({ artifactsDir: resolve(artifactsDir), expectedVersion, platform })
97-
console.log(`${platform} update manifest verified for ${expectedVersion}`)
103+
await verifyUpdateManifest({ artifactsDir: resolve(artifactsDir), channel, expectedVersion, platform })
104+
console.log(`${platform} ${channel} update manifest verified for ${expectedVersion}`)
98105
}
99106

100107
const invokedPath = process.argv[1]

0 commit comments

Comments
 (0)