From bd53e1df7d109073a555ad74489f53f8445d604c Mon Sep 17 00:00:00 2001 From: bk-agent-01 Date: Mon, 5 Oct 2026 17:34:16 +0000 Subject: [PATCH] fix(desktop): read BK app updates from a fixed release, not the 10-item feed electron-updater's GitHubProvider reads releases.atom, which lists only the ten newest releases. Stage and mobile builds share the repository and publish many times a day, so production builds fell out of the feed and installed BK T3 Code apps stopped finding updates. Each BK app now uses the generic provider against its own fixed release (bk-desktop-production / bk-desktop-staging). After each dated release is public, the publisher uploads that build's manifest to the fixed release with payload URLs made absolute into the dated release. Multi-range requests are off, because GitHub answers them with 501. A re-run repoints the feed for an already-published build, and the feed never moves to an older version. Also moves the release repository constant to iamtushar324/bkt3code. Co-Authored-By: Claude Opus 5.5 --- apps/desktop/src/updates/DesktopUpdates.ts | 11 +- .../web/src/components/desktopUpdate.logic.ts | 2 +- docs/operations/bk-desktop-build.md | 24 ++- scripts/bk-desktop-brand-overrides.test.ts | 25 +-- scripts/build-bk-desktop-dmg.ts | 3 - scripts/build-desktop-artifact.ts | 33 +-- scripts/lib/bk-desktop-brand.ts | 28 ++- scripts/lib/bk-desktop-release.test.ts | 53 +++++ scripts/lib/bk-desktop-release.ts | 40 +++- scripts/publish-bk-desktop-dmg.ts | 200 +++++++++++++++++- 10 files changed, 358 insertions(+), 61 deletions(-) diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index dd56055dd9ee..a16c330562df 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -409,11 +409,12 @@ export const make = Effect.gen(function* () { const allowsPrerelease = channel === "nightly"; // T3-CUSTOM(expbkt3): BEGIN - the fork ships two apps from one release // repository, so the string handed to electron-updater is the brand's - // ("staging-nightly" / "production-nightly"), not the contract-level - // channel. GitHubProvider matches it against each release tag's first - // prerelease identifier, which is what keeps a staging build invisible to a - // production app. The contract channel is unchanged, so the settings UI and - // the persisted setting keep their existing "latest" | "nightly" values. + // ("stage-nightly" / "production-nightly"), not the contract-level + // channel. It names the manifest the app reads (`-mac.yml`), and + // apps built before the fixed update feed match it against each release + // tag's first prerelease identifier. The contract channel is unchanged, so + // the settings UI and the persisted setting keep their existing + // "latest" | "nightly" values. const brandChannel = resolveRuntimeBrand()?.updateChannel; const updaterChannel = allowsPrerelease ? (brandChannel ?? channel) : channel; yield* electronUpdater.setChannel(updaterChannel); diff --git a/apps/web/src/components/desktopUpdate.logic.ts b/apps/web/src/components/desktopUpdate.logic.ts index e2635f8d36c3..03747f339f3c 100644 --- a/apps/web/src/components/desktopUpdate.logic.ts +++ b/apps/web/src/components/desktopUpdate.logic.ts @@ -10,7 +10,7 @@ const DESKTOP_RELEASE_TAG_URL = `${DESKTOP_RELEASE_HISTORY_URL}/tag`; // T3-CUSTOM(expbkt3): BEGIN - a fork build updates from the fork's releases, so // pointing "Read more" at upstream's tags 404s on every fork version. -const BK_DESKTOP_RELEASE_TAG_URL = "https://github.com/beknown-work/bkt3code/releases/tag"; +const BK_DESKTOP_RELEASE_TAG_URL = "https://github.com/iamtushar324/bkt3code/releases/tag"; function resolveDesktopReleaseTagBaseUrl(): string { return isBkManagedPrimary() ? BK_DESKTOP_RELEASE_TAG_URL : DESKTOP_RELEASE_TAG_URL; diff --git a/docs/operations/bk-desktop-build.md b/docs/operations/bk-desktop-build.md index 1c74382ca48f..4b074edeb7dc 100644 --- a/docs/operations/bk-desktop-build.md +++ b/docs/operations/bk-desktop-build.md @@ -14,7 +14,7 @@ The **updater cache is a third isolation axis**, and an easy one to miss. It liv The encrypted desktop connection catalog follows the same isolation boundary. BK builds store it inside their own Application Support directory (`bkt3code` or `bkt3code-staging`) instead of the shared T3 home, because Electron safe-storage ciphertext from one app identity cannot be decrypted by the other. The older shared catalog is left untouched. -Both are Apple Silicon (`arm64`) only, code signed, **keyless**, and published as prereleases on [`beknown-work/bkt3code`](https://github.com/beknown-work/bkt3code/releases). The identity in use during the current trial is an Apple Development certificate, not the self-signed root this document otherwise describes — see [Current identity is a test certificate](#current-identity-is-a-test-certificate). +Both are Apple Silicon (`arm64`) only, code signed, **keyless**, and published as prereleases on [`iamtushar324/bkt3code`](https://github.com/iamtushar324/bkt3code/releases) (formerly `beknown-work/bkt3code`, which GitHub redirects). The identity in use during the current trial is an Apple Development certificate, not the self-signed root this document otherwise describes — see [Current identity is a test certificate](#current-identity-is-a-test-certificate). ## How it runs @@ -85,14 +85,16 @@ Staging therefore pairs by pasting the credential into the pairing screen, which ## What keeps the two apps apart -One repository holds both apps' releases, and the **updater channel** is the only thing separating them. electron-updater's `GitHubProvider` walks the releases feed and takes the first release whose `semver.prerelease(tag)[0]` equals the running app's channel, then reads `-mac.yml` from it. So the channel has to be the version's first prerelease identifier: +One repository holds both apps' releases, plus the mobile builds. Each app reads its update from its **own fixed release** through electron-updater's generic provider, never from the releases feed: -| App | Version | Channel | Manifest | -| ---------- | ------------------------------------- | -------------------- | ---------------------------- | -| Staging | `X.Y.Z-stage-nightly.YYYYMMDD.N` | `stage-nightly` | `stage-nightly-mac.yml` | -| Production | `X.Y.Z-production-nightly.YYYYMMDD.N` | `production-nightly` | `production-nightly-mac.yml` | +| App | Version | Channel | Feed release | Manifest | +| ---------- | ------------------------------------- | -------------------- | ----------------------- | ---------------------------- | +| Staging | `X.Y.Z-stage-nightly.YYYYMMDD.N` | `stage-nightly` | `bk-desktop-staging` | `stage-nightly-mac.yml` | +| Production | `X.Y.Z-production-nightly.YYYYMMDD.N` | `production-nightly` | `bk-desktop-production` | `production-nightly-mac.yml` | -A staging release is therefore invisible to a production app, and vice versa. +The feed release holds only the manifest. After each dated release is public, the publisher uploads that build's manifest to the feed release with every payload URL made absolute, pointing into the dated release. A staging build never touches the production feed, and vice versa. + +**Why not the releases feed.** `GitHubProvider` reads `releases.atom`, which lists only the ten newest releases, and walks it for a tag whose `semver.prerelease(tag)[0]` equals the app's channel. Staging and mobile builds publish many times a day, so they pushed every production build out of those ten, and production apps silently stopped finding updates (October 2026). Apps built before the fixed feed still use `GitHubProvider`. They pick up the first fixed-feed build only while it is among the ten newest releases; an app that misses that window needs one manual install. The old `staging-nightly` releases from `expbkmain` are also invisible to the new `stage-nightly` app. Install the first `stage-nightly` DMG manually over the existing Stage BK T3 Code installation; its bundle identity and profile path @@ -272,19 +274,19 @@ If a publish fails partway, it leaves a **draft** holding that version's tag. Th Release notes record the signing identity as a **fingerprint of the designated requirement**, never the raw string. This repository is public and an Apple-issued certificate embeds the developer's email and Team ID in its common name; publishing the raw requirement would put that on every release. The fingerprint answers "did the signing identity change between builds?", which is the operational question. For the raw value, run `codesign -dr -` against the app. -Once a version is published, **never replace its ZIP or manifest.** Clients cache by version; a mutated asset is undetectable and unfixable from their side. Publish a higher version instead. +Once a version is published, **never replace its ZIP or manifest.** Clients cache by version; a mutated asset is undetectable and unfixable from their side. Publish a higher version instead. The feed release's manifest is the one exception: it is a pointer, and the publisher replaces it on every build. Never delete the feed release, or every installed app stops updating. If the feed step fails after the dated release is public, use **Re-run failed jobs**: the publisher finds the dated release published from the same commit and only repoints the feed. It refuses to point the feed at a version older than the one it serves, because the apps allow downgrades. After publishing, confirm the upstream pipeline did not fire and that npm is untouched: ```sh -gh run list --repo beknown-work/bkt3code --workflow release.yml -L 3 +gh run list --repo iamtushar324/bkt3code --workflow release.yml -L 3 npm view t3 dist-tags ``` ## Installing (send this to teammates) 1. Install the `BK Code Signing` certificate and set it to **Always Trust** (one-time). -2. Download the `.dmg` from the [latest prerelease](https://github.com/beknown-work/bkt3code/releases) — `BK T3 Code` for day-to-day work, `Stage BK T3 Code` to try `stage`. +2. Download the `.dmg` from the [latest prerelease](https://github.com/iamtushar324/bkt3code/releases) — `BK T3 Code` for day-to-day work, `Stage BK T3 Code` to try `stage`. 3. Drag it to Applications. 4. Builds are not notarised, so macOS quarantines them. Clear that once per app: ```sh @@ -343,7 +345,7 @@ Before advertising automatic updates on a channel, prove all of these. Nothing h Check, in order: -1. The release exists and is a **prerelease** with the right manifest asset for that channel. +1. The feed release (`bk-desktop-production` or `bk-desktop-staging`) holds the channel manifest, and that manifest names the newest version. Its URLs must point into a dated release that exists. 2. The version is strictly newer than the installed one _within the same channel_. 3. The installed app and the update are signed with the **same** certificate. Compare `codesign -dr - "/Applications/BK T3 Code.app"` against the built app and against the release notes; the designated requirements must be identical. diff --git a/scripts/bk-desktop-brand-overrides.test.ts b/scripts/bk-desktop-brand-overrides.test.ts index 068d6cebcf05..f7ff5b8ff2ba 100644 --- a/scripts/bk-desktop-brand-overrides.test.ts +++ b/scripts/bk-desktop-brand-overrides.test.ts @@ -25,7 +25,7 @@ const originalChannel = process.env[BK_MANAGED_CHANNEL_ENV_VAR]; // Set once, before any Config read. Effect's default ConfigProvider snapshots // process.env on first use, so a per-test assignment would be silently ignored // by every test after the first. -process.env.T3CODE_DESKTOP_UPDATE_REPOSITORY = "beknown-work/bkt3code"; +process.env.T3CODE_DESKTOP_UPDATE_REPOSITORY = "pingdotgg/t3code"; function withBkBrand(variant?: "staging" | "production"): void { process.env[DESKTOP_BRAND_ENV_VAR] = "bk"; @@ -130,24 +130,25 @@ describe("updater channel in the publish config", () => { }), ); - it.effect("gives each fork app its own channel, which separates their updates", () => + it.effect("points each fork app at its own fixed update-feed release", () => Effect.gen(function* () { - // Both apps publish into one repository. electron-updater picks a release - // by matching semver.prerelease(tag)[0] against the running app's channel, - // so these strings are the entire isolation mechanism — and they must - // equal the version's first prerelease identifier. + // The releases feed lists only the ten newest releases, and staging and + // mobile builds pushed every production build out of it. A fixed URL per + // app cannot be crowded out, and the channel names the manifest file. withBkBrand("staging"); - expect(yield* publishConfig("0.0.17-stage-nightly.20260413.42")).toMatchObject({ - owner: "beknown-work", - repo: "bkt3code", - releaseType: "prerelease", + expect(yield* publishConfig("0.0.17-stage-nightly.20260413.42")).toEqual({ + provider: "generic", + url: "https://github.com/iamtushar324/bkt3code/releases/download/bk-desktop-staging", channel: "stage-nightly", + useMultipleRangeRequest: false, }); withBkBrand("production"); - expect(yield* publishConfig("0.0.17-production-nightly.20260413.42")).toMatchObject({ - releaseType: "prerelease", + expect(yield* publishConfig("0.0.17-production-nightly.20260413.42")).toEqual({ + provider: "generic", + url: "https://github.com/iamtushar324/bkt3code/releases/download/bk-desktop-production", channel: "production-nightly", + useMultipleRangeRequest: false, }); }), ); diff --git a/scripts/build-bk-desktop-dmg.ts b/scripts/build-bk-desktop-dmg.ts index 2cfef7969319..7c87d5f2f9eb 100644 --- a/scripts/build-bk-desktop-dmg.ts +++ b/scripts/build-bk-desktop-dmg.ts @@ -38,7 +38,6 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { BK_DESKTOP_BRAND_ID, BK_DESKTOP_BRANDS, - BK_DESKTOP_UPDATE_REPOSITORY, DESKTOP_BRAND_ENV_VAR, } from "./lib/bk-desktop-brand.ts"; import { BK_SIGNING_IDENTITY_ENV_VAR, resolveBkSigningIdentity } from "./lib/bk-desktop-signing.ts"; @@ -347,8 +346,6 @@ const runBuild = Effect.fn("runBuild")(function* (options: { // and — via resolveBkDesktopVariant — selects which of the two fork apps // this is: bundle id, product name, user-data directory and updater channel. [BK_MANAGED_CHANNEL_ENV_VAR]: options.managedChannel, - // Points electron-updater at the fork's releases instead of upstream's. - T3CODE_DESKTOP_UPDATE_REPOSITORY: BK_DESKTOP_UPDATE_REPOSITORY, VITE_T3_EXPERIMENTAL_CONTROL_CENTER: "true", // Scrubbed, not merely omitted: `...process.env` above would otherwise carry // an inherited key straight into the bundle, which is exactly the accident diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 8629e484266b..6fa9c7565663 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -29,7 +29,7 @@ import { type WebAssetBrand, } from "./lib/brand-assets.ts"; // T3-CUSTOM(expbkt3): BEGIN - fork desktop brand (bundle id, name, icon, artifact). -import { resolveBkDesktopBrand } from "./lib/bk-desktop-brand.ts"; +import { bkDesktopUpdateFeedUrl, resolveBkDesktopBrand } from "./lib/bk-desktop-brand.ts"; import { resolveBkSigningIdentity } from "./lib/bk-desktop-signing.ts"; // T3-CUSTOM(expbkt3): END import { getDefaultBuildArch } from "./lib/build-target-arch.ts"; @@ -2572,6 +2572,25 @@ export function resolveDesktopRuntimeDependencies( export const resolveGitHubPublishConfig = Effect.fn("resolveGitHubPublishConfig")(function* ( updateChannel: "latest" | "nightly", ) { + // T3-CUSTOM(expbkt3): BEGIN - a fork app reads its manifest from one fixed + // release, not from the releases feed. The feed lists only the ten newest + // releases, and staging and mobile builds in the same repository push + // production builds out of it. The generic provider reads + // `-mac.yml` under `url`. See scripts/lib/bk-desktop-brand.ts. + // GitHub answers a multi-range request with 501, which GitHubProvider knows + // and the generic provider does not, so without the flag every differential + // download falls back to the full ZIP. + const brand = resolveDesktopBrandOverride(); + if (brand && updateChannel === "nightly") { + return { + provider: "generic", + url: bkDesktopUpdateFeedUrl(brand), + channel: brand.updateChannel, + useMultipleRangeRequest: false, + }; + } + // T3-CUSTOM(expbkt3): END + const env = yield* Config.all({ updateRepository: Config.String("T3CODE_DESKTOP_UPDATE_REPOSITORY").pipe(Config.option), githubRepository: Config.String("GITHUB_REPOSITORY").pipe(Config.option), @@ -2586,22 +2605,12 @@ export const resolveGitHubPublishConfig = Effect.fn("resolveGitHubPublishConfig" const [owner, repo, ...rest] = rawRepo.split("/"); if (!owner || !repo || rest.length > 0) return undefined; - // T3-CUSTOM(expbkt3): BEGIN - the fork ships two apps out of this one - // repository, so the channel is the brand's, not the literal "nightly". - // That name becomes the manifest asset (`-mac.yml`) and must equal - // the first prerelease identifier of the version, which is what - // electron-updater's GitHubProvider matches on. See - // scripts/lib/bk-desktop-brand.ts. - const brandChannel = resolveDesktopBrandOverride()?.updateChannel; - // T3-CUSTOM(expbkt3): END - return { provider: "github", owner, repo, releaseType: updateChannel === "nightly" ? "prerelease" : "release", - // T3-CUSTOM(expbkt3): brandChannel when a fork brand is active, else upstream's. - ...(updateChannel === "nightly" ? { channel: brandChannel ?? ("nightly" as const) } : {}), + ...(updateChannel === "nightly" ? { channel: "nightly" as const } : {}), }; }); diff --git a/scripts/lib/bk-desktop-brand.ts b/scripts/lib/bk-desktop-brand.ts index 4367ded38bd8..a610560e2fe7 100644 --- a/scripts/lib/bk-desktop-brand.ts +++ b/scripts/lib/bk-desktop-brand.ts @@ -45,8 +45,8 @@ export const BK_DESKTOP_BRAND_ID = "bk"; export type DesktopBrandId = "upstream" | "bk"; -/** Repository whose GitHub releases the fork's desktop updater reads. */ -export const BK_DESKTOP_UPDATE_REPOSITORY = "beknown-work/bkt3code"; +/** Repository whose GitHub releases hold the fork's desktop builds. */ +export const BK_DESKTOP_UPDATE_REPOSITORY = "iamtushar324/bkt3code"; /** * The fork ships one app per managed environment, so `stage` and `bkmain` @@ -88,13 +88,20 @@ export interface DesktopBrand { * electron-updater channel, and therefore the manifest asset name * (`-mac.yml`) and the version's first prerelease identifier. * - * This is what keeps the two apps apart inside one release repository. - * `GitHubProvider.getLatestVersion` walks the releases feed and takes the - * first release whose `semver.prerelease(tag)[0]` equals the running app's - * channel, so a staging build is invisible to a production app and vice - * versa. See `scripts/lib/bk-desktop-release.ts` for the version format. + * The app reads `-mac.yml` from {@link updateFeedTag}'s + * release. See `scripts/lib/bk-desktop-release.ts` for the version format. */ readonly updateChannel: string; + /** + * Tag of the fixed GitHub release this app's updater reads. The publish + * script replaces its manifest after every build. + * + * The app does not search the releases feed. That feed lists only the ten + * newest releases, and the staging and mobile builds that share this + * repository pushed every production build out of it, so production apps + * stopped finding updates. + */ + readonly updateFeedTag: string; /** * OS-level URL scheme this app registers as a handler for, or `null` to * register none. @@ -147,6 +154,7 @@ export const BK_DESKTOP_BRANDS: Readonly> linuxIconPng: BK_BRAND_ASSET_PATHS.universalIconPng, windowsIconIco: BK_BRAND_ASSET_PATHS.windowsIconIco, updateChannel: "production-nightly", + updateFeedTag: "bk-desktop-production", deepLinkScheme: "t3code", }, staging: { @@ -169,6 +177,7 @@ export const BK_DESKTOP_BRANDS: Readonly> linuxIconPng: BK_BRAND_ASSET_PATHS.universalIconPng, windowsIconIco: BK_BRAND_ASSET_PATHS.windowsIconIco, updateChannel: "stage-nightly", + updateFeedTag: "bk-desktop-staging", deepLinkScheme: null, }, }; @@ -185,6 +194,11 @@ export function isBkDesktopBrandId(value: string | undefined): boolean { return value?.trim().toLowerCase() === BK_DESKTOP_BRAND_ID; } +/** Base URL electron-updater's generic provider reads a brand's manifest from. */ +export function bkDesktopUpdateFeedUrl(brand: DesktopBrand): string { + return `https://github.com/${BK_DESKTOP_UPDATE_REPOSITORY}/releases/download/${brand.updateFeedTag}`; +} + /** Resolves the brand from a build environment, defaulting to upstream. */ export function resolveDesktopBrandId( env: Readonly>, diff --git a/scripts/lib/bk-desktop-release.test.ts b/scripts/lib/bk-desktop-release.test.ts index bd466b07fcde..440512343e31 100644 --- a/scripts/lib/bk-desktop-release.test.ts +++ b/scripts/lib/bk-desktop-release.test.ts @@ -1,6 +1,8 @@ import * as DateTime from "effect/DateTime"; import { describe, expect, it } from "vite-plus/test"; +import { BK_DESKTOP_BRANDS } from "./bk-desktop-brand.ts"; + import { compareNightlyVersions, composeNightlyVersion, @@ -10,6 +12,7 @@ import { parseNightlyVersion, resolveNewestNightlyVersion, resolveNextCounter, + rewriteManifestForUpdateFeed, tagFromVersion, updateChannelForVariant, updateManifestFileName, @@ -256,3 +259,53 @@ describe("release asset selection", () => { expect(isNightlyReleaseAsset("BK T3 Code.app", "staging")).toBe(false); }); }); + +describe("fixed update feed", () => { + const tag = "v0.0.43-production-nightly.20261002.4"; + const zip = "BK-T3-Code-0.0.43-production-nightly.20261002.4-arm64.zip"; + const dmg = "BK-T3-Code-0.0.43-production-nightly.20261002.4-arm64.dmg"; + const base = `https://github.com/iamtushar324/bkt3code/releases/download/${tag}`; + // As electron-builder writes it, from the real 20261002.4 release. + const manifest = [ + "version: 0.0.43-production-nightly.20261002.4", + "files:", + ` - url: ${zip}`, + " sha512: 2JJc==", + " size: 140050571", + ` - url: ${dmg}`, + " sha512: ut4w==", + " size: 145750515", + `path: ${zip}`, + "sha512: 2JJc==", + "releaseDate: '2026-10-02T21:48:15.265Z'", + "", + ].join("\n"); + + it("points every payload at the dated release and keeps the rest", () => { + // The feed release holds only the manifest, so a relative name would 404. + expect(rewriteManifestForUpdateFeed(manifest, tag)).toBe( + manifest + .replace(` - url: ${zip}`, ` - url: ${base}/${zip}`) + .replace(` - url: ${dmg}`, ` - url: ${base}/${dmg}`) + .replace(`path: ${zip}`, `path: ${base}/${zip}`), + ); + }); + + it("is idempotent, so a manifest rewritten twice still resolves", () => { + const once = rewriteManifestForUpdateFeed(manifest, tag); + expect(rewriteManifestForUpdateFeed(once, tag)).toBe(once); + }); + + it("keeps feed tags out of version ordering and the release workflow", () => { + // The feed releases share the repository with the dated ones. Version + // resolution must not read them as builds, and release.yml (tags v*.*.*) + // must not fire for them. + for (const brand of Object.values(BK_DESKTOP_BRANDS)) { + expect(parseNightlyVersion(brand.updateFeedTag)).toBeUndefined(); + expect(brand.updateFeedTag.startsWith("v")).toBe(false); + } + expect( + resolveNewestNightlyVersion([BK_DESKTOP_BRANDS.production.updateFeedTag, tag], "production"), + ).toMatchObject({ counter: 4 }); + }); +}); diff --git a/scripts/lib/bk-desktop-release.ts b/scripts/lib/bk-desktop-release.ts index 621f59b4432d..b6abbfc1397c 100644 --- a/scripts/lib/bk-desktop-release.ts +++ b/scripts/lib/bk-desktop-release.ts @@ -11,12 +11,14 @@ * nightly-form version, and versions must increase, or teammates never see * the update. * - * 2. **Two apps share one release repository.** `GitHubProvider` in - * electron-updater picks the newest release whose `semver.prerelease(tag)[0]` - * equals the running app's channel, then reads `-mac.yml` from it. - * So the channel has to be the version's *first* prerelease identifier: - * `X.Y.Z-stage-nightly.YYYYMMDD.N` and `X.Y.Z-production-nightly.…`. A - * staging release is then simply invisible to a production app. + * 2. **Two apps share one release repository.** Each app reads + * `-mac.yml` from its own fixed release (`updateFeedTag` in + * `./bk-desktop-brand.ts`), which the publish script repoints at every new + * build with {@link rewriteManifestForUpdateFeed}. The channel is still the + * version's *first* prerelease identifier — `X.Y.Z-stage-nightly.YYYYMMDD.N` + * and `X.Y.Z-production-nightly.…` — because apps built before the fixed + * feed use `GitHubProvider`, which matches it against each release tag in + * the releases feed. * * 3. **The release pipeline must not fire.** `.github/workflows/release.yml` * triggers on tags `v*.*.*` with `!v*-nightly.*` excluded, and it has no @@ -30,10 +32,14 @@ import * as DateTime from "effect/DateTime"; -import { BK_DESKTOP_BRANDS, type BkDesktopVariant } from "./bk-desktop-brand.ts"; +import { + BK_DESKTOP_BRANDS, + BK_DESKTOP_UPDATE_REPOSITORY, + type BkDesktopVariant, +} from "./bk-desktop-brand.ts"; /** Repository the fork publishes desktop prereleases to. */ -export const BK_DESKTOP_RELEASE_REPOSITORY = "beknown-work/bkt3code"; +export const BK_DESKTOP_RELEASE_REPOSITORY = BK_DESKTOP_UPDATE_REPOSITORY; /** Nightly-form version: `X.Y.Z--nightly.YYYYMMDD.N`. */ const NIGHTLY_VERSION_PATTERN = /^(\d+)\.(\d+)\.(\d+)-(stage|production)-nightly\.(\d{8})\.(\d+)$/; @@ -61,6 +67,24 @@ export function updateManifestFileName(variant: BkDesktopVariant): string { return `${updateChannelForVariant(variant)}-mac.yml`; } +/** + * Rewrites a built `-mac.yml` so it can be served from the fixed + * update-feed release. + * + * electron-builder writes payload names relative to the manifest. The feed + * release holds only the manifest, so each relative `url:` and `path:` becomes + * an absolute download URL in the dated release `releaseTag`. electron-updater + * resolves an absolute URL as is, and finds the `.blockmap` beside it. + */ +export function rewriteManifestForUpdateFeed(manifestText: string, releaseTag: string): string { + const base = `https://github.com/${BK_DESKTOP_RELEASE_REPOSITORY}/releases/download/${releaseTag}`; + return manifestText.replace( + /^([ \t]*(?:- )?(?:url|path):[ \t]*)(\S+)[ \t]*$/gm, + (line, prefix: string, value: string) => + /^https?:\/\//.test(value) ? line : `${prefix}${base}/${value}`, + ); +} + /** * Formats a build date as `YYYYMMDD` in UTC. * diff --git a/scripts/publish-bk-desktop-dmg.ts b/scripts/publish-bk-desktop-dmg.ts index f331284a6b4c..fc44112e4605 100644 --- a/scripts/publish-bk-desktop-dmg.ts +++ b/scripts/publish-bk-desktop-dmg.ts @@ -51,9 +51,12 @@ import { isReleaseWorkflowSafeTag, parseNightlyVersion, resolveNewestNightlyVersion, + rewriteManifestForUpdateFeed, tagFromVersion, updateChannelForVariant, updateManifestFileName, + versionFromTag, + type ParsedNightlyVersion, } from "./lib/bk-desktop-release.ts"; const RepoRoot = Effect.service(Path.Path).pipe( @@ -84,6 +87,7 @@ export class GhCommandFailedError extends Schema.TaggedError()( + "UpdateFeedWouldMoveBackwardError", + { + feedTag: Schema.String, + version: Schema.String, + feedVersion: Schema.String, + }, +) { + override get message(): string { + return ( + `Update feed ${this.feedTag} already serves ${this.feedVersion}, which is newer than ` + + `${this.version}. Installed apps allow downgrades, so repointing it would move every ` + + `app back. The dated release is public; the feed is unchanged.` + ); + } +} + const collectStreamAsString = (stream: Stream.Stream): Effect.Effect => stream.pipe( Stream.decodeText(), @@ -708,6 +729,143 @@ const assertDraftAssetsComplete = Effect.fn("assertDraftAssetsComplete")(functio } }); +/** + * Points the app's fixed update-feed release at the build just published. + * + * Runs only after the dated release is public, because the rewritten manifest + * links to that release's ZIP. The feed release holds nothing but the + * manifest, so `--clobber` swaps one small file. A client that checks during + * the swap gets a 404 and tries again at its next check. + * + * If this step fails, the dated release is still public but the app does not + * see it. The next successful publish repoints the feed. + */ +const publishUpdateFeed = Effect.fn("publishUpdateFeed")(function* (input: { + readonly variant: BkManagedChannel; + readonly releaseDir: string; + readonly manifest: string; + readonly tag: string; + readonly version: ParsedNightlyVersion; + readonly targetSha: string; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const brand = BK_DESKTOP_BRANDS[input.variant]; + const feedTag = brand.updateFeedTag; + + const manifestText = yield* fs.readFileString(path.join(input.releaseDir, input.manifest)); + const feedDir = yield* fs.makeTempDirectoryScoped(); + const feedManifest = path.join(feedDir, input.manifest); + yield* fs.writeFileString(feedManifest, rewriteManifestForUpdateFeed(manifestText, input.tag)); + + const notes = [ + `Update feed for **${brand.productName}**: the app reads \`${input.manifest}\` from this release. Do not delete it.`, + ``, + `Every publish replaces the manifest. It now points at [${input.tag}](https://github.com/${BK_DESKTOP_RELEASE_REPOSITORY}/releases/tag/${input.tag}).`, + ].join("\n"); + + const existing = yield* runGh([ + "release", + "view", + feedTag, + "--repo", + BK_DESKTOP_RELEASE_REPOSITORY, + "--json", + "tagName", + ]); + if (existing.exitCode === 0) { + // Apps run with allowDowngrade on, so an older build in the feed would be + // offered to every app. Two publishes of one channel can overlap when a + // dispatch builds it from another branch. + const current = yield* runGh([ + "release", + "download", + feedTag, + "--repo", + BK_DESKTOP_RELEASE_REPOSITORY, + "--pattern", + input.manifest, + "--output", + "-", + ]); + const feedVersion = /^version:\s*(\S+)\s*$/m.exec(current.stdout)?.[1]; + const parsedFeedVersion = feedVersion ? parseNightlyVersion(feedVersion) : undefined; + if ( + current.exitCode === 0 && + feedVersion && + parsedFeedVersion && + compareNightlyVersions(input.version, parsedFeedVersion) < 0 + ) { + return yield* new UpdateFeedWouldMoveBackwardError({ + feedTag, + version: versionFromTag(input.tag), + feedVersion, + }); + } + } + + const steps: ReadonlyArray> = + existing.exitCode === 0 + ? [ + [ + "release", + "upload", + feedTag, + "--repo", + BK_DESKTOP_RELEASE_REPOSITORY, + "--clobber", + feedManifest, + ], + ["release", "edit", feedTag, "--repo", BK_DESKTOP_RELEASE_REPOSITORY, "--notes", notes], + ] + : [ + [ + "release", + "create", + feedTag, + "--repo", + BK_DESKTOP_RELEASE_REPOSITORY, + // A prerelease is never marked "Latest", and apps built before the + // fixed feed skip this tag because it has no channel. + "--prerelease", + "--target", + input.targetSha, + "--title", + `${brand.productName} update feed`, + "--notes", + notes, + feedManifest, + ], + ]; + for (const args of steps) { + const result = yield* runGh(args); + if (result.exitCode !== 0) { + return yield* new GhCommandFailedError({ + operation: "update-feed", + exitCode: result.exitCode, + stderrTail: result.stderr.trim() || result.stdout.trim(), + }); + } + } + + // The draft wording of this error tells the reader to delete the release, + // which for the feed would stop every installed app from updating. + yield* assertDraftAssetsComplete(feedTag, [feedManifest]).pipe( + Effect.catchTag("IncompleteReleaseUploadError", (error) => + Effect.fail( + new GhCommandFailedError({ + operation: "update-feed", + exitCode: 0, + stderrTail: + `${error.asset} on ${feedTag} is incomplete (${error.detail}). ` + + `Re-run the publish job. Do not delete ${feedTag}.`, + }), + ), + ), + ); + yield* Console.log(`Update feed ${feedTag} now points at ${input.tag}.`); +}); + const command = Command.make( "publish-bk-desktop-dmg", { @@ -779,7 +937,34 @@ const command = Command.make( const releases = yield* listPublishedTags(); const existing = releases.find((release) => release.tagName.trim() === tag); if (existing && !existing.isDraft) { - return yield* new ReleaseTagAlreadyExistsError({ tag }); + // "Re-run failed jobs" after the feed step failed lands here: the dated + // release is public, so only the feed still needs this build. + const target = yield* runGh([ + "release", + "view", + tag, + "--repo", + BK_DESKTOP_RELEASE_REPOSITORY, + "--json", + "targetCommitish", + ]); + const targetCommitish = + target.exitCode === 0 + ? (yield* decodeGhReleaseTarget(target.stdout)).targetCommitish.trim().toLowerCase() + : undefined; + if (targetCommitish !== targetSha) { + return yield* new ReleaseTagAlreadyExistsError({ tag }); + } + yield* Console.log(`${tag} is already published from ${targetSha}; repointing the feed.`); + if (dryRun) return; + return yield* publishUpdateFeed({ + variant, + releaseDir: path.resolve(repoRoot, releaseDir), + manifest, + tag, + version: parsed, + targetSha, + }); } if (existing?.isDraft) { // A draft holding this tag is the wreckage of an earlier failed publish, @@ -886,7 +1071,10 @@ const command = Command.make( } if (dryRun) { - yield* Console.log(`\nDry run — would publish prerelease ${tag} targeting ${targetSha}.`); + yield* Console.log( + `\nDry run — would publish prerelease ${tag} targeting ${targetSha}, ` + + `then point update feed ${brand.updateFeedTag} at it.`, + ); return; } @@ -943,6 +1131,14 @@ const command = Command.make( } yield* Console.log(published.stdout.trim()); + yield* publishUpdateFeed({ + variant, + releaseDir: resolvedReleaseDir, + manifest, + tag, + version: parsed, + targetSha, + }); yield* Console.log( `\nPublished prerelease ${tag} at ${targetSha}. Confirm release.yml did not run:\n` + ` gh run list --repo ${BK_DESKTOP_RELEASE_REPOSITORY} --workflow release.yml -L 3`,