55 existsSync ,
66 readFileSync ,
77 readdirSync ,
8+ statSync ,
89 writeFileSync ,
910} from "node:fs" ;
1011import path from "node:path" ;
@@ -355,6 +356,20 @@ export const releaseCommand = new Command("release")
355356 process . exit ( 1 ) ;
356357 }
357358
359+ // ------------------------------------------------------------------
360+ // Snapshot pre-existing .delta files in dist/ BEFORE generate_appcast
361+ // runs, so we can distinguish "delta this run produced (or refreshed)"
362+ // from "delta that's been sitting in dist/ since a previous release".
363+ // Only the former needs to ship to R2 — the latter is already up there.
364+ // Captures filename → mtime; we compare both presence and mtime after
365+ // regen, because generate_appcast may overwrite an existing .delta if
366+ // the source dmg changed.
367+ // ------------------------------------------------------------------
368+ const distDir = path . join ( ROOT_DIR , "dist" ) ;
369+ const preExistingDeltas = ! opts . dryRun
370+ ? snapshotDeltaMtimes ( distDir )
371+ : new Map < string , number > ( ) ;
372+
358373 // ------------------------------------------------------------------
359374 // Step 5 — generate_appcast dist/ (Sparkle EdDSA-signs each enclosure)
360375 // ------------------------------------------------------------------
@@ -449,18 +464,28 @@ export const releaseCommand = new Command("release")
449464 }
450465
451466 // ------------------------------------------------------------------
452- // Step 6 — upload .dmg + every .delta in dist/ to R2
467+ // Step 6 — upload .dmg + new/changed .delta files in dist/ to R2
453468 //
454469 // generate_appcast writes signed <enclosure> entries for delta
455470 // patches alongside the full dmg; the appcast advertises both. If
456471 // we don't upload the .delta files, clients fetch the appcast,
457472 // pick a delta matching their current version, and hit a 404 — at
458473 // which point Sparkle reports "Update Error" instead of silently
459474 // falling back to the full dmg. So delta upload is not optional.
475+ //
476+ // Incremental upload: a .delta file ships only if it's both
477+ // (a) referenced by the freshly regenerated appcast, AND
478+ // (b) newly created by this run, OR its mtime changed (meaning
479+ // generate_appcast re-synthesized it because its source dmg
480+ // was rebuilt).
481+ // Deltas that already existed before this run with unchanged mtime
482+ // are assumed to be already on R2 from a previous release — they're
483+ // still listed in the appcast (so the feed stays valid for older
484+ // clients), but we don't re-PUT them. This makes a typical release
485+ // upload one .dmg + one .delta (vs current N-version, the most
486+ // common case for active users) instead of all historical deltas.
460487 // ------------------------------------------------------------------
461488 if ( willUpload ) {
462- const distDir = path . join ( ROOT_DIR , "dist" ) ;
463-
464489 // Whitelist delta uploads against the freshly-regenerated appcast.
465490 // generate_appcast does prune unreferenced delta files into
466491 // dist/old_updates/, but a resumed/partial release can leave stray
@@ -471,22 +496,42 @@ export const releaseCommand = new Command("release")
471496 const referencedDeltas = ! opts . dryRun
472497 ? collectReferencedDeltaBasenames ( appcastDest )
473498 : new Set < string > ( ) ;
474- const allDeltas = existsSync ( distDir )
475- ? readdirSync ( distDir ) . filter ( ( f ) => f . endsWith ( ".delta" ) )
476- : [ ] ;
499+ const postRunDeltas = existsSync ( distDir )
500+ ? snapshotDeltaMtimes ( distDir )
501+ : new Map < string , number > ( ) ;
477502 const deltaPaths : string [ ] = [ ] ;
478- const skippedDeltas : string [ ] = [ ] ;
479- for ( const name of allDeltas ) {
480- if ( opts . dryRun || referencedDeltas . has ( name ) ) {
503+ const skippedUnreferenced : string [ ] = [ ] ;
504+ const skippedUnchanged : string [ ] = [ ] ;
505+ for ( const [ name , mtime ] of postRunDeltas ) {
506+ if ( opts . dryRun ) {
507+ deltaPaths . push ( path . join ( distDir , name ) ) ;
508+ continue ;
509+ }
510+ if ( ! referencedDeltas . has ( name ) ) {
511+ skippedUnreferenced . push ( name ) ;
512+ continue ;
513+ }
514+ const previous = preExistingDeltas . get ( name ) ;
515+ const isNewOrChanged = previous === undefined || previous !== mtime ;
516+ if ( isNewOrChanged ) {
481517 deltaPaths . push ( path . join ( distDir , name ) ) ;
482518 } else {
483- skippedDeltas . push ( name ) ;
519+ skippedUnchanged . push ( name ) ;
484520 }
485521 }
486- if ( skippedDeltas . length > 0 ) {
522+ if ( skippedUnreferenced . length > 0 ) {
487523 warn (
488- `Skipping ${ skippedDeltas . length } unreferenced delta file(s) in ` +
489- `dist/ (not present in appcast): ${ skippedDeltas . join ( ", " ) } `
524+ `Skipping ${ skippedUnreferenced . length } unreferenced delta file(s) ` +
525+ `in dist/ (not present in appcast): ${ skippedUnreferenced . join (
526+ ", "
527+ ) } `
528+ ) ;
529+ }
530+ if ( skippedUnchanged . length > 0 ) {
531+ log (
532+ `Skipping ${ skippedUnchanged . length } unchanged delta file(s) ` +
533+ `(already on R2 from a previous release): ` +
534+ skippedUnchanged . join ( ", " )
490535 ) ;
491536 }
492537
@@ -502,8 +547,8 @@ export const releaseCommand = new Command("release")
502547
503548 const deltaSummary =
504549 deltaPaths . length > 0
505- ? ` + ${ deltaPaths . length } delta(s)`
506- : " (no referenced delta files in dist/ )" ;
550+ ? ` + ${ deltaPaths . length } new/changed delta(s)`
551+ : " (no new delta files to upload )" ;
507552 await runStep (
508553 opts . dryRun ,
509554 `Uploading ${ dmgName } ${ deltaSummary } to R2` ,
@@ -837,6 +882,27 @@ function collectReferencedDeltaBasenames(appcastPath: string): Set<string> {
837882 return referenced ;
838883}
839884
885+ // Snapshot the .delta files currently in dist/ along with their mtime in
886+ // milliseconds. Used by the release flow to diff before/after
887+ // generate_appcast so we only re-upload deltas that this run created or
888+ // refreshed — preexisting deltas with unchanged mtime are assumed to be
889+ // already in R2 from a previous release.
890+ function snapshotDeltaMtimes ( distDir : string ) : Map < string , number > {
891+ const snapshot = new Map < string , number > ( ) ;
892+ if ( ! existsSync ( distDir ) ) return snapshot ;
893+ for ( const name of readdirSync ( distDir ) ) {
894+ if ( ! name . endsWith ( ".delta" ) ) continue ;
895+ const full = path . join ( distDir , name ) ;
896+ try {
897+ const st = statSync ( full ) ;
898+ snapshot . set ( name , st . mtimeMs ) ;
899+ } catch {
900+ // File vanished between readdir and stat — ignore.
901+ }
902+ }
903+ return snapshot ;
904+ }
905+
840906// ---------------------------------------------------------------------------
841907// Notarize + staple, with a useful recovery path on keychain failures
842908// ---------------------------------------------------------------------------
0 commit comments