diff --git a/.github/workflows/review-desktop-preview.yml b/.github/workflows/review-desktop-preview.yml index 748ac5a85..9fcd7e680 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 03458d2a8..81757f2c0 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 6cb369d35..058231458 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 09971caf6..abc687357 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 193f937fe..ae25548d9 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 059297ce5..433af80cf 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 10512d2d8..4e1bcc4b7 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 52b7e0552..c8bd8c82a 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 670bffc53..78e7acc28 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", + }, + }, }); });