Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions .github/workflows/review-desktop-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand All @@ -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}")
Expand Down
12 changes: 9 additions & 3 deletions .github/workflows/review-desktop-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand All @@ -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}")
Expand Down
11 changes: 7 additions & 4 deletions apps/review-desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<version>.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`
Expand Down Expand Up @@ -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 <https://install.dev.fast>.
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 <https://install.dev.fast/preview> after the split so the
Expand Down Expand Up @@ -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/<version>/darwin-arm64/ Whiteboard-darwin-arm64-<version>.zip + .dmg
Review-darwin-arm64-<version>.zip (Review.app-named copy)
releases/latest/darwin-arm64/Whiteboard.dmg
direct-download alias, saved as
df-whiteboard-<version>.dmg
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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 });
Expand Down
25 changes: 17 additions & 8 deletions apps/review-desktop/scripts/notarize-macos.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
34 changes: 34 additions & 0 deletions apps/review-desktop/scripts/release-channel.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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 <channel>` prints one "bundle<TAB>artifact"
// line per update zip, for the packaging shell scripts.
for (const { bundle, artifact } of updateBundlesFor(process.argv[2])) {
console.log(`${bundle}\t${artifact}`);
}
}
63 changes: 52 additions & 11 deletions apps/review-desktop/scripts/validate-release-artifacts.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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);

Expand Down Expand Up @@ -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);
Expand All @@ -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}`);
}
Expand Down
17 changes: 15 additions & 2 deletions apps/review-desktop/scripts/validate-release-artifacts.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
},
});
});

Expand Down
Loading