From c2c4185e175daea86f8fd6336fd8839a81cc616e Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 6 Sep 2026 14:59:11 -0700 Subject: [PATCH 01/60] fix(web): onboarding installs agents without needing Node or npm (#10402) Co-authored-by: Claude Fable 5.1 --- .../src/provider/providerMaintenance.test.ts | 37 +++++++++++++++++++ .../components/onboarding/WelcomeWizard.tsx | 20 +++++----- .../providerReadiness.logic.test.ts | 21 +++++++++++ .../src/onboarding/providerReadiness.logic.ts | 30 +++++++++++++++ docs/user/welcome-wizard.md | 4 +- 5 files changed, 101 insertions(+), 11 deletions(-) diff --git a/apps/server/src/provider/providerMaintenance.test.ts b/apps/server/src/provider/providerMaintenance.test.ts index 2ceaf21996bf..3e0810f51b7a 100644 --- a/apps/server/src/provider/providerMaintenance.test.ts +++ b/apps/server/src/provider/providerMaintenance.test.ts @@ -25,6 +25,7 @@ import { parseHomebrewLatestVersion, ProviderVersionCache, resolveLatestProviderVersion, + resolvePackageManagedProviderMaintenance, resolveProviderMaintenanceCapabilitiesEffect, type ProviderMaintenanceCapabilities, } from "./providerMaintenance.ts"; @@ -310,6 +311,42 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { ).toBeNull(); }); + // The Codex Windows installer exposes `%LOCALAPPDATA%\\Programs\\OpenAI\\Codex\\bin` + // as a junction into `%CODEX_HOME%\\packages\\standalone\\current\\bin`. Node's + // realpath follows junctions, so the real path carries the standalone marker + // even though the visible path does not. + it.effect("recognizes a Windows standalone install through its junctioned bin dir", () => + Effect.gen(function* () { + const visiblePath = + "C:\\Users\\Theo\\AppData\\Local\\Programs\\OpenAI\\Codex\\bin\\codex.exe"; + const realPath = + "C:\\Users\\Theo\\.codex\\packages\\standalone\\releases\\0.120.0-x86_64\\bin\\codex.exe"; + const capabilities = yield* resolvePackageManagedProviderMaintenance( + { + provider: driver("codex"), + npmPackageName: "@openai/codex", + nativeUpdate: { + args: ["update"], + isCommandPath: isNativeTestCommandPath("/packages/standalone/"), + }, + }, + { + binaryPath: "codex", + resolvedCommandPath: visiblePath, + realCommandPath: realPath, + env: {}, + platform: "win32", + }, + ).pipe(Effect.provideService(HostProcessPlatform, "win32")); + + expect(capabilities.update).toMatchObject({ + executable: visiblePath, + args: ["update"], + lockKey: "codex-native", + }); + }), + ); + it.effect("proves Windows npm ownership from the package manifest beside the shim", () => Effect.gen(function* () { const tempDir = yield* makeTempDir("t3-npm-windows-capabilities"); diff --git a/apps/web/src/components/onboarding/WelcomeWizard.tsx b/apps/web/src/components/onboarding/WelcomeWizard.tsx index 62665aa86ebf..9de7d00c60ca 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.tsx @@ -42,6 +42,7 @@ import { } from "../../onboarding/projectImport.logic"; import { getOnboardingProviderState, + resolveOnboardingProviderInstallCommand, resolveOnboardingProviderLoginCommand, selectOnboardingProvidersByDriver, } from "../../onboarding/providerReadiness.logic"; @@ -641,11 +642,6 @@ function PairDirectStep({ const PRIMARY_AGENT_DRIVERS = ["claudeAgent", "codex"] as const; type OnboardingAgentDriver = (typeof PRIMARY_AGENT_DRIVERS)[number]; -const AGENT_INSTALL_COMMANDS: Record = { - claudeAgent: "npm install -g @anthropic-ai/claude-code", - codex: "npm install -g @openai/codex", -}; - /** Setup values stay fixed while provider probes refresh the surrounding cards. */ interface AgentTerminalSession { readonly environmentId: EnvironmentId; @@ -657,10 +653,11 @@ interface AgentTerminalSession { } /** - * Claude Code and Codex use live probe status. Install opens the built-in terminal inline - * with the command pre-typed — the update RPC can't install a binary that - * isn't there yet (it infers the package manager from the installed binary's - * path), and the terminal also handles the interactive login that follows. + * Claude Code and Codex use live probe status. Install opens the built-in + * terminal inline with the vendor's standalone installer pre-typed. The update + * RPC can't install a binary that isn't there yet (it infers the installer from + * the installed binary's path), and the terminal also handles the interactive + * login that follows. */ function AgentsStep({ mode, @@ -761,7 +758,10 @@ function ConnectedAgentsStep({ serverConfig.settings, serverConfig.environment.platform.os, ) - : AGENT_INSTALL_COMMANDS[driver], + : resolveOnboardingProviderInstallCommand( + driver, + serverConfig.environment.platform.os, + ), keybindings: serverConfig.keybindings, }); }} diff --git a/apps/web/src/onboarding/providerReadiness.logic.test.ts b/apps/web/src/onboarding/providerReadiness.logic.test.ts index ab742ac51b44..b0b3a4d57515 100644 --- a/apps/web/src/onboarding/providerReadiness.logic.test.ts +++ b/apps/web/src/onboarding/providerReadiness.logic.test.ts @@ -8,6 +8,7 @@ import { describe, expect, it } from "vite-plus/test"; import { getOnboardingProviderState, + resolveOnboardingProviderInstallCommand, resolveOnboardingProviderLoginCommand, selectOnboardingProvidersByDriver, } from "./providerReadiness.logic"; @@ -315,3 +316,23 @@ describe("resolveOnboardingProviderLoginCommand", () => { ).toBe("codex login"); }); }); + +describe("resolveOnboardingProviderInstallCommand", () => { + it("uses the PowerShell installer on Windows environments", () => { + expect(resolveOnboardingProviderInstallCommand("codex", "windows")).toBe( + "irm https://chatgpt.com/codex/install.ps1 | iex", + ); + expect(resolveOnboardingProviderInstallCommand("claudeAgent", "windows")).toBe( + "irm https://claude.ai/install.ps1 | iex", + ); + }); + + it.each(["darwin", "linux", "unknown"] as const)("uses the shell installer on %s", (platform) => { + expect(resolveOnboardingProviderInstallCommand("codex", platform)).toBe( + "curl -fsSL https://chatgpt.com/codex/install.sh | sh", + ); + expect(resolveOnboardingProviderInstallCommand("claudeAgent", platform)).toBe( + "curl -fsSL https://claude.ai/install.sh | bash", + ); + }); +}); diff --git a/apps/web/src/onboarding/providerReadiness.logic.ts b/apps/web/src/onboarding/providerReadiness.logic.ts index 939b4c64cd64..c9d0f910ef53 100644 --- a/apps/web/src/onboarding/providerReadiness.logic.ts +++ b/apps/web/src/onboarding/providerReadiness.logic.ts @@ -71,6 +71,36 @@ export function selectOnboardingProvidersByDriver( return providersByDriver; } +/** + * Official standalone installers. Neither needs Node or npm, and both land in + * the paths the server's provider maintenance recognizes as native, so the + * one-click updater in Settings keeps working after install. + */ +const NATIVE_INSTALL_COMMANDS = { + claudeAgent: { + windows: "irm https://claude.ai/install.ps1 | iex", + posix: "curl -fsSL https://claude.ai/install.sh | bash", + }, + codex: { + windows: "irm https://chatgpt.com/codex/install.ps1 | iex", + posix: "curl -fsSL https://chatgpt.com/codex/install.sh | sh", + }, +} as const; + +/** + * Install command for the setup terminal, keyed on the environment's platform + * (not the client's): a Windows desktop driving a WSL server gets the shell + * script. Unknown platforms get the shell script too, since the terminal there + * is a POSIX shell in practice. + */ +export function resolveOnboardingProviderInstallCommand( + driver: keyof typeof NATIVE_INSTALL_COMMANDS, + platform: ExecutionEnvironmentPlatformOs, +): string { + const commands = NATIVE_INSTALL_COMMANDS[driver]; + return platform === "windows" ? commands.windows : commands.posix; +} + /** Use the selected provider instance's binary when the setup terminal opens its login flow. */ export function resolveOnboardingProviderLoginCommand( provider: ServerProvider, diff --git a/docs/user/welcome-wizard.md b/docs/user/welcome-wizard.md index 9a2aa116670d..2534c118eddb 100644 --- a/docs/user/welcome-wizard.md +++ b/docs/user/welcome-wizard.md @@ -26,7 +26,9 @@ unreadable settings with defaults. T3 Code checks the selected computer for Claude Code and Codex. If an agent is not installed or signed in, select its action to open a terminal with the -correct command ready to run. Other providers can be enabled in Settings. +correct command ready to run. Install uses the vendor's standalone installer, +which does not need Node or npm and keeps **Update now** working in Settings. +Other providers can be enabled in Settings. The setup terminal uses the home directory and environment configured for the selected provider instance. Sensitive values remain redacted in Settings and From 7ac93e300ee17a4ee5e92192264fcfd438805b6f Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 6 Sep 2026 15:01:54 -0700 Subject: [PATCH 02/60] fix(server): allow settling threads with unanswered async questions (#10400) --- .../src/orchestration/decider.settled.test.ts | 106 ++++++++++++++++++ apps/server/src/orchestration/decider.ts | 57 +++++++--- docs/user/thread-sidebar.md | 2 + 3 files changed, 151 insertions(+), 14 deletions(-) diff --git a/apps/server/src/orchestration/decider.settled.test.ts b/apps/server/src/orchestration/decider.settled.test.ts index ebca8434c34b..abc5cff37e3f 100644 --- a/apps/server/src/orchestration/decider.settled.test.ts +++ b/apps/server/src/orchestration/decider.settled.test.ts @@ -322,6 +322,112 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => { }), ); + it.effect("manual settlement dismisses async questions without starting a turn", () => + Effect.gen(function* () { + const question = (requestId: string): OrchestrationThread["activities"][number] => ({ + id: EventId.make(requestId), + kind: "user-input.requested", + summary: "Question", + tone: "approval", + turnId: null, + createdAt: "1969-12-31T00:00:00.000Z", + payload: { requestId, responseMode: "message" }, + }); + const readModel = makeReadModel(null, null, makeSession("ready"), [ + question("first"), + question("second"), + question("answered"), + { + ...question("answered"), + id: EventId.make("answer"), + createdAt: "1969-12-31T01:00:00.000Z", + kind: "user-input.resolved", + }, + ]); + const command = { + type: "thread.settle" as const, + commandId: CommandId.make("settle-async"), + threadId: ThreadId.make("thread-1"), + }; + const result = yield* decideOrchestrationCommand({ command, readModel }); + const events = Array.isArray(result) ? result : [result]; + expect(events.map((event) => event.type)).toEqual([ + "thread.settled", + "thread.activity-appended", + "thread.activity-appended", + ]); + expect(events.slice(1).map((event) => event.payload)).toEqual( + ["first", "second"].map((requestId) => ({ + threadId: command.threadId, + activity: expect.objectContaining({ + kind: "user-input.resolved", + summary: "User input dismissed", + payload: { requestId, responseMode: "message" }, + }), + })), + ); + let projected = readModel; + for (const [index, event] of events.entries()) { + projected = yield* projectEvent(projected, { ...event, sequence: index + 1 }); + } + expect(projected.threads[0]?.settledOverride).toBe("settled"); + expect(projected.threads[0]?.messages).toEqual([]); + const repeated = yield* decideOrchestrationCommand({ command, readModel: projected }); + expect(repeated).toMatchObject({ type: "thread.settled" }); + }), + ); + + it.effect("async questions do not bypass automatic settlement or other blockers", () => + Effect.gen(function* () { + const question: OrchestrationThread["activities"][number] = { + id: EventId.make("async-question"), + kind: "user-input.requested", + summary: "Question", + tone: "approval", + turnId: null, + createdAt: NOW, + payload: { requestId: "async-question", responseMode: "message" }, + }; + for (const blocker of ["auto", "running", "starting", "approval", "native"] as const) { + const error = yield* decideOrchestrationCommand({ + command: + blocker === "auto" + ? { + type: "thread.auto-settle", + commandId: CommandId.make(`settle-${blocker}`), + threadId: ThreadId.make("thread-1"), + snapshotSequence: 0, + settledAt: NOW, + } + : { + type: "thread.settle", + commandId: CommandId.make(`settle-${blocker}`), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel( + null, + null, + makeSession(blocker === "running" || blocker === "starting" ? blocker : "ready"), + [ + question, + ...(blocker === "approval" || blocker === "native" + ? [ + { + ...question, + id: EventId.make("blocking-request"), + kind: blocker === "approval" ? "approval.requested" : "user-input.requested", + payload: { requestId: "blocking-request" }, + }, + ] + : []), + ], + ), + }).pipe(Effect.flip); + expect(error).toMatchObject({ _tag: "OrchestrationThreadSettleBlockedError" }); + } + }), + ); + it.effect("clears an open request when its respond failure marks it stale", () => Effect.gen(function* () { const activity = ( diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 9dc55e1194cc..9762d9bfd6da 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -68,10 +68,8 @@ function isStaleRequestFailureDetail(payload: Record | null): b // Scans the read model's activities, which the projector caps at the most // recent 500 plus pending async questions. Async questions remain actionable // while the agent works, so they must not expire with the activity window. -function hasOpenBlockingRequest(thread: { - readonly activities: ReadonlyArray<{ readonly kind: string; readonly payload: unknown }>; -}): boolean { - const openRequestIds = new Set(); +function openRequests(thread: Pick) { + const requests = new Map(); for (const activity of thread.activities) { const payload = typeof activity.payload === "object" && activity.payload !== null @@ -80,18 +78,18 @@ function hasOpenBlockingRequest(thread: { const requestId = typeof payload?.requestId === "string" ? payload.requestId : null; if (requestId === null) continue; if (activity.kind === "approval.requested" || activity.kind === "user-input.requested") { - openRequestIds.add(requestId); + requests.set(requestId, activity); } else if (activity.kind === "approval.resolved" || activity.kind === "user-input.resolved") { - openRequestIds.delete(requestId); + requests.delete(requestId); } else if ( (activity.kind === "provider.approval.respond.failed" || activity.kind === "provider.user-input.respond.failed") && isStaleRequestFailureDetail(payload) ) { - openRequestIds.delete(requestId); + requests.delete(requestId); } } - return openRequestIds.size > 0; + return requests; } /** Apply the shared shell-level rule to the detailed command read model. */ @@ -456,10 +454,18 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" if (thread.session?.status === "starting" || thread.session?.status === "running") { return yield* new OrchestrationThreadSettleBlockedError({ threadId: command.threadId }); } - // Pending approval / user-input requests are blocked-on-you work: a - // raced or stale client must not park them behind a settled override - // that would surface only after the request resolves. - if (hasOpenBlockingRequest(thread)) { + const pendingRequests = openRequests(thread); + // Manual settlement dismisses async questions without answering them. + // Native callbacks and approvals still need a response or interruption. + if ( + Array.from(pendingRequests.values()).some( + (activity) => + command.type === "thread.auto-settle" || + activity.kind !== "user-input.requested" || + !Predicate.isObject(activity.payload) || + activity.payload.responseMode !== "message", + ) + ) { return yield* new OrchestrationThreadSettleBlockedError({ threadId: command.threadId }); } const occurredAt = yield* nowIso; @@ -495,6 +501,29 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" // Settling is "I'm done with this": clear states that would keep the // row pinned or snoozed instead of showing the new settled state. const companionEvents: Array> = []; + for (const [requestId, request] of pendingRequests) { + companionEvents.push({ + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.activity-appended", + payload: { + threadId: command.threadId, + activity: { + id: EventId.make(`settle:${command.commandId}:${requestId}`), + kind: "user-input.resolved", + summary: "User input dismissed", + tone: "info", + turnId: request.turnId, + createdAt: occurredAt, + payload: { requestId, responseMode: "message" }, + }, + }, + }); + } if (thread.pinnedAt != null) { companionEvents.push({ ...(yield* withEventBase({ @@ -581,7 +610,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" // user-input request is the agent waiting on the user, and hiding it // defeats the request. (A running session IS snoozable — snooze only // affects visibility, never the agent.) - if (hasOpenBlockingRequest(thread)) { + if (openRequests(thread).size > 0) { return yield* Effect.fail( new OrchestrationCommandInvariantError({ commandType: command.type, @@ -1453,7 +1482,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" thread.messages.length > 0 || thread.latestTurn !== null || thread.session !== null || - hasOpenBlockingRequest(thread) + openRequests(thread).size > 0 ) { return yield* new OrchestrationCommandInvariantError({ commandType: command.type, diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 4286ed654d81..f20d5acbb179 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -63,6 +63,8 @@ their default order until the server is updated. Choose **Settle thread** from its menu to move finished work out of the active list without deleting the conversation. **Un-settle thread** restores it to active work and prevents automatic settlement until new activity resumes the usual rules. +Manually settling an idle thread dismisses unanswered async questions without +sending an answer or restarting the agent. By default, environments settle inactive threads after three days and settle threads whose pull request merged. A closed pull request can also settle an idle From 001f06d543beb92cc66bb7e8d54d233a20d802b4 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 6 Sep 2026 15:27:21 -0700 Subject: [PATCH 03/60] feat(ci): ship stable releases from the latest nightly commit (#10410) Co-authored-by: Claude Fable 5.1 --- .github/scripts/check-nightly-release.cjs | 43 +++++++--- .../scripts/check-nightly-release.test.cjs | 42 ++++++++++ .github/workflows/release.yml | 81 ++++++++++++------- docs/operations/release.md | 31 ++++--- 4 files changed, 147 insertions(+), 50 deletions(-) diff --git a/.github/scripts/check-nightly-release.cjs b/.github/scripts/check-nightly-release.cjs index dc4b55bc6517..82ee40da1aff 100644 --- a/.github/scripts/check-nightly-release.cjs +++ b/.github/scripts/check-nightly-release.cjs @@ -1,19 +1,21 @@ const MINIMUM_RELEASE_GAP_MS = 6 * 60 * 60 * 1000; -// Runs after the workflow acquires the nightly concurrency lock. -async function shouldReleaseNightly({ github, context, core, now = Date.now() }) { +const isNightlyTag = (tag) => /^v.*-nightly\./.test(tag) || tag.startsWith("nightly-v"); + +// Newest published nightly by publication time, or undefined when none exists. +async function findLatestNightly({ github, context }) { const releases = await github.paginate(github.rest.repos.listReleases, { ...context.repo, per_page: 100, }); - const lastNightly = releases - .filter( - (release) => - !release.draft && - release.published_at && - (/^v.*-nightly\./.test(release.tag_name) || release.tag_name.startsWith("nightly-v")), - ) + return releases + .filter((release) => !release.draft && release.published_at && isNightlyTag(release.tag_name)) .sort((a, b) => Date.parse(b.published_at) - Date.parse(a.published_at))[0]; +} + +// Runs after the workflow acquires the nightly concurrency lock. +async function shouldReleaseNightly({ github, context, core, now = Date.now() }) { + const lastNightly = await findLatestNightly({ github, context }); if (!lastNightly) { core.info("No published nightly found. Proceeding with release."); @@ -41,4 +43,25 @@ async function shouldReleaseNightly({ github, context, core, now = Date.now() }) return true; } -module.exports = { shouldReleaseNightly }; +// Stable releases build the commit the latest nightly shipped, so the stable +// build is one nightly users already ran. Returns the nightly tag, its commit, +// and the stable version that nightly was a preview of. +async function resolveLatestNightlyCommit({ github, context, core }) { + const lastNightly = await findLatestNightly({ github, context }); + if (!lastNightly) { + throw new Error("No published nightly found. Stable releases build the latest nightly commit."); + } + + const tag = lastNightly.tag_name; + // repos.getCommit dereferences annotated tags, so this is the commit either way. + const { data: commit } = await github.rest.repos.getCommit({ ...context.repo, ref: tag }); + const version = /^(?:nightly-)?v(\d+\.\d+\.\d+)-nightly\./.exec(tag)?.[1]; + if (!version) { + throw new Error(`Cannot derive a stable version from nightly tag ${tag}.`); + } + + core.info(`Latest nightly ${tag} shipped ${commit.sha} as a preview of ${version}.`); + return { tag, sha: commit.sha, version }; +} + +module.exports = { shouldReleaseNightly, resolveLatestNightlyCommit }; diff --git a/.github/scripts/check-nightly-release.test.cjs b/.github/scripts/check-nightly-release.test.cjs index 476773bc4e5a..49ade68aeef7 100644 --- a/.github/scripts/check-nightly-release.test.cjs +++ b/.github/scripts/check-nightly-release.test.cjs @@ -99,3 +99,45 @@ for (const status of ["behind", "diverged"]) { assert.equal(await shouldReleaseNightly(options), false); }); } + +const { resolveLatestNightlyCommit } = require("./check-nightly-release.cjs"); + +function nightlyCommitFixture({ releases, commitSha = "abc123" }) { + const refs = []; + const { options } = fixture({ releases }); + options.github.rest.repos.getCommit = async ({ ref }) => { + refs.push(ref); + return { data: { sha: commitSha } }; + }; + return { options, refs }; +} + +test("stable releases resolve the commit of the newest published nightly", async () => { + const { options, refs } = nightlyCommitFixture({ + releases: [ + nightly(10, { tag_name: "v1.0.1-nightly.20260905.100" }), + nightly(1, { tag_name: "v1.0.1-nightly.20260905.123" }), + nightly(0, { tag_name: "v1.0.0" }), + nightly(0, { draft: true, tag_name: "v1.0.1-nightly.20260905.999" }), + ], + commitSha: "deadbeef", + }); + assert.deepEqual(await resolveLatestNightlyCommit(options), { + tag: "v1.0.1-nightly.20260905.123", + sha: "deadbeef", + version: "1.0.1", + }); + assert.deepEqual(refs, ["v1.0.1-nightly.20260905.123"]); +}); + +test("stable releases derive the version from legacy nightly tags", async () => { + const { options } = nightlyCommitFixture({ + releases: [nightly(1, { tag_name: "nightly-v0.9.0-nightly.20260905.5" })], + }); + assert.equal((await resolveLatestNightlyCommit(options)).version, "0.9.0"); +}); + +test("stable releases fail without a published nightly", async () => { + const { options } = nightlyCommitFixture({ releases: [nightly(0, { tag_name: "v1.0.0" })] }); + await assert.rejects(resolveLatestNightlyCommit(options), /No published nightly/); +}); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 48cd451e3fea..882065118478 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,7 +19,7 @@ on: - stable - nightly version: - description: "Release version (for example 1.2.3 or v1.2.3)" + description: "Stable version override (for example 1.2.3). Defaults to the version the latest nightly previewed." required: false type: string @@ -39,33 +39,54 @@ permissions: id-token: none jobs: - check_changes: - name: Check automatic nightly release - if: github.event_name == 'schedule' + # Picks the commit every later job builds. Nightlies and tag pushes build the + # triggering commit. Manual stable releases build the commit of the latest + # published nightly, so stable only ever ships a build that nightly users + # have already run. Scheduled runs also decide here whether a nightly is due. + resolve_commit: + name: Resolve release commit runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 5 outputs: - has_changes: ${{ steps.check.outputs.result }} + ref: ${{ steps.resolve.outputs.ref }} + nightly_version: ${{ steps.resolve.outputs.nightly_version }} + has_changes: ${{ steps.resolve.outputs.has_changes }} steps: - name: Checkout uses: actions/checkout@v6 with: sparse-checkout: .github/scripts - - id: check - name: Check release gap and new commits + - id: resolve + name: Resolve release commit uses: actions/github-script@v8 + env: + DISPATCH_CHANNEL: ${{ inputs.channel }} with: script: | - const { shouldReleaseNightly } = require('./.github/scripts/check-nightly-release.cjs'); - return await shouldReleaseNightly({ github, context, core }); + const { + shouldReleaseNightly, + resolveLatestNightlyCommit, + } = require('./.github/scripts/check-nightly-release.cjs'); + + if (context.eventName === 'schedule') { + core.setOutput('has_changes', await shouldReleaseNightly({ github, context, core })); + core.setOutput('ref', context.sha); + } else if (context.eventName === 'workflow_dispatch' && process.env.DISPATCH_CHANNEL !== 'nightly') { + const { tag, sha, version } = await resolveLatestNightlyCommit({ github, context, core }); + core.notice(`Stable release builds ${sha}, the commit shipped by ${tag}.`); + core.setOutput('ref', sha); + core.setOutput('nightly_version', version); + } else { + core.setOutput('ref', context.sha); + } preflight: name: Preflight - needs: [check_changes] + needs: [resolve_commit] if: | - !failure() && !cancelled() && - (github.event_name != 'schedule' || needs.check_changes.outputs.has_changes == 'true') + needs.resolve_commit.result == 'success' && + (github.event_name != 'schedule' || needs.resolve_commit.outputs.has_changes == 'true') runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 outputs: @@ -78,11 +99,12 @@ jobs: cli_dist_tag: ${{ steps.release_meta.outputs.cli_dist_tag }} is_prerelease: ${{ steps.release_meta.outputs.is_prerelease }} make_latest: ${{ steps.release_meta.outputs.make_latest }} - ref: ${{ github.sha }} + ref: ${{ needs.resolve_commit.outputs.ref }} steps: - name: Checkout uses: actions/checkout@v6 with: + ref: ${{ needs.resolve_commit.outputs.ref }} fetch-depth: 0 sparse-checkout: | /* @@ -104,8 +126,9 @@ jobs: env: DISPATCH_CHANNEL: ${{ github.event.inputs.channel }} DISPATCH_VERSION: ${{ github.event.inputs.version }} + NIGHTLY_VERSION: ${{ needs.resolve_commit.outputs.nightly_version }} NIGHTLY_DATE: ${{ github.run_started_at }} - NIGHTLY_SHA: ${{ github.sha }} + NIGHTLY_SHA: ${{ needs.resolve_commit.outputs.ref }} NIGHTLY_RUN_NUMBER: ${{ github.run_number }} run: | if [[ "${GITHUB_EVENT_NAME}" == "schedule" || ( "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${DISPATCH_CHANNEL:-stable}" == "nightly" ) ]]; then @@ -123,9 +146,9 @@ jobs: echo "make_latest=false" >> "$GITHUB_OUTPUT" else if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then - raw="${DISPATCH_VERSION}" + raw="${DISPATCH_VERSION:-$NIGHTLY_VERSION}" if [[ -z "$raw" ]]; then - echo "workflow_dispatch stable releases require the version input." >&2 + echo "workflow_dispatch stable releases need a version input or a published nightly." >&2 exit 1 fi else @@ -210,14 +233,12 @@ jobs: relay_public_config: name: Resolve T3 Connect public config - # Consumes only the commit SHA, not preflight's resolved version, so it runs - # alongside preflight instead of after it. The condition mirrors preflight's: - # check_changes is skipped on manual and tag releases (skipped is neither failure - # nor success, so success() would be wrong here). - needs: [check_changes] + # Consumes only the release commit, not preflight's resolved version, so it + # runs alongside preflight instead of after it. The condition mirrors preflight's. + needs: [resolve_commit] if: | - !failure() && !cancelled() && - (github.event_name != 'schedule' || needs.check_changes.outputs.has_changes == 'true') + needs.resolve_commit.result == 'success' && + (github.event_name != 'schedule' || needs.resolve_commit.outputs.has_changes == 'true') runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 5 environment: @@ -239,7 +260,7 @@ jobs: - name: Checkout uses: actions/checkout@v6 with: - ref: ${{ github.sha }} + ref: ${{ needs.resolve_commit.outputs.ref }} sparse-checkout: | /* !/.repos/ @@ -312,19 +333,19 @@ jobs: # machine. node-pty is N-API, so one binary works across all WSL Node versions. build_wsl_node_pty: name: Build WSL node-pty (linux-x64) - # Same gating as relay_public_config: only the commit SHA is needed, so this - # runs alongside preflight. See the condition comment there. - needs: [check_changes] + # Same gating as relay_public_config: only the release commit is needed, so + # this runs alongside preflight. See the condition comment there. + needs: [resolve_commit] if: | - !failure() && !cancelled() && - (github.event_name != 'schedule' || needs.check_changes.outputs.has_changes == 'true') + needs.resolve_commit.result == 'success' && + (github.event_name != 'schedule' || needs.resolve_commit.outputs.has_changes == 'true') runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 15 steps: - name: Checkout uses: actions/checkout@v6 with: - ref: ${{ github.sha }} + ref: ${{ needs.resolve_commit.outputs.ref }} sparse-checkout: | /* !/.repos/ diff --git a/docs/operations/release.md b/docs/operations/release.md index 4217c76f1f1e..5097457ae0af 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -8,9 +8,18 @@ This document covers the unified release workflow for stable and nightly desktop - Workflow: `.github/workflows/release.yml` - Triggers: - - push tag matching `v*.*.*` for stable releases + - manual `workflow_dispatch` with `channel=stable`, the normal way to ship stable + - push tag matching `v*.*.*` for a stable release of an explicit commit - scheduled nightly check every 30 minutes - - manual `workflow_dispatch` for either channel + - manual `workflow_dispatch` with `channel=nightly` +- A manual stable release builds the commit of the latest published nightly, not `main` HEAD. + Nightly is the release candidate: verify the nightly, then promote it. Merges to `main` keep + landing while you verify and never leak into the stable build. + - The version defaults to the one the nightly previewed (`0.0.39-nightly.*` ships as `0.0.39`). + Pass the `version` input to override it, for example for a minor bump. + - The stable tag is created on the nightly's commit when the GitHub Release is published. + - Pushing a `vX.Y.Z` tag by hand still works and builds exactly the tagged commit. Use it when + the commit to ship is not the latest nightly, such as a cherry-picked fix on a release branch. - Runs lint, typecheck, and tests alongside artifact builds. Publishing waits for every check. - Reads the shared production T3 Connect relay URL and Clerk client configuration before packaging clients. - Builds four artifacts in parallel for both channels: @@ -291,8 +300,8 @@ risk, manually dispatch `channel=nightly`; this still publishes a real nightly n prerelease, desktop updater release, and hosted nightly alias, but it does not update stable aliases or commit a version bump to `main`. Only run it when a real nightly release is acceptable. -Manual `channel=stable` with a version input is also a real stable-channel release. Omitting signing -secrets only makes platform artifacts unsigned; it does not prevent publication. +Manual `channel=stable` is also a real stable-channel release. Omitting signing secrets only makes +platform artifacts unsigned; it does not prevent publication. ## 2) Apple signing + notarization setup (macOS) @@ -370,17 +379,19 @@ Checklist: ## 4) Ongoing release checklist -1. Ensure `main` is green in CI. -2. Bump app version as needed. -3. Create release tag: `vX.Y.Z`. -4. Push tag. -5. Verify workflow steps: +1. Pick the latest nightly and verify it: run the smoke test above against its artifacts and + check the nightly channel for regressions. +2. Dispatch the Release workflow with `channel=stable`. Leave `version` empty unless the version + should differ from the one the nightly previewed. +3. Confirm the `Resolve release commit` notice names the nightly tag and commit you verified. If a + newer nightly published in between, the run builds that one instead. +4. Verify workflow steps: - preflight passes - release quality checks pass - all matrix builds pass - `publish_cli` publishes the exact release version before the release job - release job uploads expected files -6. Smoke test downloaded artifacts. +5. Smoke test downloaded artifacts. ## 5) Troubleshooting From 075a86e3b0152ea8f867ddd2c424739f5da08238 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 6 Sep 2026 15:29:20 -0700 Subject: [PATCH 04/60] feat(marketing): add a nightly channel to the download page (#10408) Co-authored-by: Claude Fable 5.1 --- apps/marketing/public/nightly-sky.svg | 44 ++ apps/marketing/src/assets/icon-nightly.webp | Bin 0 -> 31614 bytes apps/marketing/src/layouts/Layout.astro | 14 + apps/marketing/src/lib/releases.ts | 37 +- apps/marketing/src/pages/download.astro | 552 +++++++++++++++----- 5 files changed, 496 insertions(+), 151 deletions(-) create mode 100644 apps/marketing/public/nightly-sky.svg create mode 100644 apps/marketing/src/assets/icon-nightly.webp diff --git a/apps/marketing/public/nightly-sky.svg b/apps/marketing/public/nightly-sky.svg new file mode 100644 index 000000000000..3165b211b287 --- /dev/null +++ b/apps/marketing/public/nightly-sky.svg @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/marketing/src/assets/icon-nightly.webp b/apps/marketing/src/assets/icon-nightly.webp new file mode 100644 index 0000000000000000000000000000000000000000..8a00067e43b81b5be5b07672f302e24cbf8a9cb9 GIT binary patch literal 31614 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 literal 0 HcmV?d00001 diff --git a/apps/marketing/src/layouts/Layout.astro b/apps/marketing/src/layouts/Layout.astro index 4c95bc86560e..a06521986ea0 100644 --- a/apps/marketing/src/layouts/Layout.astro +++ b/apps/marketing/src/layouts/Layout.astro @@ -80,6 +80,7 @@ const canonicalUrl = new URL(Astro.url.pathname, Astro.site); T3 Code