diff --git a/.github/workflows/review-desktop-preview.yml b/.github/workflows/review-desktop-preview.yml index 748ac5a855..9fcd7e680a 100644 --- a/.github/workflows/review-desktop-preview.yml +++ b/.github/workflows/review-desktop-preview.yml @@ -407,9 +407,10 @@ jobs: RELEASE_PREFIX="s3://${R2_BUCKET}/releases/${RELEASE_VERSION}/darwin-arm64" DMG_DISPOSITION="attachment; filename=\"df-whiteboard-preview-${RELEASE_VERSION}.dmg\"" - aws s3 cp "$DIST/Whiteboard-darwin-arm64-${RELEASE_VERSION}.zip" \ - "${RELEASE_PREFIX}/Whiteboard-darwin-arm64-${RELEASE_VERSION}.zip" \ - --content-type application/zip + for zip in "$DIST"/*-darwin-arm64-"${RELEASE_VERSION}".zip; do + aws s3 cp "$zip" "${RELEASE_PREFIX}/$(basename "$zip")" \ + --content-type application/zip + done aws s3 cp "$DIST/Whiteboard-darwin-arm64-${RELEASE_VERSION}.dmg" \ "${RELEASE_PREFIX}/Whiteboard-darwin-arm64-${RELEASE_VERSION}.dmg" \ --content-type application/x-apple-diskimage \ @@ -431,6 +432,11 @@ jobs: echo "$RESPONSE" echo "$RESPONSE" | grep -F "\"productVersion\":\"${RELEASE_VERSION}\"" echo "$RESPONSE" | grep -F "\"version\":\"${RELEASE_COMMIT}\"" + echo "$RESPONSE" | grep -F "/Whiteboard-darwin-arm64-${RELEASE_VERSION}.zip" + + echo "Feed for a Review Preview.app install (expect the Review zip):" + curl -sf "https://update.dev.fast/api/update/darwin-arm64/preview/0000000000000000000000000000000000000000?bundle=Review%20Preview" \ + | grep -F "/Review-darwin-arm64-${RELEASE_VERSION}.zip" echo "Feed for the just-released commit (expect 204):" STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://update.dev.fast/api/update/darwin-arm64/preview/${RELEASE_COMMIT}") diff --git a/.github/workflows/review-desktop-release.yml b/.github/workflows/review-desktop-release.yml index 03458d2a88..81757f2c03 100644 --- a/.github/workflows/review-desktop-release.yml +++ b/.github/workflows/review-desktop-release.yml @@ -582,9 +582,10 @@ jobs: # rides along in the filename a client saves the download as. DMG_DISPOSITION="attachment; filename=\"df-whiteboard-${RELEASE_VERSION}.dmg\"" - aws s3 cp "$DIST/Whiteboard-darwin-arm64-${RELEASE_VERSION}.zip" \ - "${RELEASE_PREFIX}/Whiteboard-darwin-arm64-${RELEASE_VERSION}.zip" \ - --content-type application/zip + for zip in "$DIST"/*-darwin-arm64-"${RELEASE_VERSION}".zip; do + aws s3 cp "$zip" "${RELEASE_PREFIX}/$(basename "$zip")" \ + --content-type application/zip + done aws s3 cp "$DIST/Whiteboard-darwin-arm64-${RELEASE_VERSION}.dmg" \ "${RELEASE_PREFIX}/Whiteboard-darwin-arm64-${RELEASE_VERSION}.dmg" \ --content-type application/x-apple-diskimage \ @@ -606,6 +607,11 @@ jobs: echo "$RESPONSE" echo "$RESPONSE" | grep -F "\"productVersion\":\"${RELEASE_VERSION}\"" echo "$RESPONSE" | grep -F "\"version\":\"${RELEASE_COMMIT}\"" + echo "$RESPONSE" | grep -F "/Review-darwin-arm64-${RELEASE_VERSION}.zip" + + echo "Feed for a Whiteboard.app install (expect the Whiteboard zip):" + curl -sf "https://update.dev.fast/api/update/darwin-arm64/stable/0000000000000000000000000000000000000000?bundle=Whiteboard" \ + | grep -F "/Whiteboard-darwin-arm64-${RELEASE_VERSION}.zip" echo "Feed for the just-released commit (expect 204):" STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://update.dev.fast/api/update/darwin-arm64/stable/${RELEASE_COMMIT}") diff --git a/apps/review-desktop/README.md b/apps/review-desktop/README.md index 6cb369d353..058231458e 100644 --- a/apps/review-desktop/README.md +++ b/apps/review-desktop/README.md @@ -115,8 +115,9 @@ SKIP_NOTARIZE=1 pnpm --filter @dev.fast/review-desktop app:package:macos builds an unsigned `VSCode-darwin-arm64/Whiteboard.app` and skips signing, notarization, and artifact creation. A full run needs the signing -environment and produces `dist/Whiteboard-darwin-arm64-.zip` (the -Squirrel update payload) and the matching `.dmg`, both notarized and stapled: +environment and produces the `.dmg` plus one Squirrel update zip per installed +bundle folder name (`Whiteboard-…zip`, `Review-…zip`; see `release-channel.mjs`), +all notarized and stapled: - `CODESIGN_IDENTITY` — the Developer ID Application identity string. - Keychain: `CODESIGN_KEYCHAIN` (explicit path), or `AGENT_TEMPDIRECTORY` @@ -219,8 +220,9 @@ Preview uses its own bundle identifier, URL scheme, CLI name, and data folders, so it can run beside stable without replacing the stable app or sharing its settings. Preview updates continue to use the preview feed. To return to stable, open the existing `Whiteboard.app` or install it from . -Auto-updated Review installs keep the `Review.app` file name; reinstall from -the disk image to get `Whiteboard.app`. +Squirrel renames an install to the update zip's folder name, so the client +sends its own folder name (`?bundle=`) and the feed answers with the matching +zip: `Review.app` installs stay `Review.app`, fresh installs are `Whiteboard.app`. Builds from before the preview identity split installed as `Review.app`. Reinstall once from after the split so the @@ -285,6 +287,7 @@ browser download always does. update/stable/darwin-arm64/latest.json current-release manifest update/preview/darwin-arm64/latest.json current-preview manifest releases//darwin-arm64/ Whiteboard-darwin-arm64-.zip + .dmg + Review-darwin-arm64-.zip (Review.app-named copy) releases/latest/darwin-arm64/Whiteboard.dmg direct-download alias, saved as df-whiteboard-.dmg diff --git a/apps/review-desktop/code-oss/src/vs/platform/update/electron-main/abstractUpdateService.ts b/apps/review-desktop/code-oss/src/vs/platform/update/electron-main/abstractUpdateService.ts index 09971caf6b..abc687357d 100644 --- a/apps/review-desktop/code-oss/src/vs/platform/update/electron-main/abstractUpdateService.ts +++ b/apps/review-desktop/code-oss/src/vs/platform/update/electron-main/abstractUpdateService.ts @@ -28,6 +28,8 @@ const LAST_KNOWN_VERSION_STORAGE_KEY = 'abstractUpdateService/lastKnownVersion'; export interface IUpdateURLOptions { readonly background?: boolean; readonly internalOrg?: string; + /** The .app folder name this install runs from (without .app). The feed serves the zip whose folder matches, so Squirrel never renames the install. */ + readonly bundle?: string; } export function createUpdateURL(baseUpdateUrl: string, platform: string, quality: string, commit: string, options?: IUpdateURLOptions): string { @@ -39,6 +41,10 @@ export function createUpdateURL(baseUpdateUrl: string, platform: string, quality url.searchParams.set('u', options?.internalOrg ?? 'none'); + if (options?.bundle) { + url.searchParams.set('bundle', options.bundle); + } + return url.toString(); } diff --git a/apps/review-desktop/code-oss/src/vs/platform/update/electron-main/updateService.darwin.ts b/apps/review-desktop/code-oss/src/vs/platform/update/electron-main/updateService.darwin.ts index 193f937fea..ae25548d9f 100644 --- a/apps/review-desktop/code-oss/src/vs/platform/update/electron-main/updateService.darwin.ts +++ b/apps/review-desktop/code-oss/src/vs/platform/update/electron-main/updateService.darwin.ts @@ -35,6 +35,11 @@ import { AvailableForDownload, IUpdate, State, StateType, UpdateType } from '../ import { IMeteredConnectionService } from '../../meteredConnection/common/meteredConnection.js'; import { AbstractUpdateService, createUpdateURL, getUpdateRequestHeaders, IUpdateURLOptions, UpdateErrorClassification } from './abstractUpdateService.js'; +/** The .app folder name this process runs from, e.g. "Review" for /Applications/Review.app; undefined outside a bundle. */ +function darwinBundleName(): string | undefined { + return /\/([^/]+)\.app\/Contents\/MacOS\//.exec(process.execPath)?.[1]; +} + export class DarwinUpdateService extends AbstractUpdateService implements IRelaunchHandler { private feedUrlError: string | undefined; @@ -114,7 +119,7 @@ export class DarwinUpdateService extends AbstractUpdateService implements IRelau protected buildUpdateFeedUrl(quality: string, commit: string, options?: IUpdateURLOptions): string | undefined { this.feedUrlError = undefined; const assetID = this.productService.darwinUniversalAssetId ?? (process.arch === 'x64' ? 'darwin' : 'darwin-arm64'); - const url = createUpdateURL(this.productService.updateUrl!, assetID, quality, commit, options); + const url = createUpdateURL(this.productService.updateUrl!, assetID, quality, commit, { ...options, bundle: darwinBundleName() }); const headers = getUpdateRequestHeaders(this.productService.version); try { this.logService.trace('update#buildUpdateFeedUrl - setting feed URL for Electron autoUpdater', { url, assetID, quality, commit, headers }); diff --git a/apps/review-desktop/scripts/notarize-macos.sh b/apps/review-desktop/scripts/notarize-macos.sh index 059297ce52..433af80cf1 100755 --- a/apps/review-desktop/scripts/notarize-macos.sh +++ b/apps/review-desktop/scripts/notarize-macos.sh @@ -8,7 +8,7 @@ PRODUCT_NAME="$(node -p "require('$CHECKOUT/product.json').nameShort")" PACKAGED_APP="$APP_DIR/VSCode-darwin-arm64/$PRODUCT_NAME.app" VERSION="$(node -p "require('$APP_DIR/package.json').version")" ARTIFACT_DIR="${DEV_FAST_REVIEW_ARTIFACT_DIR:-$APP_DIR/dist}" -UPDATE_ZIP="$ARTIFACT_DIR/Whiteboard-darwin-arm64-$VERSION.zip" +QUALITY="$(node -p "require('$CHECKOUT/product.json').quality")" DMG="$ARTIFACT_DIR/Whiteboard-darwin-arm64-$VERSION.dmg" if (( $# > 0 )); then @@ -137,7 +137,7 @@ codesign --verify --deep --strict --verbose=2 "$PACKAGED_APP" codesign -dv --verbose=2 "$PACKAGED_APP" mkdir -p "$ARTIFACT_DIR" -rm -f -- "$UPDATE_ZIP" "$DMG" +rm -f -- "$ARTIFACT_DIR"/*-darwin-arm64-"$VERSION".zip "$DMG" mkdir -p "$DMG_STAGE" ditto "$PACKAGED_APP" "$DMG_STAGE/$PRODUCT_NAME.app" @@ -167,9 +167,18 @@ xcrun stapler validate "$PACKAGED_APP" spctl -a -vv --type exec "$PACKAGED_APP" spctl -a -vv --type open --context context:primary-signature "$DMG" -# The update zip ships the stapled app. -ditto -c -k --keepParent "$PACKAGED_APP" "$UPDATE_ZIP" - -echo "Created notarized Review Desktop artifacts:" -echo " $UPDATE_ZIP" -echo " $DMG" +# One update zip per bundle folder name still installed (release-channel.mjs +# lists them): each ships the same stapled app under that folder name, so +# Squirrel's rename-to-the-update's-name is a no-op for every install. +UPDATE_ZIPS=() +while IFS=$'\t' read -r bundle artifact; do + staged="$TEMP_ROOT/zips/$artifact/$bundle.app" + mkdir -p "$(dirname "$staged")" + ditto "$PACKAGED_APP" "$staged" + zip="$ARTIFACT_DIR/$artifact-darwin-arm64-$VERSION.zip" + ditto -c -k --keepParent "$staged" "$zip" + UPDATE_ZIPS+=("$zip") +done < <(node "$APP_DIR/scripts/release-channel.mjs" "$QUALITY") + +echo "Created notarized Whiteboard Desktop artifacts:" +printf ' %s\n' "${UPDATE_ZIPS[@]}" "$DMG" diff --git a/apps/review-desktop/scripts/release-channel.mjs b/apps/review-desktop/scripts/release-channel.mjs index 10512d2d86..4e1bcc4b71 100644 --- a/apps/review-desktop/scripts/release-channel.mjs +++ b/apps/review-desktop/scripts/release-channel.mjs @@ -19,6 +19,22 @@ const RELEASE_IDENTITIES = Object.freeze({ }), }); +// Squirrel renames an install to the folder name inside the update zip, so a +// release ships one zip per folder name still installed: `bundle` is that +// folder name (minus .app), `artifact` prefixes the zip file. The first entry +// is what a client that does not name its folder receives; the DMG always +// carries the channel's nameShort. +const UPDATE_BUNDLES = Object.freeze({ + stable: Object.freeze([ + Object.freeze({ bundle: "Review", artifact: "Review" }), + Object.freeze({ bundle: "Whiteboard", artifact: "Whiteboard" }), + ]), + preview: Object.freeze([ + Object.freeze({ bundle: "Whiteboard Preview", artifact: "Whiteboard" }), + Object.freeze({ bundle: "Review Preview", artifact: "Review" }), + ]), +}); + export function assertReleaseChannel(channel) { if (!Object.hasOwn(RELEASE_IDENTITIES, channel)) { throw new Error( @@ -32,3 +48,21 @@ export function releaseIdentityFor(channel) { return RELEASE_IDENTITIES[channel]; } + +export function updateBundlesFor(channel) { + assertReleaseChannel(channel); + + return UPDATE_BUNDLES[channel]; +} + +export function updateZipName(artifact, version) { + return `${artifact}-darwin-arm64-${version}.zip`; +} + +if (process.argv[1] === new URL(import.meta.url).pathname) { + // `node release-channel.mjs ` prints one "bundleartifact" + // line per update zip, for the packaging shell scripts. + for (const { bundle, artifact } of updateBundlesFor(process.argv[2])) { + console.log(`${bundle}\t${artifact}`); + } +} diff --git a/apps/review-desktop/scripts/validate-release-artifacts.mjs b/apps/review-desktop/scripts/validate-release-artifacts.mjs index 52b7e05521..c8bd8c82ac 100644 --- a/apps/review-desktop/scripts/validate-release-artifacts.mjs +++ b/apps/review-desktop/scripts/validate-release-artifacts.mjs @@ -17,6 +17,8 @@ import { verifyCuratedExtensions } from "./curated-extensions.mjs"; import { assertReleaseChannel, releaseIdentityFor, + updateBundlesFor, + updateZipName, } from "./release-channel.mjs"; import { assertPackagedArtifacts } from "./stage-review-runtime.mjs"; @@ -26,23 +28,48 @@ const UPDATE_URL = "https://update.dev.fast"; export { assertReleaseChannel }; -export function buildManifest({ - version, - commit, - zipSha256, - now = new Date(), -}) { +// `payloads` is one entry per update zip, in updateBundlesFor() order: the +// first is the default for clients that do not name their bundle folder. +export function buildManifest({ version, commit, payloads, now = new Date() }) { + const bundles = Object.fromEntries( + payloads.map(({ bundle, artifact, sha256 }) => [ + bundle, + { + url: `${UPDATE_URL}/releases/${version}/darwin-arm64/${updateZipName(artifact, version)}`, + sha256hash: sha256, + }, + ]), + ); + + const [fallback] = Object.values(bundles); + return { version, commit, - url: `${UPDATE_URL}/releases/${version}/darwin-arm64/Whiteboard-darwin-arm64-${version}.zip`, + ...fallback, name: version, pub_date: now.toISOString(), timestamp: now.getTime(), - sha256hash: zipSha256, + bundles, }; } +// Squirrel installs the zip's top-level folder under that name, so a zip whose +// folder does not match the bundle it is served to would rename the install. +export function assertZipFolder(zip, folder) { + const entries = execFileSync("unzip", ["-Z1", zip], { encoding: "utf8" }) + .split("\n") + .filter(Boolean); + + const roots = new Set(entries.map((entry) => entry.split("/")[0])); + + if (roots.size !== 1 || !roots.has(folder)) { + throw new Error( + `${zip} must contain only ${folder}/, found ${[...roots].join(", ") || "nothing"}`, + ); + } +} + export function assertPackagedProduct(product, { commit, channel = "stable" }) { assertReleaseChannel(channel); @@ -136,7 +163,12 @@ async function main() { `${sourceProduct.nameShort}.app`, ); - const zip = path.join(artifactDir, `Whiteboard-darwin-arm64-${version}.zip`); + const zips = updateBundlesFor(channel).map(({ bundle, artifact }) => ({ + bundle, + artifact, + file: path.join(artifactDir, updateZipName(artifact, version)), + })); + const dmg = path.join(artifactDir, `Whiteboard-darwin-arm64-${version}.dmg`); await assertPackagedArtifacts(app); @@ -159,12 +191,21 @@ async function main() { run("spctl", ["-a", "-vv", "--type", "exec", app]); run("xcrun", ["stapler", "validate", dmg]); - const manifest = buildManifest({ version, commit, zipSha256: sha256(zip) }); + for (const { bundle, file } of zips) { + assertZipFolder(file, `${bundle}.app`); + } + + const payloads = zips.map((zip) => ({ ...zip, sha256: sha256(zip.file) })); + const manifest = buildManifest({ version, commit, payloads }); const manifestPath = path.join(artifactDir, "latest.json"); writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); console.log(`Validated release artifacts for ${version} (${commit}):`); - console.log(` ${zip} sha256=${manifest.sha256hash}`); + + for (const { bundle, file, sha256 } of payloads) { + console.log(` ${file} (${bundle}.app) sha256=${sha256}`); + } + console.log(` ${dmg} sha256=${sha256(dmg)}`); console.log(` ${manifestPath}`); } diff --git a/apps/review-desktop/scripts/validate-release-artifacts.test.mjs b/apps/review-desktop/scripts/validate-release-artifacts.test.mjs index 670bffc53a..78e7acc289 100644 --- a/apps/review-desktop/scripts/validate-release-artifacts.test.mjs +++ b/apps/review-desktop/scripts/validate-release-artifacts.test.mjs @@ -33,18 +33,31 @@ test("buildManifest emits the schema the update Worker serves", () => { const manifest = buildManifest({ version: "1.2.3", commit: "abc123", - zipSha256: "cafe", + payloads: [ + { bundle: "Review", artifact: "Review", sha256: "cafe" }, + { bundle: "Whiteboard", artifact: "Whiteboard", sha256: "f00d" }, + ], now, }); + const review = + "https://update.dev.fast/releases/1.2.3/darwin-arm64/Review-darwin-arm64-1.2.3.zip"; + assert.deepEqual(manifest, { version: "1.2.3", commit: "abc123", - url: "https://update.dev.fast/releases/1.2.3/darwin-arm64/Whiteboard-darwin-arm64-1.2.3.zip", + url: review, name: "1.2.3", pub_date: "2026-07-29T00:00:00.000Z", timestamp: now.getTime(), sha256hash: "cafe", + bundles: { + Review: { url: review, sha256hash: "cafe" }, + Whiteboard: { + url: "https://update.dev.fast/releases/1.2.3/darwin-arm64/Whiteboard-darwin-arm64-1.2.3.zip", + sha256hash: "f00d", + }, + }, }); });