33 * without touching the running executable. The actual swap happens on the
44 * next startup (see `native-swap.ts`).
55 *
6- * The CDN serves the bare platform binary (e.g. `pythinker-code-win32-x64.exe`),
7- * whose sha256 comes from the per-release manifest over HTTPS — a staged
8- * binary is byte-exact what the release pipeline produced.
6+ * The GitHub release serves a per-platform zip archive holding the single
7+ * platform binary; the archive's sha256 comes from the per-release manifest
8+ * over HTTPS. The archive is verified before it is opened and the binary is
9+ * extracted next to it, so a staged binary is byte-exact what the release
10+ * pipeline produced.
911 */
1012
1113import { createHash } from 'node:crypto' ;
@@ -20,12 +22,12 @@ import { PYTHINKER_CODE_NATIVE_STAGED_STATE_FILE_NAME } from '#/constant/app';
2022import { getNativeStagedStateFile , getNativeStagingDir } from '#/utils/paths' ;
2123import { writeJsonFile } from '#/utils/persistence' ;
2224
23- import { UPDATE_DISABLED_MESSAGE } from './cdn' ;
2425import {
2526 fetchNativeReleaseManifest ,
2627 nativeBinaryUrl ,
2728 selectPlatformEntry ,
2829} from './native-manifest' ;
30+ import { extractZipEntry , readSingleZipEntry } from './zip-archive' ;
2931
3032const StagedNativeUpdateSchema = z
3133 . object ( {
@@ -186,15 +188,17 @@ export async function hashFileSha256(filePath: string): Promise<string | null> {
186188
187189/**
188190 * Whether a `.staging/` entry is an updater-owned artifact: a staged
189- * executable (`pythinker-<version>[.<pid>.<epoch-ms>.<n>][.exe]`) or a download
190- * intermediate (the same plus `.part`). Ownership derives from the
191+ * executable (`pythinker-<version>[.<pid>.<epoch-ms>.<n>][.exe]`), an
192+ * extraction intermediate (the same plus `.part`), or a download
193+ * intermediate (the same plus `.zip.part`). Ownership derives from the
191194 * semver/file-name contract (prerelease and build metadata included), so
192195 * foreign files in the directory are never matched.
193196 */
194197function isUpdaterOwnedStagingFile ( entry : string ) : boolean {
195198 if ( ! entry . startsWith ( 'pythinker-' ) ) return false ;
196199 let name = entry . slice ( 'pythinker-' . length ) ;
197200 if ( name . endsWith ( '.part' ) ) name = name . slice ( 0 , - '.part' . length ) ;
201+ if ( name . endsWith ( '.zip' ) ) name = name . slice ( 0 , - '.zip' . length ) ;
198202 if ( name . endsWith ( '.exe' ) ) name = name . slice ( 0 , - '.exe' . length ) ;
199203 // Published artifacts may carry a unique per-worker infix after the
200204 // version (.<pid>.<epoch-ms>.<n>, or the older .<pid>.<n>) — try with and
@@ -377,10 +381,6 @@ async function downloadAndHash(
377381export async function stageNativeUpdate (
378382 options : StageNativeUpdateOptions ,
379383) : Promise < StageNativeUpdateResult > {
380- if ( UPDATE_DISABLED_MESSAGE . length > 0 ) {
381- throw new Error ( UPDATE_DISABLED_MESSAGE ) ;
382- }
383-
384384 const platform = options . platform ?? process . platform ;
385385 const arch = options . arch ?? process . arch ;
386386 // Validate BEFORE anything derives a filesystem path from the version: the
@@ -444,31 +444,42 @@ export async function stageNativeUpdate(
444444 manual : options . manual === true ? true : undefined ,
445445 } ;
446446
447- // The .part intermediate is just the publish name plus the suffix — the
448- // name already carries this worker's unique infix, so concurrent workers
449- // never interleave writes into a shared path.
447+ // The intermediates are just the publish name plus a suffix — the name
448+ // already carries this worker's unique infix, so concurrent workers never
449+ // interleave writes into a shared path. The archive lands in `.zip.part`,
450+ // the extracted binary in `.part`.
451+ const archivePath = join ( stagingDir , `${ exeFileName } .zip.part` ) ;
450452 const partPath = join ( stagingDir , `${ exeFileName } .part` ) ;
451453 try {
452454 const manifest = await fetchNativeReleaseManifest ( options . version , fetchImpl ) ;
453455 const entry = selectPlatformEntry ( manifest , platform , arch ) ;
454- const size = await downloadAndHash (
456+ await downloadAndHash (
455457 nativeBinaryUrl ( options . version , entry . filename ) ,
456- partPath ,
458+ archivePath ,
457459 entry . checksum ,
458460 fetchImpl ,
459461 options . onProgress ,
460462 options . idleTimeoutMs ,
461463 ) ;
462- // sha256 matched the manifest. Make the private .part file executable
463- // BEFORE publishing it: a concurrent swap may move the staged exe into
464- // the install path the instant it appears at its published name, so a
465- // post-publish chmod could land on a path that is already gone — leaving
466- // a non-executable installation behind.
464+ // The archive's sha256 matched the manifest, so its single entry is the
465+ // binary the release pipeline packaged. The extracted bytes get their
466+ // own digest: that is what the startup swap re-verifies on disk.
467+ const extracted = await extractZipEntry (
468+ archivePath ,
469+ await readSingleZipEntry ( archivePath ) ,
470+ partPath ,
471+ ) ;
472+ await rm ( archivePath , { force : true } ) ;
473+ // Make the private .part file executable BEFORE publishing it: a
474+ // concurrent swap may move the staged exe into the install path the
475+ // instant it appears at its published name, so a post-publish chmod
476+ // could land on a path that is already gone — leaving a non-executable
477+ // installation behind.
467478 await chmod ( partPath , 0o755 ) ;
468479 await rename ( partPath , stagedExePath ( options . exePath , staged ) ) ;
469480
470- staged . sha256 = entry . checksum ;
471- staged . exeSize = size ;
481+ staged . sha256 = extracted . sha256 ;
482+ staged . exeSize = extracted . size ;
472483 // Atomic write: staged.json only ever appears complete and consistent.
473484 await writeJsonFile (
474485 getNativeStagedStateFile ( options . exePath ) ,
@@ -477,11 +488,12 @@ export async function stageNativeUpdate(
477488 ) ;
478489 return { status : 'staged' , staged } ;
479490 } catch ( error ) {
480- // Remove only what THIS attempt privately owns: its unique .part file.
481- // If the failure landed after the publishing rename, this attempt's exe
482- // is already at its unique name with no metadata pointing at it — left
483- // in place (a just-published exe may belong to a concurrent metadata
484- // write) and reaped by the age-gated orphan cleanup.
491+ // Remove only what THIS attempt privately owns: its unique intermediate
492+ // files. If the failure landed after the publishing rename, this
493+ // attempt's exe is already at its unique name with no metadata pointing
494+ // at it — left in place (a just-published exe may belong to a concurrent
495+ // metadata write) and reaped by the age-gated orphan cleanup.
496+ await rm ( archivePath , { force : true } ) . catch ( ( ) => { } ) ;
485497 await rm ( partPath , { force : true } ) . catch ( ( ) => { } ) ;
486498 // Best effort: drop the staging dir itself when empty (a concurrent
487499 // worker's files keep it around — rmdir only removes empty dirs).
0 commit comments