diff --git a/apps/server/src/cli/update.test.ts b/apps/server/src/cli/update.test.ts index 848c2c798df41..ecd837e37ac01 100644 --- a/apps/server/src/cli/update.test.ts +++ b/apps/server/src/cli/update.test.ts @@ -18,7 +18,13 @@ import * as BootService from "../cloud/bootService.ts"; import { prunePinnedRuntimes } from "../cloud/pinnedRuntime.ts"; import * as ManagedPolicy from "../policy/ManagedPolicy.ts"; import * as ProcessRunner from "../processRunner.ts"; -import { repointLauncher, resolveLauncherPath, resolveNewestVersion, runUpdate } from "./update.ts"; +import { + repointLauncher, + resolveLauncherPath, + resolveMirrorVersion, + resolveNewestVersion, + runUpdate, +} from "./update.ts"; // lsof listing one process, so prune sees a process list in which no runtime is running. const unrelatedLsof = ProcessRunner.ProcessRunner.of({ @@ -60,10 +66,59 @@ it.effect("authenticates each release page until the requested channel is found" }), ); +it.effect("reads the newest version from the update feed file a mirror publishes", () => + Effect.gen(function* () { + const requested: string[] = []; + const mirror = { + organization: "Example Corp", + feedUrl: "https://updates.example.com/t3code/?token=abc", + }; + const resolve = ( + channel: "stable" | "nightly" | "preview", + platform: NodeJS.Platform, + arch: string, + feed: string, + ) => + resolveMirrorVersion(mirror, channel).pipe( + Effect.map((version): string => version), + Effect.catchTags({ CliUpdateError: (error) => Effect.succeed(`error: ${error.reason}`) }), + Effect.provideService(HostProcess.Platform, platform), + Effect.provideService(HostProcess.Architecture, arch), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => { + requested.push(request.url); + return Effect.succeed(HttpClientResponse.fromWeb(request, new Response(feed))); + }), + ), + ); + + assert.equal( + yield* resolve("stable", "darwin", "arm64", "version: 1.4.0\nfiles: []\n"), + "1.4.0", + ); + assert.equal( + yield* resolve("nightly", "linux", "arm64", "version: '1.5.0-nightly.20261101.2'\n"), + "1.5.0-nightly.20261101.2", + ); + assert.deepStrictEqual(requested, [ + "https://updates.example.com/t3code/latest-mac.yml?token=abc", + "https://updates.example.com/t3code/nightly-linux-arm64.yml?token=abc", + ]); + // A feed for the wrong track is not trusted to mean the requested one. + assert.include( + yield* resolve("stable", "win32", "x64", "version: 1.5.0-nightly.20261101.2\n"), + "Could not read latest.yml from Example Corp's update mirror.", + ); + assert.include(yield* resolve("preview", "win32", "x64", ""), "has no preview builds"); + }), +); + it.layer(NodeServices.layer)("t3 update under a managed policy", (it) => { const runWithPolicy = ( updates: ManagedUpdatesPolicy, input: { readonly channel?: "stable" | "nightly"; readonly requestedVersion?: string } = {}, + httpClient = HttpClient.make(() => Effect.die("unexpected release lookup")), ) => runUpdate({ baseDir: "/unused", @@ -85,10 +140,7 @@ it.layer(NodeServices.layer)("t3 update under a managed policy", (it) => { // Refusals happen before the update touches the network, the service, or a process. Layer.mock(BootService.BootService, {}), Layer.mock(ProcessRunner.ProcessRunner, {}), - Layer.succeed( - HttpClient.HttpClient, - HttpClient.make(() => Effect.die("unexpected release lookup")), - ), + Layer.succeed(HttpClient.HttpClient, httpClient), ), ), ); @@ -118,6 +170,24 @@ it.layer(NodeServices.layer)("t3 update under a managed policy", (it) => { } }), ); + + it.effect("looks for updates on the policy's mirror, never the public releases", () => + Effect.gen(function* () { + const requested: string[] = []; + const error = yield* runWithPolicy( + { mode: "auto", feedUrl: "https://updates.example.com/t3code" }, + { channel: "stable" }, + HttpClient.make((request) => { + requested.push(new URL(request.url).origin); + return Effect.succeed( + HttpClientResponse.fromWeb(request, new Response("", { status: 404 })), + ); + }), + ); + assert.include(error.message, "from Example Corp's update mirror"); + assert.deepStrictEqual(requested, ["https://updates.example.com"]); + }), + ); }); it.layer(NodeServices.layer)("t3 update launcher", (it) => { diff --git a/apps/server/src/cli/update.ts b/apps/server/src/cli/update.ts index dc2e29345864c..be36b1184d88d 100644 --- a/apps/server/src/cli/update.ts +++ b/apps/server/src/cli/update.ts @@ -5,6 +5,8 @@ import { cliReleaseIndexPageUrl, cliReleaseChannelOf, newestCliReleaseVersion, + releaseAssetUrl, + updateFeedFileName, type CliReleaseChannel, } from "@t3tools/shared/cliRelease"; import * as Console from "effect/Console"; @@ -24,6 +26,7 @@ import { ensurePinnedRuntimeInstalled, launcherOwnsVersionsDir, pinnedRuntimeCommand, + isUnpublishedRelease, PinnedRuntimeInstallError, pinnedRuntimePaths, pinnedRuntimeVersionsDir, @@ -33,7 +36,7 @@ import { } from "../cloud/pinnedRuntime.ts"; import { compareExactServiceVersions, isExactServiceVersion } from "../cloud/serviceProtocol.ts"; import * as ManagedPolicy from "../policy/ManagedPolicy.ts"; -import { managedUpdateRefusal } from "../policy/managedUpdates.ts"; +import { managedUpdateMirror, managedUpdateRefusal } from "../policy/managedUpdates.ts"; import * as ProcessRunner from "../processRunner.ts"; import { isProcessAlive, readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; @@ -94,6 +97,47 @@ export const resolveNewestVersion = Effect.fn("cli.update.resolve_newest")(funct return yield* new CliUpdateError({ reason: `No published ${channel} release was found.` }); }); +/** The version an update mirror's feed file for this platform offers on `channel`. */ +export const resolveMirrorVersion = Effect.fn("cli.update.resolve_mirror")(function* ( + mirror: { readonly organization: string; readonly feedUrl: string }, + channel: CliReleaseChannel, +) { + if (channel === "preview") { + return yield* new CliUpdateError({ + reason: `${mirror.organization}'s update mirror has no preview builds.`, + }); + } + const httpClient = yield* HttpClient.HttpClient; + const fileName = updateFeedFileName( + channel === "nightly" ? "nightly" : "latest", + yield* HostProcess.Platform, + yield* HostProcess.Architecture, + ); + const url = releaseAssetUrl(mirror.feedUrl, fileName); + // Not the URL, whose query can hold the mirror's access token. + const unreadable = new CliUpdateError({ + reason: `Could not read ${fileName} from ${mirror.organization}'s update mirror.`, + }); + const body = yield* httpClient.get(url).pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap((response) => response.text), + Effect.mapError(() => unreadable), + Effect.timeoutOrElse({ + duration: RELEASE_INDEX_TIMEOUT, + orElse: () => Effect.fail(unreadable), + }), + ); + const version = /^version:\s*["']?([^"'\s]+)/m.exec(body)?.[1]; + if ( + version === undefined || + !isExactServiceVersion(version) || + cliReleaseChannelOf(version) !== channel + ) { + return yield* unreadable; + } + return version; +}); + /** * Only a launcher that already points into this home's `runtime/versions` tree is touched; a * plain copy of the executable, or a launcher for some other install, is left alone. It is @@ -320,6 +364,7 @@ export const runUpdate = Effect.fn("cli.update.run")(function* (input: { const httpClient = yield* HttpClient.HttpClient; const service = yield* BootService.BootService; const policy = yield* ManagedPolicy.ManagedPolicy; + const mirror = managedUpdateMirror(policy.document); const currentVersion = packageJson.version; const channel = input.channel ?? cliReleaseChannelOf(currentVersion); @@ -338,9 +383,11 @@ export const runUpdate = Effect.fn("cli.update.run")(function* (input: { const progress = createUpdateProgress(); progress.status("Checking for updates..."); const targetVersion = yield* ( - input.requestedVersion === undefined - ? resolveNewestVersion(channel) - : Effect.succeed(input.requestedVersion) + input.requestedVersion !== undefined + ? Effect.succeed(input.requestedVersion) + : mirror === undefined + ? resolveNewestVersion(channel) + : resolveMirrorVersion(mirror, channel) ).pipe(Effect.ensuring(Effect.sync(progress.finish))); const targetChannel = cliReleaseChannelOf(targetVersion); @@ -455,7 +502,8 @@ export const runUpdate = Effect.fn("cli.update.run")(function* (input: { httpClient, platform, arch, - releaseBaseUrl: environment[CLI_RELEASE_BASE_URL_ENV]?.trim() || undefined, + releaseBaseUrl: mirror?.feedUrl ?? (environment[CLI_RELEASE_BASE_URL_ENV]?.trim() || undefined), + requirePublishedRelease: mirror !== undefined, validate: (paths) => runner .run({ @@ -481,17 +529,15 @@ export const runUpdate = Effect.fn("cli.update.run")(function* (input: { ), }).pipe( Effect.ensuring(Effect.sync(progress.finish)), - Effect.catchIf( - (error): error is PinnedRuntimeInstallError => - error._tag === "PinnedRuntimeInstallError" && - error.step.startsWith("downloading the t3 release checksums") && - String(error.cause).includes("404"), - () => - Effect.fail( - new CliUpdateError({ - reason: `No release archive was published for t3@${targetVersion}.`, - }), - ), + Effect.catchIf(isUnpublishedRelease, () => + Effect.fail( + new CliUpdateError({ + reason: + mirror === undefined + ? `No release archive was published for t3@${targetVersion}.` + : `${mirror.organization}'s update mirror has no t3@${targetVersion} for this machine.`, + }), + ), ), ); diff --git a/apps/server/src/cloud/pinnedRuntime.test.ts b/apps/server/src/cloud/pinnedRuntime.test.ts index 7babf123b9d42..38e56b30388f9 100644 --- a/apps/server/src/cloud/pinnedRuntime.test.ts +++ b/apps/server/src/cloud/pinnedRuntime.test.ts @@ -16,6 +16,7 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; import * as ProcessRunner from "../processRunner.ts"; import { ensurePinnedRuntimeInstalled, + isUnpublishedRelease, pinnedRuntimeCommand, pinnedRuntimePaths, PinnedRuntimeInstallError, @@ -39,7 +40,7 @@ const validChecksums = archiveHex(archiveBytes).pipe( const releaseHttpClient = (checksums: string, requests: string[] = []) => HttpClient.make((request) => { requests.push(request.url); - const body = request.url.endsWith("/SHA256SUMS") ? checksums : archiveBytes; + const body = new URL(request.url).pathname.endsWith("/SHA256SUMS") ? checksums : archiveBytes; return Effect.succeed(HttpClientResponse.fromWeb(request, new Response(body))); }); const extractingRunner = (fs: FileSystem.FileSystem, path: Path.Path, commands: string[] = []) => @@ -155,6 +156,40 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { }), ); + it.effect("reports a release that lists an archive it does not serve as unpublished", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-archive-missing-" }); + const checksums = yield* validChecksums; + const failsWith = (status: number) => + ensurePinnedRuntimeInstalled({ + baseDir, + version, + fs, + path, + platform: "linux", + arch: "x64", + httpClient: HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + request.url.endsWith("/SHA256SUMS") + ? new Response(checksums) + : new Response("", { status }), + ), + ), + ), + // "404" in the mirror's path must not read a server error as a missing release. + releaseBaseUrl: "https://updates.example.com/404/t3code", + runner: extractingRunner(fs, path), + validate: () => Effect.die("must not validate a missing archive"), + }).pipe(Effect.flip); + assert.isTrue(isUnpublishedRelease(yield* failsWith(404))); + assert.isFalse(isUnpublishedRelease(yield* failsWith(500))); + }), + ); + it.effect("refuses an archive whose checksum does not match the release", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -267,6 +302,42 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { }), ); + it.effect("reuses a cached runtime under a mirror only while the mirror lists it", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-mirror-" }); + const cached = pinnedRuntimePaths(path, baseDir, version, "linux"); + yield* fs.makeDirectory(path.dirname(cached.entryPath), { recursive: true }); + yield* fs.writeFileString(cached.entryPath, "#!/bin/sh\n"); + yield* fs.writeFileString(cached.sentinelPath, `${version}\n`); + const install = (checksums: string, requests: string[]) => + ensurePinnedRuntimeInstalled({ + baseDir, + version, + fs, + path, + platform: "linux", + arch: "x64", + httpClient: releaseHttpClient(checksums, requests), + // A signed mirror URL keeps its query on every file it serves. + releaseBaseUrl: "https://updates.example.com/t3code/?token=abc", + requirePublishedRelease: true, + runner: extractingRunner(fs, path), + validate: () => Effect.void, + }); + + const withdrawn = yield* install("", []).pipe(Effect.flip); + assert.include(withdrawn.message, `finding ${archiveName} in the t3 release checksums`); + const requests: string[] = []; + assert.equal((yield* install(yield* validChecksums, requests)).entryPath, cached.entryPath); + // Listed, so the cached runtime is used without downloading it again. + assert.deepEqual(requests, [ + `https://updates.example.com/t3code/v${version}/SHA256SUMS?token=abc`, + ]); + }), + ); + it.effect("preserves a completed runtime when validation fails", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/cloud/pinnedRuntime.ts b/apps/server/src/cloud/pinnedRuntime.ts index 96745fe85bf41..4e8df29f3026d 100644 --- a/apps/server/src/cloud/pinnedRuntime.ts +++ b/apps/server/src/cloud/pinnedRuntime.ts @@ -9,14 +9,15 @@ import * as Schema from "effect/Schema"; import * as Option from "effect/Option"; import * as Stream from "effect/Stream"; import * as Semaphore from "effect/Semaphore"; -import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; +import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/http"; import { CLI_RELEASE_CHECKSUMS_FILE, cliArchiveFileName, cliArchivePlatformKey, cliArchiveTarCommand, - cliReleaseDownloadBaseUrl, + cliReleaseAssetUrl, + type CliArchivePlatformKey, parseChecksums, } from "@t3tools/shared/cliRelease"; @@ -111,6 +112,8 @@ interface PinnedRuntimeInstallInput { readonly arch: string; readonly httpClient: HttpClient.HttpClient; readonly releaseBaseUrl?: string | undefined; + /** Reuse a cached runtime only while the release source still lists it, as a policy mirror does. */ + readonly requirePublishedRelease?: boolean; readonly onProgress?: (progress: PinnedRuntimeProgress) => void; } @@ -178,28 +181,11 @@ const installFromArchive = Effect.fn("cloud.pinned_runtime.install_archive")(fun }); } const httpClient = input.httpClient; - const baseUrl = cliReleaseDownloadBaseUrl(input.version, input.releaseBaseUrl); - const fileName = cliArchiveFileName(input.version, platformKey); - input.onProgress?.({ stage: "download", received: 0, total: undefined }); - const checksums = parseChecksums( - new TextDecoder().decode( - yield* fetchReleaseAsset( - httpClient, - `${baseUrl}/${CLI_RELEASE_CHECKSUMS_FILE}`, - "downloading the t3 release checksums", - ), - ), - ); - const expected = checksums.get(fileName); - if (expected === undefined) { - return yield* new PinnedRuntimeInstallError({ - step: `finding ${fileName} in the t3 release checksums`, - }); - } + const { fileName, expected } = yield* publishedArchive(input, platformKey); const archive = yield* fetchReleaseAsset( httpClient, - `${baseUrl}/${fileName}`, + cliReleaseAssetUrl(input.version, fileName, input.releaseBaseUrl), "downloading the t3 release archive", input.onProgress, ); @@ -248,6 +234,40 @@ const installFromArchive = Effect.fn("cloud.pinned_runtime.install_archive")(fun yield* fs.remove(archivePath, { force: true }).pipe(Effect.ignore); }); +/** The release's archive for this machine and its checksum; fails when the release lists none. */ +const publishedArchive = Effect.fn("cloud.pinned_runtime.published_archive")(function* ( + input: PinnedRuntimeInstallInput, + platformKey: CliArchivePlatformKey, +) { + const fileName = cliArchiveFileName(input.version, platformKey); + const checksums = parseChecksums( + new TextDecoder().decode( + yield* fetchReleaseAsset( + input.httpClient, + cliReleaseAssetUrl(input.version, CLI_RELEASE_CHECKSUMS_FILE, input.releaseBaseUrl), + "downloading the t3 release checksums", + ), + ), + ); + const expected = checksums.get(fileName); + if (expected === undefined) { + return yield* new PinnedRuntimeInstallError({ + step: `finding ${fileName} in the t3 release checksums`, + }); + } + return { fileName, expected }; +}); + +const isPinnedRuntimeInstallError = Schema.is(PinnedRuntimeInstallError); + +/** Whether an install failed because the release source does not publish the version for this machine. */ +export const isUnpublishedRelease = (error: unknown): error is PinnedRuntimeInstallError => + isPinnedRuntimeInstallError(error) && + (error.step.startsWith("finding ") || + (error.step.startsWith("downloading the t3 release") && + HttpClientError.isHttpClientError(error.cause) && + error.cause.response?.status === 404)); + const installPinnedRuntime = Effect.fn("cloud.pinned_runtime.ensure_installed")(function* ( input: PinnedRuntimeInstallInput, ) { @@ -265,6 +285,15 @@ const installPinnedRuntime = Effect.fn("cloud.pinned_runtime.ensure_installed")( const alreadyPinned = entryExists && Option.isSome(sentinel) && sentinel.value.trim() === input.version; if (alreadyPinned) { + if (input.requirePublishedRelease === true) { + const platformKey = cliArchivePlatformKey(input.platform, input.arch); + if (platformKey === undefined) { + return yield* new PinnedRuntimeInstallError({ + step: `selecting a t3 release archive for ${input.platform}-${input.arch}`, + }); + } + yield* publishedArchive(input, platformKey); + } input.onProgress?.({ stage: "cached" }); yield* input.validate(paths); return paths; diff --git a/apps/server/src/cloud/selfUpdate.test.ts b/apps/server/src/cloud/selfUpdate.test.ts index 9e1aee9c095aa..a938d6778820f 100644 --- a/apps/server/src/cloud/selfUpdate.test.ts +++ b/apps/server/src/cloud/selfUpdate.test.ts @@ -36,9 +36,10 @@ interface HarnessOptions { } const archiveBytes = new TextEncoder().encode("not really a tarball"); -const releaseHttpClient = (order: string[], beforeDownload?: Effect.Effect) => +const releaseHttpClient = (order: string[], urls: string[], beforeDownload?: Effect.Effect) => HttpClient.make((request) => Effect.gen(function* () { + urls.push(request.url); if (request.url.endsWith("/SHA256SUMS")) { const digest = yield* Effect.promise(() => crypto.subtle.digest("SHA-256", archiveBytes)); const hex = Array.from(new Uint8Array(digest), (byte) => @@ -62,6 +63,7 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* ( const path = yield* Path.Path; const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-self-update-test-" }); const order: string[] = []; + const urls: string[] = []; const runner = ProcessRunner.ProcessRunner.of({ run: (input) => Effect.gen(function* () { @@ -141,7 +143,10 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* ( run: () => Effect.die("unexpected desktop app update run"), }, ), - Effect.provideService(HttpClient.HttpClient, releaseHttpClient(order, options.beforeDownload)), + Effect.provideService( + HttpClient.HttpClient, + releaseHttpClient(order, urls, options.beforeDownload), + ), Effect.provideService(HostProcess.Platform, "linux"), Effect.provideService(HostProcess.Architecture, "x64"), Effect.provide( @@ -151,7 +156,7 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* ( ), ), ); - return { selfUpdate, order }; + return { selfUpdate, order, urls }; }); it.layer(NodeServices.layer)("server self update", (it) => { @@ -198,11 +203,17 @@ it.layer(NodeServices.layer)("server self update", (it) => { it.effect("lets a user start an update, but only to stable without nightly", () => Effect.gen(function* () { - const { selfUpdate, order } = yield* makeHarness({ + const { selfUpdate, order, urls } = yield* makeHarness({ policy: { schemaVersion: 1, organization: "Example Corp", - limits: { updates: { mode: "auto", allowNightly: false } }, + limits: { + updates: { + mode: "auto", + allowNightly: false, + feedUrl: "https://updates.example.com/t3code/", + }, + }, }, }); expect( @@ -210,7 +221,15 @@ it.layer(NodeServices.layer)("server self update", (it) => { .reason, ).toBe("Example Corp manages T3 Code updates. Only stable releases can be installed."); expect(order).toEqual([]); + // The mirror lists only 1.1.0's archive. + expect((yield* selfUpdate.update({ targetVersion: "1.2.0" }).pipe(Effect.flip)).reason).toBe( + "Example Corp's update mirror has no t3@1.2.0 for this machine.", + ); expect((yield* selfUpdate.update({ targetVersion: "1.1.0" })).updateId).toBe("launcher-id"); + // The release comes from the policy's mirror, not GitHub. + expect(urls.length).toBeGreaterThan(0); + for (const url of urls) + expect(url).toMatch(/^https:\/\/updates\.example\.com\/t3code\/v1\.[12]\.0\//); }), ); diff --git a/apps/server/src/cloud/selfUpdate.ts b/apps/server/src/cloud/selfUpdate.ts index 1f285b9b6522e..58baa160adf71 100644 --- a/apps/server/src/cloud/selfUpdate.ts +++ b/apps/server/src/cloud/selfUpdate.ts @@ -31,10 +31,11 @@ import * as ServerConfig from "../config.ts"; import * as DesktopAppUpdate from "../desktopUpdate/DesktopAppUpdate.ts"; import { databaseMigrationHead } from "../persistence/databaseMigrationHead.ts"; import * as ManagedPolicy from "../policy/ManagedPolicy.ts"; -import { managedUpdateRefusal } from "../policy/managedUpdates.ts"; +import { managedUpdateMirror, managedUpdateRefusal } from "../policy/managedUpdates.ts"; import * as ProcessRunner from "../processRunner.ts"; import { ensurePinnedRuntimeInstalled, + isUnpublishedRelease, pinnedRuntimeCommand, PinnedRuntimeInstallError, pinnedRuntimePaths, @@ -132,9 +133,10 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { const platform = yield* HostProcess.Platform; const arch = yield* HostProcess.Architecture; const httpClient = yield* HttpClient.HttpClient; - const releaseBaseUrl = Option.getOrUndefined( - yield* Config.String(CLI_RELEASE_BASE_URL_ENV).pipe(Config.option), - ); + const mirror = managedUpdateMirror(policy.document); + const releaseBaseUrl = + mirror?.feedUrl ?? + Option.getOrUndefined(yield* Config.String(CLI_RELEASE_BASE_URL_ENV).pipe(Config.option)); const inFlight = yield* Ref.make(false); yield* pruneRuntimesOnStart({ baseDir: serverConfig.baseDir, managed: launcher.managed }).pipe( @@ -209,6 +211,7 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { platform, arch, releaseBaseUrl, + requirePublishedRelease: mirror !== undefined, validate: (runtime) => runner .run({ @@ -282,7 +285,12 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { Effect.mapError((error) => error._tag === "PinnedRuntimePreflightBlockedError" ? failWith(error.reason, error) - : failWith(`Could not prepare t3@${targetVersion}.`, error), + : mirror !== undefined && isUnpublishedRelease(error) + ? failWith( + `${mirror.organization}'s update mirror has no t3@${targetVersion} for this machine.`, + error, + ) + : failWith(`Could not prepare t3@${targetVersion}.`, error), ), ); diff --git a/apps/server/src/policy/managedUpdates.ts b/apps/server/src/policy/managedUpdates.ts index 32adc6e4f3812..d805cfa028e2f 100644 --- a/apps/server/src/policy/managedUpdates.ts +++ b/apps/server/src/policy/managedUpdates.ts @@ -5,6 +5,17 @@ import { } from "@t3tools/contracts"; import type { CliReleaseChannel } from "@t3tools/shared/cliRelease"; +/** + * The update mirror the policy sends updates to, or `undefined` for the public releases. It wins + * over the environment, which a user can set. + */ +export function managedUpdateMirror(document: ManagedPolicyDocument | undefined) { + const feedUrl = document?.limits?.updates?.feedUrl; + return feedUrl === undefined || document === undefined + ? undefined + : { organization: document.organization, feedUrl }; +} + /** * Why the managed policy refuses an update a user or maintainer asked for, or `undefined` when it * allows it. The server never updates on its own, so `auto` allows every explicit request. diff --git a/docs/user/managed-policy.md b/docs/user/managed-policy.md index e1ba501e6fa3d..adac234dcb3e8 100644 --- a/docs/user/managed-policy.md +++ b/docs/user/managed-policy.md @@ -249,9 +249,13 @@ starts the binary you set. - `mode`: `auto` keeps the normal update flow. `off` stops T3 Code from checking for updates, for machines that get T3 Code through device management. -- `feedUrl`: the desktop app updates from your own mirror of the release feed instead of GitHub. - Publish only the versions you have approved. The `t3` command-line server updates through npm, - so pin it with your package tooling. +- `feedUrl`: T3 Code updates from your own mirror instead of GitHub: the desktop app, `t3 update`, + and updates started from another device. Publish only the versions you have approved, laid out + as on a GitHub release. The release's update files (`latest.yml`, `latest-mac.yml`, + `latest-linux.yml`, `latest-linux-arm64.yml`, and their `nightly` versions if you allow nightly) + and the desktop installers they list go at the URL's root. The release's `t3-*` archives and + `SHA256SUMS` go under `v/`. A `t3` run through `npx` comes from npm, so pin it with your + package tooling. - `allowNightly: false`: the nightly channel is unavailable. ## What a policy does not cover diff --git a/packages/shared/src/cliRelease.ts b/packages/shared/src/cliRelease.ts index 66777cc044478..80eeec4397545 100644 --- a/packages/shared/src/cliRelease.ts +++ b/packages/shared/src/cliRelease.ts @@ -50,6 +50,22 @@ export function cliReleaseDownloadBaseUrl( return `${(baseUrl?.trim() || CLI_RELEASE_DEFAULT_BASE_URL).replace(/\/+$/, "")}/v${version}`; } +/** `segments` added to `baseUrl`'s path, keeping its query, such as a signed mirror URL's token. */ +export function releaseAssetUrl(baseUrl: string, ...segments: ReadonlyArray): string { + const url = new URL(baseUrl); + url.pathname = [url.pathname.replace(/\/+$/, ""), ...segments.map(encodeURIComponent)].join("/"); + return url.toString(); +} + +/** A file of release `version`, from `baseUrl` laid out as GitHub's release downloads. */ +export function cliReleaseAssetUrl( + version: string, + fileName: string, + baseUrl: string | undefined = CLI_RELEASE_DEFAULT_BASE_URL, +): string { + return releaseAssetUrl(baseUrl?.trim() || CLI_RELEASE_DEFAULT_BASE_URL, `v${version}`, fileName); +} + export function parseChecksums(text: string): ReadonlyMap { const checksums = new Map(); for (const line of text.split(/\r?\n/)) { @@ -86,6 +102,21 @@ export function desktopUpdateChannelOf(version: string): "latest" | "nightly" { return cliReleaseChannelOf(version) === "nightly" ? "nightly" : "latest"; } +/** + * The update feed file a desktop app on this platform reads, which an update mirror publishes at + * its root beside the release's CLI archives. + */ +export function updateFeedFileName( + channel: "latest" | "nightly", + platform: NodeJS.Platform, + arch: string, +): string { + if (platform === "darwin") return `${channel}-mac.yml`; + if (platform === "linux") + return arch === "arm64" ? `${channel}-linux-arm64.yml` : `${channel}-linux.yml`; + return `${channel}.yml`; +} + /** Whether a track switch may install `found` after its database check cleared `checked`: any build * on the switch's track at least as new, since later releases only add migrations. */ export function acceptsTrackSwitchRelease(