diff --git a/.agents/skills/test-t3-app/SKILL.md b/.agents/skills/test-t3-app/SKILL.md index 06fe9867b15ae..6fde6fff30984 100644 --- a/.agents/skills/test-t3-app/SKILL.md +++ b/.agents/skills/test-t3-app/SKILL.md @@ -21,6 +21,19 @@ Test with meaningful project and thread data. Read [references/sqlite-fixtures.md](references/sqlite-fixtures.md) only when inspecting or seeding SQLite. Stop the test server before direct fixture writes. +## Drive agent turns for free + +Do not spend a paid model to see a turn render. `vp run dev` registers the +`Replay (dev)` provider, which replays recorded turns with simulated streaming +through the real adapter and orchestration. Its models are scenarios: +`showcase` covers thinking, a plan, tool calls, an approval, and markdown. +Press `Ctrl+Alt+Shift+R` for a picker (GNOME takes that key for screen +recording), call `window.t3dev.replay("showcase")` +with `preview_evaluate`, or run +`node apps/server/src/bin.ts thread start --base-dir .t3 --provider devReplay --model showcase "go"`. +When the test needs real provider behavior, use that provider's cheapest +model, such as Haiku for Claude or Luna for Codex. + ## Use the Browser panel Call `preview_status`, then `preview_open` if the Browser panel is diff --git a/.agents/skills/test-t3-mobile/SKILL.md b/.agents/skills/test-t3-mobile/SKILL.md index b2f5443e431a1..f48bb0377ad94 100644 --- a/.agents/skills/test-t3-mobile/SKILL.md +++ b/.agents/skills/test-t3-mobile/SKILL.md @@ -31,11 +31,21 @@ when inspecting or seeding SQLite. Stop the test server before fixture writes. From the checkout being tested on the selected device host, run: ```bash -node scripts/mobile-native-client.ts ensure +vp run mobile:install ``` -This reuses a matching native client or builds and installs one. Authorized -mobile verification includes that build step unless the user prohibits it. +This installs the dev client for the checkout's native fingerprint: from the +device, the local cache, or the shared cache in seconds, building once only +when no machine has that fingerprint yet (5-15 minutes, logged to the file it +names). The device id may be omitted when exactly one device is booted. It +prints one JSON line; on failure it names the next command. Mobile verification +includes that step unless the user prohibits it. + +For a device you opened in an agent-device session elsewhere (a device pool, +cloud, or remote host), run `vp run mobile:install --session ` +from the directory you opened the session in. It installs the cached build into +that session (`--abi arm64-v8a` for an ARM Android device; `--metro ` also +opens it against Metro) and never builds. Start `vp run dev:client` from `apps/mobile`, or reuse a healthy Metro belonging to this checkout. Open its printed development-client URL with AgentDevice diff --git a/.coderabbit.config.ts b/.coderabbit.config.ts index 06983eaa71fd1..0b7d0182e6df7 100644 --- a/.coderabbit.config.ts +++ b/.coderabbit.config.ts @@ -26,6 +26,11 @@ export default defineConfig((ctx) => ({ path: "apps/web/src/**/*.{tsx,css}", instructions: "Hold changed code to the rules in docs/internals/web-ui.md.", }, + { + path: "**/*.test.*", + instructions: + "Hold changed tests to the rules in docs/internals/testing.md. Request tests only as those rules allow.", + }, ], }, knowledge_base: { @@ -33,6 +38,7 @@ export default defineConfig((ctx) => ({ filePatterns: [ { files: "docs/internals/effect-services.md", applyTo: "{apps,packages,infra}/**/*.ts" }, { files: "docs/internals/web-ui.md", applyTo: "apps/web/src/**/*.{tsx,css}" }, + { files: "docs/internals/testing.md", applyTo: "{apps,packages,infra}/**/*.{ts,tsx}" }, ], }, }, diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 5880459d75a12..144879ecdb565 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,4 +1,4 @@ -// Dev container for T3 Code. Mirrors CI (ubuntu-24.04, Node 24, Rust stable) +// Dev container for T3 Code. Mirrors CI (ubuntu-24.04, Node 26, Rust stable) // and the canonical setup in docs/operations/development.md: global `vp`, `vp i`. // Contributor doc: docs/internals/devcontainer.md { @@ -8,7 +8,7 @@ // nodeGypDependencies (default true) brings python3/make/g++, which Linux // needs for node-pty's node-gyp fallback (its prebuilds are mac/win only). "ghcr.io/devcontainers/features/node:2": { - "version": "24" + "version": "26" }, // native/resource-monitor (edition 2024, needs stable >= 1.85). The server // degrades gracefully without the binary, but CI checks cargo fmt + test. diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 353a1f41a2616..1c4ae817060a9 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -59,6 +59,7 @@ github:myacoub91 github:naMqe-h github:nateEc github:naveed949 +github:nexxeln github:nmggithub github:Noojuno github:notkainoa diff --git a/.github/actions/setup-ci-deps/action.yml b/.github/actions/setup-ci-deps/action.yml index d53722f57dd75..e9b7ae70a36ad 100644 --- a/.github/actions/setup-ci-deps/action.yml +++ b/.github/actions/setup-ci-deps/action.yml @@ -1,51 +1,153 @@ name: Setup CI dependencies description: Install Vite+ and the workspace dependencies from cache. +inputs: + task-cache-read-token: + description: Read-only credential for the private maintainer task cache + required: false + default: "" + install: + description: Set to "false" to set up Vite+ only, with no pnpm store or install + required: false + default: "true" + install-args: + description: Arguments for `vp install`, such as filters. Empty installs the whole workspace. + required: false + default: "" runs: using: composite steps: - # setup-vp reuses an installed exact version, so restoring its home skips the - # installer download on every job. + # ~/.vite-plus holds vp, the Node.js runtime, and pnpm. A cache entry never + # updates, so the key names all three versions; otherwise a pnpm or Node.js + # bump makes every job download them from npm again. - name: Resolve Vite+ version id: vp shell: bash run: | echo "version=$(sed -nE 's/^ vite-plus: ([0-9][^ ]*)$/\1/p' pnpm-workspace.yaml)" >> "$GITHUB_OUTPUT" + echo "node=$(jq -r '.engines.node' package.json)" >> "$GITHUB_OUTPUT" + echo "pnpm=$(jq -r '.packageManager' package.json)" >> "$GITHUB_OUTPUT" + # Pinned because release-desktop.yml signs builds with this action in the job. - name: Cache Vite+ + id: vp_cache if: steps.vp.outputs.version != '' - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: ~/.vite-plus - key: vite-plus-home-v1-${{ runner.os }}-${{ runner.arch }}-${{ steps.vp.outputs.version }} + key: vite-plus-home-v2-${{ runner.os }}-${{ runner.arch }}-${{ steps.vp.outputs.version }}-node${{ steps.vp.outputs.node }}-${{ steps.vp.outputs.pnpm }} + # setup-vp always runs the installer, which fetches vp from npm even when + # this exact version is installed. A cache hit already has everything, so + # skip setup-vp and do its remaining work here. This keeps npm outages from + # failing jobs. + - name: Use cached Vite+ + if: steps.vp_cache.outputs.cache-hit == 'true' + shell: bash + run: | + echo "$HOME/.vite-plus/bin" >> "$GITHUB_PATH" + "$HOME/.vite-plus/bin/vp" env use "${{ steps.vp.outputs.node }}" + + # vp gives each Node.js download attempt 10 minutes by default, so one + # stalled stream holds the job that long. It retries 3 times and resumes + # partial bytes, so a shorter limit costs a slow download only a retry. - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + if: steps.vp_cache.outputs.cache-hit != 'true' + uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1 + env: + VP_DOWNLOAD_TIMEOUT: "120" with: node-version-file: package.json cache: false run-install: false - - name: Resolve pnpm store path + # A shared key lets whichever install saves first, often a small filtered + # one, become every job's store. Each set of install args gets its own. + - name: Resolve pnpm store id: pnpm_store + if: inputs.install == 'true' shell: bash + env: + INSTALL_ARGS: ${{ inputs.install-args }} run: | - store_path="$(vp pm cache dir)" - echo "path=$store_path" >> "$GITHUB_OUTPUT" + echo "path=$(vp pm cache dir)" >> "$GITHUB_OUTPUT" + metadata_dir="${pnpm_config_cache_dir:-$RUNNER_TEMP/pnpm-metadata}" + echo "pnpm_config_cache_dir=$metadata_dir" >> "$GITHUB_ENV" + echo "verified=$metadata_dir/lockfile-verified.jsonl" >> "$GITHUB_OUTPUT" + if [[ -z "$INSTALL_ARGS" ]]; then + echo "namespace=full" >> "$GITHUB_OUTPUT" + else + echo "namespace=filtered-$(printf '%s' "$INSTALL_ARGS" | cksum | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" + fi - # setup-vp's shared cache key ignores install filters. Keep full installs in a - # separate namespace, including fallback restores. + # Filtered installs prefer the current full store, which holds every package, + # over an older store of their own. # The pnpm store stays a cache tarball on the runner's own disk. A store on a # Blacksmith sticky disk mounts in under a second, but pnpm then copies every # package across filesystems instead of hard-linking it: install took 28-49s # against 9s plus an 11s restore. - - name: Cache full pnpm store - uses: actions/cache@v6 + - name: Restore pnpm store + id: pnpm_cache + if: inputs.install == 'true' + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: ${{ steps.pnpm_store.outputs.path }} - key: pnpm-full-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', 'package.json') }} + key: pnpm-${{ steps.pnpm_store.outputs.namespace }}-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', 'package.json') }} restore-keys: | + pnpm-full-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', 'package.json') }} + pnpm-${{ steps.pnpm_store.outputs.namespace }}-v1-${{ runner.os }}-${{ runner.arch }}- pnpm-full-v1-${{ runner.os }}-${{ runner.arch }}- + # pnpm accepts an earlier pass for the same lockfile and policy at any age, so + # a cached record skips fetching registry metadata for every package. + - name: Restore lockfile verification + id: pnpm_verified + if: inputs.install == 'true' + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: ${{ steps.pnpm_store.outputs.verified }} + key: pnpm-verified-v1-${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', 'package.json') }} + - name: Install dependencies + if: inputs.install == 'true' shell: bash - run: vp install + env: + INSTALL_ARGS: ${{ inputs.install-args }} + # Unquoted so each filter is its own argument. + run: vp install $INSTALL_ARGS + + - name: Save lockfile verification + if: inputs.install == 'true' && steps.pnpm_verified.outputs.cache-hit != 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: ${{ steps.pnpm_store.outputs.verified }} + key: ${{ steps.pnpm_verified.outputs.cache-primary-key }} + + # A filtered install that started from the current full store adds nothing + # worth a second copy of it. + - name: Save pnpm store + if: >- + inputs.install == 'true' && + steps.pnpm_cache.outputs.cache-hit != 'true' && + (steps.pnpm_store.outputs.namespace == 'full' || + steps.pnpm_cache.outputs.cache-matched-key != format('pnpm-full-v1-{0}-{1}-{2}', runner.os, runner.arch, hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', 'package.json'))) + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: ${{ steps.pnpm_store.outputs.path }} + key: ${{ steps.pnpm_cache.outputs.cache-primary-key }} + + - name: Enable shared task cache + if: >- + github.repository == 'pingdotgg/t3code' && + (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && + hashFiles('.task-cache.json') != '' && + inputs.task-cache-read-token != '' + shell: bash + env: + T3_TASK_CACHE_READ_TOKEN: ${{ inputs.task-cache-read-token }} + run: | + node infra/task-cache/scripts/install-ci.ts + if [[ ( "$GITHUB_EVENT_NAME" == pull_request && "$GITHUB_BASE_REF" == main ) || ( "$GITHUB_EVENT_NAME" == push && "$GITHUB_REF" == refs/heads/main ) ]]; then + echo 'VP_REMOTE_CACHE=read-write' >> "$GITHUB_ENV" + else + echo 'VP_REMOTE_CACHE=read' >> "$GITHUB_ENV" + fi diff --git a/.github/scripts/ci-changes.cjs b/.github/scripts/ci-changes.cjs index d7ebb1a0790cd..ed32670755ff6 100644 --- a/.github/scripts/ci-changes.cjs +++ b/.github/scripts/ci-changes.cjs @@ -1,21 +1,10 @@ -// Decides which CI jobs a change needs. Prints `key=value` lines for -// $GITHUB_OUTPUT; a job runs unless its key is "false". -// -// node ci-changes.cjs --paths gate by the listed paths -// node ci-changes.cjs --paths --full run every Linux job, gate only the macOS lint -// node ci-changes.cjs changed paths unknown: run everything -// -// scripts/check-changed.ts requires the same decisions so local runs match CI. -// -// Every changed path maps to workspace packages, which then pull in their -// dependents from package.json. A path this file does not recognize runs -// everything, so a new root file or directory can only cost minutes. +// Unknown paths run every job; recognized packages pull in their dependents. +// scripts/check-changed.ts shares these decisions so local checks match CI. const fs = require("node:fs"); const path = require("node:path"); const root = path.resolve(__dirname, "../.."); -// Never read by a build, typecheck, or test. Lint still formats them. const INERT = [ /^docs\//, /^\.(agents|cursor|vscode|devcontainer|macroscope|repos)\//, @@ -23,15 +12,13 @@ const INERT = [ /^LICENSE$/, ]; -// Run everything: the root vite.config.ts loads the first as setup for every -// package's tests, and the vendored mobile tarballs are installed dependencies. +// Shared test setup and vendored mobile dependencies affect every package. const RUNS_EVERYTHING = [ - /^packages\/shared\/src\/(HostProcess|testing\/longTempDir)\.ts$/, + /^packages\/shared\/src\/(HostProcess|testing\/(longTempDir|providerEnv))\.ts$/, /^apps\/mobile\/deps\//, ]; -// Paths outside a package, or files inside one that other packages read -// through relative imports rather than a package.json dependency. +// Includes cross-package reads absent from package.json dependencies. const EXTRA_OWNERS = [ // Vite configs and tsconfigs of these apps import scripts/lib directly. [/^scripts\/lib\//, ["@t3tools/desktop", "@t3tools/mobile", "@t3tools/web", "t3"]], @@ -40,13 +27,15 @@ const EXTRA_OWNERS = [ [/^native\/browser-secret\//, ["@t3tools/desktop"]], [/^native\/kde-snap-shot\//, ["@t3tools/desktop"]], [/^native\/libghostty-vt\//, ["@t3tools/web"]], + // apps/server/src/cloud/ConnectReachability.test.ts checks the guide's headings. + [/^docs\/user\/t3-connect-troubleshooting\.md$/, ["t3"]], ]; const RUST = /^native\/(resource-monitor|kde-snap-shot|hyprland-snap-shot)\//; -// Tests that read another package's files without declaring it. The scripts -// tests read app manifests, assets, and sources (cursor-sdk-packaging, -// third-party-licenses, brand-assets, build-desktop-artifact). +// These tests read other packages without declaring dependencies. const TEST_ONLY_DEPS = { + // Evals import the server's source to test its real model-facing text. + "@t3tools/evals": ["t3"], "@t3tools/provider-pi": ["@t3tools/client-runtime"], "@t3tools/scripts": [ "@t3tools/desktop", @@ -56,17 +45,12 @@ const TEST_ONLY_DEPS = { "@t3tools/provider-cursor", ], }; -// Declared dependencies that only feed a build step: the server bundles the -// built web app (apps/server/vite.config.ts) but never imports it. +// The server bundles the web build without importing it. const BUILD_ONLY_DEPS = { t3: ["@t3tools/web"] }; -// Sources scripts/mobile-native-static-check.ts lints, plus the tool and rule -// configuration that decides how it lints them, plus the root package.json -// that defines the lint:mobile command. const MOBILE_NATIVE = /^apps\/mobile\/.*\.(swift|kt|kts)$|^apps\/mobile\/(\.swiftlint\.yml|detekt\.yml|\.editorconfig|Brewfile)$|^scripts\/mobile-native-static-check\.ts$|^package\.json$|^\.github\/workflows\/ci\.yml$|^\.github\/scripts\/ci-changes\.cjs$|^\.github\/actions\/ci-changes\//; -// Jobs that test every package except these, which have jobs of their own. const OWN_TEST_JOB = new Set(["t3", "@t3tools/web", "@t3tools/monorepo"]); function readWorkspace() { @@ -108,14 +92,13 @@ function readWorkspace() { return packages; } -// Returns the packages a path touches, [] for an inert path, or null when the -// path is not understood and everything must run. +// [] means inert; null means unknown and requires every job. function ownersOf(file, packages) { - if (INERT.some((pattern) => pattern.test(file))) return []; + const extra = EXTRA_OWNERS.flatMap(([pattern, names]) => (pattern.test(file) ? names : [])); + if (INERT.some((pattern) => pattern.test(file))) return extra; // A manifest can change dependencies or versions other packages read. if (path.posix.basename(file) === "package.json") return null; if (RUNS_EVERYTHING.some((pattern) => pattern.test(file))) return null; - const extra = EXTRA_OWNERS.flatMap(([pattern, names]) => (pattern.test(file) ? names : [])); // scripts/build-desktop-artifact.test.ts reads the crates' sources. if (RUST.test(file)) return ["@t3tools/scripts", ...extra]; @@ -148,7 +131,6 @@ function decide(files, packages, full) { if (everything) { affected = all; } else { - // Add dependents until nothing changes. let grew = true; while (grew) { grew = false; diff --git a/.github/scripts/mirror-play-apk.cjs b/.github/scripts/mirror-play-apk.cjs index 90b368b2aa036..d017fc907f124 100644 --- a/.github/scripts/mirror-play-apk.cjs +++ b/.github/scripts/mirror-play-apk.cjs @@ -1,7 +1,4 @@ -// Publishes closed-testing betas, or downloads the internal APK for a normal -// nightly release. Play generates and signs the APK from the -// uploaded bundle with the app signing key, so it carries the same signature -// as the Play Store install. Run by .github/workflows/mobile-android-apk.yml. +// Play signs generated APKs with the app signing key, matching Play Store installs. const { execFileSync } = require("node:child_process"); const crypto = require("node:crypto"); const fs = require("node:fs"); @@ -50,10 +47,7 @@ async function play(token, method, url) { return response; } -// Highest version code the track serves, with its release name. Play names a -// release after the bundle's versionName unless the uploader sets one. This -// endpoint reads without opening an edit; an edit would invalidate any open -// EAS Submit edit made with the same service account. +// Opening an edit would invalidate any EAS Submit edit using the same service account. async function newestTrackBuild(token, appUrl, trackName) { const { releases = [] } = await ( await play(token, "GET", `${appUrl}/tracks/${trackName}/releases`) @@ -82,8 +76,7 @@ function readRelease(tag) { } } -// Download to a temporary directory so a failed request cannot leave a partial -// APK among the release assets. +// Stage downloads so failed requests cannot leave partial release assets. async function downloadApk({ token, appUrl, build, apkName }) { const generated = await ( await play(token, "GET", `${appUrl}/generatedApks/${build.versionCode}`) diff --git a/.github/scripts/stage-preview-bundle.py b/.github/scripts/stage-preview-bundle.py index 0b3a53285916b..1730ee5c8d752 100644 --- a/.github/scripts/stage-preview-bundle.py +++ b/.github/scripts/stage-preview-bundle.py @@ -12,8 +12,7 @@ "server/dist/client/index.html", "desktop/dist-electron/main.cjs", } -# The current bundle is about 32 MiB compressed. Bound extraction on the -# trusted runner even when the PR replaces the uploader entirely. +# Bound trusted-runner extraction even when a PR replaces the uploader. MAX_ARCHIVE_BYTES = 512 * 1024 * 1024 MAX_EXPANDED_BYTES = 2 * 1024 * 1024 * 1024 MAX_ENTRIES = 50_000 @@ -58,9 +57,7 @@ def stage_bundle(archive: Path, destination: Path): files.add(name) if not REQUIRED_FILES <= files: raise ValueError("Preview bundle is missing required entry points") - # Validate all names before writing anything. This is a fresh directory - # outside the checkout; neither pre-existing links nor trusted files - # can be followed or overwritten. ZIP permissions are never restored. + # Validate before writing into a fresh directory so existing links/files cannot be followed or overwritten. destination.mkdir(parents=True, exist_ok=False) for entry in entries: target = destination / entry.filename diff --git a/.github/scripts/thread-transfer-report.cjs b/.github/scripts/thread-transfer-report.cjs index 5ed6a6ff498a9..09b25131e4c48 100644 --- a/.github/scripts/thread-transfer-report.cjs +++ b/.github/scripts/thread-transfer-report.cjs @@ -294,25 +294,34 @@ async function resolve({ github, context, core }) { const sourceArtifacts = await artifactsForRun(github, owner, repo, source.id); const sourceArtifact = findResultArtifact(sourceArtifacts); - // CI requires the artifact whenever the server tests run, so a passing run - // without one skipped them because the PR cannot affect the server. + // Successful server tests require this artifact; its absence means CI skipped them. if (!sourceArtifact && source.conclusion === "success") { core.info("CI skipped the server tests for this change; nothing to report."); core.setOutput("publish", "false"); return; } - const workflowRuns = await github.paginate(github.rest.actions.listWorkflowRuns, { - owner, - repo, - workflow_id: source.workflow_id, - branch: pull.base.ref, - event: "push", - status: "success", - per_page: 100, - }); + // One page each for the exact base and the newest runs; paging through + // every successful main run costs up to ten requests to keep 20. + const listRuns = async (filters) => { + const { data } = await github.rest.actions.listWorkflowRuns({ + owner, + repo, + workflow_id: source.workflow_id, + branch: pull.base.ref, + event: "push", + status: "success", + per_page: 20, + ...filters, + }); + return data.workflow_runs; + }; + const [baseRuns, recentRuns] = await Promise.all([ + listRuns({ head_sha: pull.base.sha }), + listRuns({}), + ]); const orderedRuns = [ - ...workflowRuns.filter((run) => run.head_sha === pull.base.sha), - ...workflowRuns.filter((run) => run.head_sha !== pull.base.sha), + ...baseRuns, + ...recentRuns.filter((run) => run.head_sha !== pull.base.sha), ].slice(0, 20); let baselineRun; diff --git a/.github/scripts/thread-transfer-report.test.cjs b/.github/scripts/thread-transfer-report.test.cjs index 753c3eb759072..8c4595ccf0c6a 100644 --- a/.github/scripts/thread-transfer-report.test.cjs +++ b/.github/scripts/thread-transfer-report.test.cjs @@ -55,7 +55,18 @@ test("validates the fixed artifact schema", () => { test("resolves a fallback PR with a redacted head repo and exact main baseline", async () => { const outputs = {}; const listWorkflowRunArtifacts = () => {}; - const listWorkflowRuns = () => {}; + // The exact base run sits far past the newest page of main CI runs. + const mainRuns = Array.from({ length: 1_000 }, (_, index) => ({ + id: 1_000 - index, + head_sha: index === 900 ? "base-sha" : `main-sha-${index}`, + })); + const listWorkflowRuns = async ({ head_sha, per_page }) => ({ + data: { + workflow_runs: mainRuns + .filter((run) => head_sha === undefined || run.head_sha === head_sha) + .slice(0, per_page), + }, + }); const listPullRequestsAssociatedWithCommit = () => {}; const github = { paginate: async (method, input) => { @@ -77,9 +88,6 @@ test("resolves a fallback PR with a redacted head repo and exact main baseline", }, ]; } - if (method === listWorkflowRuns) { - return [{ id: 1, head_sha: "base-sha" }]; - } throw new Error("unexpected pagination call"); }, rest: { @@ -123,7 +131,7 @@ test("resolves a fallback PR with a redacted head repo and exact main baseline", assert.equal(outputs.publish, "true"); assert.equal(outputs.pull_number, "5350"); assert.equal(outputs.pr_artifact, "true"); - assert.equal(outputs.baseline_run_id, "1"); + assert.equal(outputs.baseline_run_id, "100"); assert.equal(outputs.baseline_matches_base, "true"); }); diff --git a/.github/triage/PLAYBOOK.md b/.github/triage/PLAYBOOK.md index 13ca38fed4ad6..cc922de448005 100644 --- a/.github/triage/PLAYBOOK.md +++ b/.github/triage/PLAYBOOK.md @@ -11,8 +11,8 @@ provided alongside this playbook. Everything machine-specific lives there, not h ## 1. Ask what went wrong -Your first message to the user: ask them to describe what went wrong, in their own -words. Ask them to paste screenshots directly into this session if they have any. +Unless the user already described it, your first message asks what went wrong, in +their own words. Ask them to paste screenshots directly into this session if they have any. Ask follow-up questions when the description is vague. Good repro steps are the most valuable thing you can extract from this conversation. @@ -125,10 +125,16 @@ of `main` for that work, never the tag-pinned diagnosis clone. - Show the user the complete final issue text and get an explicit yes before posting. Never post without it. - Note at the end of the issue which model and agent produced it. -- If `gh` is not authenticated, offer `gh auth login`, or build a prefilled - https://github.com/pingdotgg/t3code/issues/new URL with title and body query - parameters; print the URL, and open it in their browser only after they - approve. +- Post with `gh issue create --repo pingdotgg/t3code --label via-triage` only + if `gh api repos/pingdotgg/t3code --jq .permissions.triage` prints `true`. +- Otherwise, or if `gh` is not authenticated, build a prefilled + https://github.com/pingdotgg/t3code/issues/new?template=via-triage.yml URL. + The form applies the label whoever submits it. Add `title` plus one + URL-encoded query parameter per form field id: `what-happened`, `diagnosis`, + `steps`, `version`, `environment`, `evidence`, `related`, `workaround` and + `agent`. Keep the full encoded URL below 6,000 characters by shortening any + fields as needed. Give the user any omitted text to paste into the form before + submitting. Print the URL, and open it in their browser only after they approve. - If the user pasted screenshots, remind them to drag the images into the issue after it is created; they cannot be attached from here. diff --git a/.github/workflows/agent-context-budget.yml b/.github/workflows/agent-context-budget.yml new file mode 100644 index 0000000000000..41efb7b69a5a2 --- /dev/null +++ b/.github/workflows/agent-context-budget.yml @@ -0,0 +1,55 @@ +name: Agent Context Budget + +# Fails when a PR raises a value in agentContextBudget.json above main's. AgentContextBudget.test.ts +# keeps that file equal to the measured context, so the context cannot grow by editing the file. +# To accept growth on purpose, a maintainer adds the agent-context-increase label. +on: + pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] + +permissions: + contents: read + +concurrency: + group: agent-context-budget-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + budget: + name: Agent context budget + runs-on: ubuntu-24.04 + timeout-minutes: 5 + env: + BUDGET: apps/server/src/provider/agentContextBudget.json + ALLOW_INCREASE: ${{ contains(github.event.pull_request.labels.*.name, 'agent-context-increase') }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + sparse-checkout: apps/server/src/provider/agentContextBudget.json + sparse-checkout-cone-mode: false + + - name: Compare with main + run: | + git fetch --quiet --depth=1 origin main + if ! git cat-file -e "FETCH_HEAD:$BUDGET" 2>/dev/null; then + echo "main has no agent context budget yet." + exit 0 + fi + git show "FETCH_HEAD:$BUDGET" > "$RUNNER_TEMP/main.json" + grown=$(jq -r --slurpfile main "$RUNNER_TEMP/main.json" ' + to_entries[] + | select($main[0][.key] != null and .value > $main[0][.key]) + | "\(.key): \($main[0][.key]) -> \(.value) (+\(.value - $main[0][.key]))" + ' "$BUDGET") + if [ -z "$grown" ]; then + echo "Agent context is not larger than main." + exit 0 + fi + printf 'Agent context grew compared to main:\n%s\n' "$grown" + if [ "$ALLOW_INCREASE" = "true" ]; then + echo "Accepted by the agent-context-increase label." + exit 0 + fi + echo "::error file=$BUDGET::Agent context grew compared to main. Cut the added text and rerun the evals that cover it (evals/README.md), or ask a maintainer to add the agent-context-increase label." + exit 1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b31de38d203a..6aa0d039fe9b8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,6 +58,9 @@ jobs: # file, docs included. The `Check` job at the end of this file passes only # when every other job passes or was skipped by `changes`. lint: + permissions: + contents: read + id-token: write name: Lint runs-on: ubuntu-24.04 timeout-minutes: 25 @@ -81,6 +84,8 @@ jobs: - name: Setup Vite+ uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} # electron's install.js (run by ensure:electron) reads this cache before it # downloads from GitHub. The key changes only with the Electron version. @@ -107,6 +112,9 @@ jobs: run: vp check build: + permissions: + contents: read + id-token: write name: Build and Typecheck needs: changes if: ${{ !cancelled() && (needs.changes.outputs.build != 'false' || needs.changes.outputs.typecheck != 'false') }} @@ -138,6 +146,8 @@ jobs: - name: Setup Vite+ uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} # electron's install.js (run by ensure:electron) reads this cache before it # downloads from GitHub. The key changes only with the Electron version. @@ -196,6 +206,9 @@ jobs: # dependency layers. The concurrency limit stays at the default 4 so peak load # per runner is unchanged. test: + permissions: + contents: read + id-token: write name: Test needs: changes if: ${{ !cancelled() && needs.changes.outputs.test != 'false' }} @@ -211,16 +224,6 @@ jobs: !/apps/mobile/modules/t3-terminal/Vendor/**/*.a sparse-checkout-cone-mode: false - # Blacksmith boots GitHub's Ubuntu runner image (gcc is usually present), - # but ACP process-tree live tests compile a small pthread fixture with `cc` - # and soft-skip when it is missing. Install build-essential so that path - # always runs in CI instead of silently no-oping. - - name: Install C toolchain for process-tree fixtures - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends build-essential - command -v cc - - uses: ./.github/actions/setup-apt-mirrors # The desktop tests compile the browser secret helper. The install runs in @@ -236,6 +239,8 @@ jobs: - name: Setup Vite+ uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} # electron's install.js (run by ensure:electron) reads this cache before it # downloads from GitHub. The key changes only with the Electron version. @@ -277,6 +282,9 @@ jobs: # started last and ran alone for about 40s after everything else finished. # release.yml mirrors this test, test_web, and test_server split. test_web: + permissions: + contents: read + id-token: write name: Test Web needs: changes if: ${{ !cancelled() && needs.changes.outputs.test_web != 'false' }} @@ -294,6 +302,8 @@ jobs: - name: Setup Vite+ uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} # The browser project runs React tests in headless Chromium. Same cache key as # test_server, so both jobs share one downloaded browser. @@ -323,6 +333,12 @@ jobs: - name: Test run: vp run --filter @t3tools/web test + # Browser tests run source modules; this boots the production chunk graph. + - name: Boot the built web client + run: | + vp run --filter @t3tools/web build + node apps/web/scripts/verify-built-boot.ts + # apps/server sets `fileParallelism: false`, so its test files run strictly # one at a time. Sharding spreads them over separate runners instead of # separate workers, so no two server test files ever share a machine and the @@ -332,7 +348,12 @@ jobs: # The shards are the longest jobs, so they gate themselves instead of waiting # ~15s for `changes`: a shard the change cannot affect stops after checkout. test_server: + permissions: + contents: read + id-token: write name: Test Server ${{ matrix.shard }} + # T3-CUSTOM(expbkt3): GitHub-hosted runner; Blacksmith runners never start on + # this fork, and without the private task cache shards run longer. runs-on: ubuntu-24.04 timeout-minutes: 25 strategy: @@ -358,6 +379,8 @@ jobs: - name: Setup Vite+ if: steps.changes.outputs.test_server != 'false' uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} # No Electron setup here: `t3` (apps/server) has no Electron dependency # and none of its tests touch the runtime. Only the non-server `test` @@ -394,7 +417,18 @@ jobs: env: T3CODE_TRANSFER_BUDGET_REPORT_PATH: ${{ runner.temp }}/t3code-transfer-budget.md T3CODE_TRANSFER_BUDGET_RESULT_PATH: ${{ runner.temp }}/thread-transfer-result.json - run: vp run --filter t3 test --shard ${{ matrix.shard }}/${{ strategy.job-total }} + run: >- + vp run test:cached -- --shard ${{ matrix.shard }}/${{ strategy.job-total }} + --uncached apps/server/integration/transferBudgetV2.integration.test.ts + --verify-hits 1 --report "${{ runner.temp }}/test-cache-${{ matrix.shard }}.json" apps/server + + - name: Upload test cache measurements + if: always() && steps.changes.outputs.test_server != 'false' + uses: actions/upload-artifact@v7 + with: + name: test-cache-${{ matrix.shard }} + path: ${{ runner.temp }}/test-cache-${{ matrix.shard }}.json + if-no-files-found: error # integration/transferBudgetV2.integration.test.ts writes the report; exactly one shard # produces these files. Gating the upload on their presence keeps a @@ -464,6 +498,9 @@ jobs: done release_smoke: + permissions: + contents: read + id-token: write name: Release Smoke needs: changes if: ${{ !cancelled() && needs.changes.outputs.release_smoke != 'false' }} @@ -481,6 +518,8 @@ jobs: - name: Setup Vite+ uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} - name: Exercise release-only workflow steps run: node scripts/release-smoke.ts diff --git a/.github/workflows/deploy-relay.yml b/.github/workflows/deploy-relay.yml index df4dda9750899..1a84903f43a45 100644 --- a/.github/workflows/deploy-relay.yml +++ b/.github/workflows/deploy-relay.yml @@ -5,12 +5,11 @@ on: branches: - main # Redeploys main after a variable or secret change. Alchemy redeploys the - # Worker when a value it reads changes, so force is rarely needed, and it - # replaces the Postgres runtime role (alchemy-run/alchemy#1832). + # Worker when a value it reads changes, so force is rarely needed. workflow_dispatch: inputs: force: - description: Redeploy every resource, including ones with no detected changes. Replaces the Postgres runtime role. + description: Redeploy every resource, including ones with no detected changes. type: boolean default: false @@ -81,6 +80,7 @@ jobs: CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} APNS_PRIVATE_KEY: ${{ secrets.APNS_PRIVATE_KEY }} FCM_SERVICE_ACCOUNT: ${{ secrets.FCM_SERVICE_ACCOUNT }} + T3CHAT_LINK_SECRET: ${{ secrets.T3CHAT_LINK_SECRET }} - name: Publish relay deploy commit status uses: actions/github-script@v8 diff --git a/.github/workflows/desktop-macos-preview-publish.yml b/.github/workflows/desktop-macos-preview-publish.yml index 1266d4936af1e..a4760193d5bbb 100644 --- a/.github/workflows/desktop-macos-preview-publish.yml +++ b/.github/workflows/desktop-macos-preview-publish.yml @@ -77,16 +77,19 @@ jobs: return; } - // workflow_run.pull_requests is empty for fork PRs, so resolve the - // PR from the built commit instead and require exactly one open PR - // from the same head repository and branch. The build baked its + // workflow_run.pull_requests and the commit-to-PR lookup are both + // empty for fork PRs, so list open PRs by head owner and branch + // and require exactly one at the built commit. The build baked its // PR number into the version, so two candidates would mean the // asset name could belong to either. - const associated = await github.paginate( - github.rest.repos.listPullRequestsAssociatedWithCommit, - { owner, repo, commit_sha: run.head_sha, per_page: 100 }, - ); - const matching = associated.filter( + const open = await github.paginate(github.rest.pulls.list, { + owner, + repo, + state: "open", + head: `${run.head_repository?.owner?.login}:${run.head_branch}`, + per_page: 100, + }); + const matching = open.filter( (candidate) => candidate.state === "open" && candidate.head.sha === run.head_sha && diff --git a/.github/workflows/mobile-eas-preview-fork.yml b/.github/workflows/mobile-eas-preview-fork.yml new file mode 100644 index 0000000000000..e7615d46011bd --- /dev/null +++ b/.github/workflows/mobile-eas-preview-fork.yml @@ -0,0 +1,145 @@ +name: Mobile EAS Preview (Fork) + +# EAS previews for fork PRs. This runs the PR's code with an Expo token: the +# app config and plugins run on this runner, and native builds run on EAS +# with preview signing credentials. The label is the maintainer's sign-off +# that they reviewed that code, so it covers exactly the commit it was applied +# to: this pins that commit and consumes the label before running anything. A +# later push deploys nothing until the label is applied again. +# +# Same-repo PRs deploy on every push while labeled, via mobile-eas-preview.yml. + +on: + pull_request_target: + types: [labeled] + +permissions: + contents: read + +jobs: + preview: + name: EAS Preview + if: >- + github.event.pull_request.head.repo.full_name != github.repository && + github.event.label.name == '🚀 Mobile Continuous Deployment' + runs-on: blacksmith-8vcpu-ubuntu-2404 + concurrency: + group: mobile-eas-preview-fork-${{ github.event.pull_request.number }} + cancel-in-progress: true + permissions: + contents: read + pull-requests: write + env: + APP_VARIANT: preview + NODE_OPTIONS: --max-old-space-size=8192 + MOBILE_VERSION_POLICY: fingerprint + steps: + # If a maintainer removed the label first, that removal wins. + - id: consume + name: Consume the deployment label + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + name: context.payload.label.name, + }); + core.setOutput("consumed", "true"); + } catch (error) { + if (error.status !== 404) throw error; + core.info("The label was removed before this run could consume it. Skipping."); + core.setOutput("consumed", "false"); + } + + - id: expo-token + name: Check for EXPO_PREVIEW_TOKEN + if: steps.consume.outputs.consumed == 'true' + env: + EXPO_TOKEN: ${{ secrets.EXPO_PREVIEW_TOKEN }} + run: | + if [ -n "$EXPO_TOKEN" ]; then + echo "present=true" >> "$GITHUB_OUTPUT" + else + echo "present=false" >> "$GITHUB_OUTPUT" + echo "EXPO_PREVIEW_TOKEN is not set; skipping EAS preview." + fi + + # The head SHA from the label event, not the branch, so a push after the + # label cannot swap in unreviewed code. No credentials stay in .git for + # the PR's scripts to read. + - name: Checkout reviewed commit + if: steps.expo-token.outputs.present == 'true' + uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + persist-credentials: false + # No sparse-checkout: see mobile-eas-preview.yml. + + # No caches: pull_request_target saves them under main's scope, where + # trusted workflows with other secrets would restore what PR code wrote. + - name: Setup Vite+ + if: steps.expo-token.outputs.present == 'true' + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: false + run-install: | + args: + - --filter=@t3tools/mobile... + + - name: Expose pnpm + if: steps.expo-token.outputs.present == 'true' + run: | + pnpm_version="$(node --print "require('./package.json').packageManager.split('@').pop()")" + vp_pnpm_bin="$HOME/.vite-plus/package_manager/pnpm/$pnpm_version/pnpm/bin" + echo "$vp_pnpm_bin" >> "$GITHUB_PATH" + "$vp_pnpm_bin/pnpm" --version + + - name: Setup EAS + if: steps.expo-token.outputs.present == 'true' + uses: expo/expo-github-action@v8 + with: + # Pinned and installed with npm for the same reasons as mobile-eas-preview.yml. + eas-version: 21.8.0 + eas-cache: false + token: ${{ secrets.EXPO_PREVIEW_TOKEN }} + packager: npm + + - name: Pull preview environment variables + if: steps.expo-token.outputs.present == 'true' + working-directory: apps/mobile + env: + EXPO_TOKEN: ${{ secrets.EXPO_PREVIEW_TOKEN }} + run: | + eas env:pull preview --non-interactive + # See mobile-eas-preview.yml: keeps Android build and OTA fingerprints matching. + node --input-type=module <<'NODE' + import { appendFileSync, readFileSync } from "node:fs"; + import { parseEnv } from "node:util"; + const env = parseEnv(readFileSync(".env.local", "utf8")); + const googleServicesFile = env.T3CODE_ANDROID_GOOGLE_SERVICES_FILE; + if (googleServicesFile) { + appendFileSync(process.env.GITHUB_ENV, `T3CODE_ANDROID_GOOGLE_SERVICES_FILE=${googleServicesFile}\n`); + } + NODE + + - name: Deploy with fingerprint check + if: steps.expo-token.outputs.present == 'true' + uses: expo/expo-github-action/continuous-deploy-fingerprint@main + env: + EXPO_TOKEN: ${{ secrets.EXPO_PREVIEW_TOKEN }} + RUNNER_DEBUG: "1" + # The action only comments for pull_request events; this hook gives + # it the PR under pull_request_target. Without it, it skips the comment. + EXPO_TEST_GITHUB_PULL: ${{ github.event.pull_request.number }} + with: + profile: preview:dev + branch: pr-${{ github.event.pull_request.number }} + platform: all + environment: preview + working-directory: apps/mobile + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/mobile-eas-preview.yml b/.github/workflows/mobile-eas-preview.yml index b6c5b157c8f27..d7652bfaa0c45 100644 --- a/.github/workflows/mobile-eas-preview.yml +++ b/.github/workflows/mobile-eas-preview.yml @@ -7,7 +7,9 @@ on: jobs: preview: name: EAS Preview + # Fork PRs deploy one reviewed commit per label through mobile-eas-preview-fork.yml. if: | + github.event.pull_request.head.repo.full_name == github.repository && contains(github.event.pull_request.labels.*.name, '🚀 Mobile Continuous Deployment') && (github.event.action != 'labeled' || github.event.label.name == '🚀 Mobile Continuous Deployment') runs-on: ubuntu-24.04 @@ -23,15 +25,15 @@ jobs: MOBILE_VERSION_POLICY: fingerprint steps: - id: expo-token - name: Check for EXPO_TOKEN + name: Check for EXPO_PREVIEW_TOKEN env: - EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + EXPO_TOKEN: ${{ secrets.EXPO_PREVIEW_TOKEN }} run: | if [ -n "$EXPO_TOKEN" ]; then echo "present=true" >> "$GITHUB_OUTPUT" else echo "present=false" >> "$GITHUB_OUTPUT" - echo "EXPO_TOKEN is not available; skipping EAS preview." + echo "EXPO_PREVIEW_TOKEN is not available; skipping EAS preview." fi - name: Checkout @@ -70,7 +72,7 @@ jobs: # continuous-deploy-fingerprint still reads `build.project.ownerAccount`. # Unpin once https://github.com/expo/expo-github-action/issues/358 is fixed. eas-version: 21.8.0 - token: ${{ secrets.EXPO_TOKEN }} + token: ${{ secrets.EXPO_PREVIEW_TOKEN }} # npm, not pnpm: this only installs eas-cli into the action's own # tool dir, and pnpm 11 hard-fails that install on dtrace-provider's # ignored build script (no allowBuilds config outside the repo). @@ -80,7 +82,7 @@ jobs: if: steps.expo-token.outputs.present == 'true' working-directory: apps/mobile env: - EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + EXPO_TOKEN: ${{ secrets.EXPO_PREVIEW_TOKEN }} run: | eas env:pull preview --non-interactive # EAS Update disables dotenv loading. Keep the downloaded native config @@ -99,7 +101,7 @@ jobs: if: steps.expo-token.outputs.present == 'true' uses: expo/expo-github-action/continuous-deploy-fingerprint@main env: - EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + EXPO_TOKEN: ${{ secrets.EXPO_PREVIEW_TOKEN }} # The Expo action otherwise suppresses the EAS command's failure output. RUNNER_DEBUG: "1" with: diff --git a/.github/workflows/mobile-eas-production.yml b/.github/workflows/mobile-eas-production.yml index 11ac6df40eb4c..81f26d6ce8f6e 100644 --- a/.github/workflows/mobile-eas-production.yml +++ b/.github/workflows/mobile-eas-production.yml @@ -97,13 +97,13 @@ jobs: - id: expo-token name: Check for EXPO_TOKEN env: - EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + EXPO_TOKEN: ${{ secrets.EXPO_PRODUCTION_TOKEN }} run: | if [ -n "$EXPO_TOKEN" ]; then echo "present=true" >> "$GITHUB_OUTPUT" else echo "present=false" >> "$GITHUB_OUTPUT" - echo "EXPO_TOKEN is not available; skipping EAS production job." + echo "EXPO_PRODUCTION_TOKEN is not available; skipping EAS production job." fi - id: version_app_token @@ -149,7 +149,7 @@ jobs: uses: expo/expo-github-action@v8 with: eas-version: latest - token: ${{ secrets.EXPO_TOKEN }} + token: ${{ secrets.EXPO_PRODUCTION_TOKEN }} # npm, not pnpm: this only installs eas-cli into the action's own # tool dir, and pnpm 11 hard-fails that install on dtrace-provider's # ignored build script (no allowBuilds config outside the repo). @@ -159,8 +159,21 @@ jobs: if: steps.expo-token.outputs.present == 'true' working-directory: apps/mobile env: - EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} - run: eas env:pull production --non-interactive + EXPO_TOKEN: ${{ secrets.EXPO_PRODUCTION_TOKEN }} + run: | + eas env:pull production --non-interactive + # EAS commands skip dotenv, and the Android fingerprint hashes this + # file. Without it every merge misses the build's hash and rebuilds. + node --input-type=module <<'NODE' + import { appendFileSync, readFileSync } from "node:fs"; + import { parseEnv } from "node:util"; + const env = parseEnv(readFileSync(".env.local", "utf8")); + const googleServicesFile = env.T3CODE_ANDROID_GOOGLE_SERVICES_FILE; + if (!googleServicesFile) { + throw new Error("T3CODE_ANDROID_GOOGLE_SERVICES_FILE is missing from the production environment"); + } + appendFileSync(process.env.GITHUB_ENV, `T3CODE_ANDROID_GOOGLE_SERVICES_FILE=${googleServicesFile}\n`); + NODE - name: Apply manual version override if: steps.version_app_token.outcome == 'success' @@ -202,7 +215,7 @@ jobs: git config user.name "${APP_SLUG}[bot]" git config user.email "${user_id}+${APP_SLUG}[bot]@users.noreply.github.com" git add apps/mobile/app.config.ts - git commit \ + git commit --no-verify \ -m "chore(mobile): bump app version to $RELEASE_VERSION" \ -m "Co-authored-by: codex " git push origin "HEAD:refs/heads/${GITHUB_REF_NAME}" @@ -231,7 +244,7 @@ jobs: if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'build' working-directory: apps/mobile env: - EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + EXPO_TOKEN: ${{ secrets.EXPO_PRODUCTION_TOKEN }} MOBILE_BUILD_PROFILE: ${{ inputs.profile || 'production' }} run: eas build --platform ${{ inputs.platform }} --profile "$MOBILE_BUILD_PROFILE" --auto-submit --non-interactive --no-wait @@ -242,7 +255,7 @@ jobs: if: steps.expo-token.outputs.present == 'true' && (github.event_name == 'push' || inputs.mode == 'update') working-directory: apps/mobile env: - EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + EXPO_TOKEN: ${{ secrets.EXPO_PRODUCTION_TOKEN }} PLATFORM: ${{ inputs.platform || 'all' }} run: | if [ "$PLATFORM" = "all" ]; then platforms="ios android"; else platforms="$PLATFORM"; fi @@ -262,7 +275,7 @@ jobs: if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'update' working-directory: apps/mobile env: - EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + EXPO_TOKEN: ${{ secrets.EXPO_PRODUCTION_TOKEN }} MOBILE_BUILD_PROFILE: ${{ inputs.profile || 'production' }} OTA_MESSAGE: ${{ inputs.message || format('Production OTA ({0})', github.sha) }} run: | @@ -275,6 +288,7 @@ jobs: --environment production \ --platform ${{ inputs.platform }} \ --message "$OTA_MESSAGE" \ + --emit-metadata \ --non-interactive # Reconcile each version separately because the major is in the fingerprint. @@ -285,7 +299,7 @@ jobs: if: steps.expo-token.outputs.present == 'true' && github.event_name == 'push' working-directory: apps/mobile env: - EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + EXPO_TOKEN: ${{ secrets.EXPO_PRODUCTION_TOKEN }} run: | failed=0 message="$(git log -1 --pretty=%s | head -c 120) ($(git rev-parse --short=9 HEAD))" @@ -313,8 +327,11 @@ jobs: --environment production \ --platform "$platform" \ --message "$message" \ + --emit-metadata \ --non-interactive; then echo ":white_check_mark: $platform/$profile: OTA published to production (fingerprint \`$hash\`)" >> "$GITHUB_STEP_SUMMARY" + # The next target's export replaces dist; keep this one for the map upload. + rm -rf "dist-$platform-$profile" && mv dist "dist-$platform-$profile" else failed=1 echo ":x: $platform/$profile: OTA publish failed (fingerprint \`$hash\`)" >> "$GITHUB_STEP_SUMMARY" @@ -354,3 +371,18 @@ jobs: fi done exit "$failed" + + # Lets the relay symbolicate crash stacks from the updates published + # above. A step of its own so the R2 credentials reach only this script, + # not `eas update` and the dependencies it runs. Never fails the job. + - name: Upload OTA source maps + if: ${{ !cancelled() && steps.expo-token.outputs.present == 'true' }} + working-directory: apps/mobile + env: + MOBILE_SOURCEMAPS_R2: ${{ secrets.MOBILE_SOURCEMAPS_R2 }} + run: | + for dir in dist dist-*; do + if [ -f "$dir/eas-update-metadata.json" ]; then + node scripts/upload-sourcemaps.mts ota "$dir" + fi + done diff --git a/.github/workflows/mobile-fingerprint-check.yml b/.github/workflows/mobile-fingerprint-check.yml index 5849b71ddbb8a..6ef032450e04f 100644 --- a/.github/workflows/mobile-fingerprint-check.yml +++ b/.github/workflows/mobile-fingerprint-check.yml @@ -2,7 +2,8 @@ name: Mobile Fingerprint Check # Detects whether a PR changes the native fingerprint — i.e. whether merging # it would leave main un-OTA-able until a new store build ships. Native-change -# PRs get the "📱 Native Change" label so they can be held and merged as a +# PRs get the "📱 Native Change" label (applied by mobile-fingerprint-label.yml, +# which runs from main so fork PRs get it too) so they can be held and merged as a # batch right before the next store submission, keeping main OTA-able for # everything else in between. (Once one native PR merges, every later merge # inherits the drifted fingerprint and loses OTA reach too — that is why the @@ -27,8 +28,6 @@ jobs: runs-on: ubuntu-24.04 permissions: contents: read - issues: write - pull-requests: write env: APP_VARIANT: production NODE_OPTIONS: --max-old-space-size=8192 @@ -127,94 +126,19 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" echo "changed_platforms=${changed# }" >> "$GITHUB_OUTPUT" - - name: Sync native change label - # Fork PRs get a read-only token under pull_request; the check stays - # advisory there (summary only). This workflow must not move to - # pull_request_target — it installs and runs PR code. - if: github.event.pull_request.head.repo.full_name == github.repository - uses: actions/github-script@v8 + # mobile-fingerprint-label.yml reads this from main with a write token + # and syncs the label; this job's token is read-only for fork PRs. + - name: Record changed platforms env: CHANGED_PLATFORMS: ${{ steps.compare.outputs.changed_platforms }} - with: - script: | - const managedLabel = { - name: "📱 Native Change", - color: "d93f0b", - description: - "Changes the native fingerprint; merging blocks production OTAs until a new store build ships.", - }; - const nativeChanged = (process.env.CHANGED_PLATFORMS ?? "").trim() !== ""; - const issueNumber = context.payload.pull_request.number; - - try { - const { data: existing } = await github.rest.issues.getLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: managedLabel.name, - }); - - if ( - existing.color !== managedLabel.color || - (existing.description ?? "") !== managedLabel.description - ) { - await github.rest.issues.updateLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: managedLabel.name, - color: managedLabel.color, - description: managedLabel.description, - }); - } - } catch (error) { - if (error.status !== 404) { - throw error; - } - - try { - await github.rest.issues.createLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: managedLabel.name, - color: managedLabel.color, - description: managedLabel.description, - }); - } catch (createError) { - if (createError.status !== 422) { - throw createError; - } - } - } - - const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - per_page: 100, - }); - const hasLabel = currentLabels.some((label) => label.name === managedLabel.name); - - if (nativeChanged && !hasLabel) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - labels: [managedLabel.name], - }); - } else if (!nativeChanged && hasLabel) { - try { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - name: managedLabel.name, - }); - } catch (removeError) { - if (removeError.status !== 404) { - throw removeError; - } - } - } + run: | + mkdir -p "$RUNNER_TEMP/fingerprint-result" + printf '%s\n' "$CHANGED_PLATFORMS" > "$RUNNER_TEMP/fingerprint-result/changed-platforms" - core.info( - `PR #${issueNumber}: native fingerprint ${nativeChanged ? `changed (${process.env.CHANGED_PLATFORMS})` : "unchanged"}`, - ); + - name: Upload changed platforms + uses: actions/upload-artifact@v7 + with: + name: fingerprint-result + path: ${{ runner.temp }}/fingerprint-result + if-no-files-found: error + retention-days: 1 diff --git a/.github/workflows/mobile-fingerprint-label.yml b/.github/workflows/mobile-fingerprint-label.yml new file mode 100644 index 0000000000000..45e4c9f2ad42d --- /dev/null +++ b/.github/workflows/mobile-fingerprint-label.yml @@ -0,0 +1,189 @@ +name: Mobile Fingerprint Label + +# Trusted half of mobile-fingerprint-check.yml. That workflow runs PR code +# (fork PRs included) with a read-only token and records which platforms' +# fingerprints changed. This one runs from main with a write token and syncs +# the "📱 Native Change" label. It never checks out or runs PR code. +# +# A PR can edit the check workflow it runs under, so the recorded result is +# untrusted data: it is validated, and at worst a PR mislabels itself. + +on: + workflow_run: + workflows: [Mobile Fingerprint Check] + types: [completed] + +permissions: + contents: read + +jobs: + label: + name: Sync native change label + # A cancelled run was superseded by a newer push, whose run labels instead. + if: >- + github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + actions: read + contents: read + issues: write + pull-requests: write + steps: + - id: pr + name: Resolve the pull request behind the check + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const run = context.payload.workflow_run; + const { owner, repo } = context.repo; + + const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, { + owner, + repo, + run_id: run.id, + per_page: 100, + }); + const results = artifacts.filter( + (artifact) => artifact.name === "fingerprint-result" && !artifact.expired, + ); + if (results.length !== 1) { + core.info(`Expected one fingerprint-result artifact; found ${results.length}. Skipping.`); + return; + } + + // workflow_run.pull_requests and the commit-to-PR lookup are both + // empty for fork PRs, so list open PRs by head owner and branch. + // Requiring the PR's current head to be the checked commit keeps a + // slow run for an older push from overwriting a newer result. + const open = await github.paginate(github.rest.pulls.list, { + owner, + repo, + state: "open", + head: `${run.head_repository?.owner?.login}:${run.head_branch}`, + per_page: 100, + }); + const matching = open.filter( + (candidate) => + candidate.state === "open" && + candidate.head.sha === run.head_sha && + candidate.head.ref === run.head_branch && + candidate.head.repo?.full_name === run.head_repository?.full_name, + ); + if (matching.length !== 1) { + core.info(`Expected one open PR at ${run.head_sha}; found ${matching.length}. Skipping.`); + return; + } + + core.setOutput("artifact_id", String(results[0].id)); + core.setOutput("pr_number", String(matching[0].number)); + + # Read the archive as bytes; never extract PR-produced files into a + # workspace this job runs code from. + - id: result + name: Read changed platforms + if: steps.pr.outputs.pr_number != '' + shell: bash + env: + GH_TOKEN: ${{ github.token }} + ARTIFACT_ID: ${{ steps.pr.outputs.artifact_id }} + run: | + set -euo pipefail + gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}/zip" > "$RUNNER_TEMP/fingerprint-result.zip" + changed="$(unzip -p "$RUNNER_TEMP/fingerprint-result.zip" changed-platforms)" + if [[ ! "$changed" =~ ^((ios|android)( (ios|android))?)?$ ]]; then + echo "Unexpected changed-platforms value '$changed'." >&2 + exit 1 + fi + echo "changed_platforms=$changed" >> "$GITHUB_OUTPUT" + + - name: Sync native change label + if: steps.pr.outputs.pr_number != '' + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + PR_NUMBER: ${{ steps.pr.outputs.pr_number }} + CHANGED_PLATFORMS: ${{ steps.result.outputs.changed_platforms }} + with: + script: | + const managedLabel = { + name: "📱 Native Change", + color: "d93f0b", + description: + "Changes the native fingerprint; merging blocks production OTAs until a new store build ships.", + }; + const nativeChanged = (process.env.CHANGED_PLATFORMS ?? "").trim() !== ""; + const issueNumber = Number(process.env.PR_NUMBER); + + try { + const { data: existing } = await github.rest.issues.getLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: managedLabel.name, + }); + + if ( + existing.color !== managedLabel.color || + (existing.description ?? "") !== managedLabel.description + ) { + await github.rest.issues.updateLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: managedLabel.name, + color: managedLabel.color, + description: managedLabel.description, + }); + } + } catch (error) { + if (error.status !== 404) { + throw error; + } + + try { + await github.rest.issues.createLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: managedLabel.name, + color: managedLabel.color, + description: managedLabel.description, + }); + } catch (createError) { + if (createError.status !== 422) { + throw createError; + } + } + } + + const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + per_page: 100, + }); + const hasLabel = currentLabels.some((label) => label.name === managedLabel.name); + + if (nativeChanged && !hasLabel) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + labels: [managedLabel.name], + }); + } else if (!nativeChanged && hasLabel) { + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + name: managedLabel.name, + }); + } catch (removeError) { + if (removeError.status !== 404) { + throw removeError; + } + } + } + + core.info( + `PR #${issueNumber}: native fingerprint ${nativeChanged ? `changed (${process.env.CHANGED_PLATFORMS})` : "unchanged"}`, + ); diff --git a/.github/workflows/mobile-showcase-screenshots.yml b/.github/workflows/mobile-showcase-screenshots.yml index 958738276a9ed..8c121f3c31580 100644 --- a/.github/workflows/mobile-showcase-screenshots.yml +++ b/.github/workflows/mobile-showcase-screenshots.yml @@ -43,7 +43,7 @@ env: jobs: ios: - name: iPhone 6.9, iPhone 6.5, and iPad 13 + name: iPhone, iPhone Duo, and iPad 13 if: inputs.platform == 'all' || inputs.platform == 'ios' runs-on: macos-26 # Capturing every palette multiplies the device matrix by six, and only the @@ -87,6 +87,9 @@ jobs: brew install cameroncooke/axe/axe axe --version + - name: Install foldable simulator controls + run: npm install --global agent-device@0.21.25 + - name: Capture iOS showcase run: pnpm screenshots:mobile --platform ios --appearance "${{ inputs.appearance }}" --theme "${{ inputs.theme }}" diff --git a/.github/workflows/pr-vouch.yml b/.github/workflows/pr-vouch.yml index c4abb08b727b2..75e2ee8a56548 100644 --- a/.github/workflows/pr-vouch.yml +++ b/.github/workflows/pr-vouch.yml @@ -20,6 +20,10 @@ permissions: jobs: collect-targets: name: Collect PR targets + # Most comments are not recheck commands; skip them before a runner starts. + if: >- + github.event_name != 'issue_comment' || + (github.event.issue.pull_request && contains(github.event.comment.body, '/recheck-vouch')) runs-on: ubuntu-24.04 outputs: targets: ${{ steps.collect.outputs.targets }} diff --git a/.github/workflows/relay-preview-cleanup.yml b/.github/workflows/relay-preview-cleanup.yml new file mode 100644 index 0000000000000..eed067adfd773 --- /dev/null +++ b/.github/workflows/relay-preview-cleanup.yml @@ -0,0 +1,203 @@ +name: Clean up relay preview + +# Tears down stage `pr-` when `relay:preview` is removed or the PR closes, +# and retries teardown for closed labeled PRs on a schedule. A label stays on a +# PR only while its teardown is outstanding, so the scheduled retry lists +# exactly the failures. +# +# This runs under pull_request_target, so the workflow and the Alchemy program +# come from the default branch. No PR code runs here, which is why it needs no +# reviewer approval and why a PR cannot edit its own teardown to reach secrets. +on: + pull_request_target: + types: [unlabeled, closed] + schedule: + - cron: "17 7 * * *" + workflow_dispatch: + +permissions: + contents: read + issues: read + pull-requests: read + +jobs: + cleanup_pr: + name: Tear down relay preview + if: >- + github.ref == 'refs/heads/main' && + github.event_name == 'pull_request_target' && + ( + (github.event.action == 'unlabeled' && github.event.label.name == 'relay:preview') || + (github.event.action == 'closed' && contains(github.event.pull_request.labels.*.name, 'relay:preview')) + ) + # Job-level so that events which skip this job, such as a closed PR without + # the label, never take the group's pending slot. GitHub replaces a pending + # run in a group with a newer one, which would drop a queued teardown. + concurrency: + group: relay-preview-${{ github.event.pull_request.number }} + cancel-in-progress: false + permissions: + contents: read + issues: write + pull-requests: write + runs-on: blacksmith-8vcpu-ubuntu-2404 + timeout-minutes: 30 + environment: + name: relay-preview-cleanup + env: + GH_TOKEN: ${{ github.token }} + STAGE: pr-${{ github.event.pull_request.number }} + CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + PLANETSCALE_ORGANIZATION: ${{ vars.PLANETSCALE_ORGANIZATION }} + AXIOM_ORG_ID: ${{ vars.AXIOM_ORG_ID }} + RELAY_API_ZONE_NAME: ${{ vars.RELAY_API_ZONE_NAME }} + RELAY_TUNNEL_ZONE_NAME: ${{ vars.RELAY_TUNNEL_ZONE_NAME }} + CLERK_PUBLISHABLE_KEY: ${{ vars.CLERK_PUBLISHABLE_KEY }} + CLERK_JWT_AUDIENCE: ${{ vars.CLERK_JWT_AUDIENCE }} + APNS_ENABLED: "false" + ALCHEMY_TELEMETRY_DISABLED: "1" + steps: + - name: Checkout default branch + uses: actions/checkout@v6 + with: + ref: ${{ github.event.repository.default_branch }} + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: | + args: + - --filter=t3code-relay... + + # Destroy also runs the preview tunnel sweep, which is part of the stack. + - name: Destroy preview stage + run: vp run --filter t3code-relay destroy --stage "$STAGE" --yes --no-input + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + PLANETSCALE_API_TOKEN_ID: ${{ secrets.PLANETSCALE_API_TOKEN_ID }} + PLANETSCALE_API_TOKEN: ${{ secrets.PLANETSCALE_API_TOKEN }} + AXIOM_TOKEN: ${{ secrets.AXIOM_TOKEN }} + CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} + + # Only reached after a successful destroy. A failed one keeps the label, + # so the scheduled retry picks the PR up again. + - name: Clear the teardown marker + if: github.event.action == 'closed' + run: gh pr edit "${{ github.event.pull_request.number }}" --repo "$GITHUB_REPOSITORY" --remove-label relay:preview + + # An unlabeled event has already removed the label, so a failed teardown + # must put it back or no retry will ever see the stage. Labels added with + # GITHUB_TOKEN do not start new runs, so this does not redeploy. + - name: Restore the teardown marker after a failed destroy + if: failure() && github.event.action == 'unlabeled' + run: gh pr edit "${{ github.event.pull_request.number }}" --repo "$GITHUB_REPOSITORY" --add-label relay:preview + + list_closed_previews: + name: List closed relay previews + if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request_target' + runs-on: blacksmith-8vcpu-ubuntu-2404 + timeout-minutes: 10 + outputs: + numbers: ${{ steps.list.outputs.numbers }} + steps: + # The issues endpoint is paginated by gh, so there is no listing cap. Pull + # requests appear there with a `pull_request` key. + - name: List closed PRs labeled relay:preview + id: list + env: + GH_TOKEN: ${{ github.token }} + run: | + numbers=$(gh api --paginate --method GET "repos/$GITHUB_REPOSITORY/issues" \ + -f labels=relay:preview -f state=closed -f per_page=100 \ + --jq '.[] | select(.pull_request) | .number') \ + || { echo "Could not list closed relay:preview PRs" >&2; exit 1; } + json=$(printf '%s\n' "$numbers" | jq -Rsc 'split("\n") | map(select(length > 0) | tonumber)') + echo "Closed relay:preview PRs: $json" + echo "numbers=$json" >> "$GITHUB_OUTPUT" + + sweep_pr: + name: Retry teardown for PR ${{ matrix.number }} + needs: list_closed_previews + if: github.ref == 'refs/heads/main' && needs.list_closed_previews.outputs.numbers != '[]' + runs-on: blacksmith-8vcpu-ubuntu-2404 + timeout-minutes: 30 + strategy: + fail-fast: false + max-parallel: 4 + matrix: + number: ${{ fromJSON(needs.list_closed_previews.outputs.numbers) }} + # Same group as the deploy and teardown for this PR, so a reopened PR's + # deploy cannot run concurrently with a teardown of its previous stage. + concurrency: + group: relay-preview-${{ matrix.number }} + cancel-in-progress: false + permissions: + contents: read + issues: write + pull-requests: write + environment: + name: relay-preview-cleanup + env: + GH_TOKEN: ${{ github.token }} + STAGE: pr-${{ matrix.number }} + CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + PLANETSCALE_ORGANIZATION: ${{ vars.PLANETSCALE_ORGANIZATION }} + AXIOM_ORG_ID: ${{ vars.AXIOM_ORG_ID }} + RELAY_API_ZONE_NAME: ${{ vars.RELAY_API_ZONE_NAME }} + RELAY_TUNNEL_ZONE_NAME: ${{ vars.RELAY_TUNNEL_ZONE_NAME }} + CLERK_PUBLISHABLE_KEY: ${{ vars.CLERK_PUBLISHABLE_KEY }} + CLERK_JWT_AUDIENCE: ${{ vars.CLERK_JWT_AUDIENCE }} + APNS_ENABLED: "false" + ALCHEMY_TELEMETRY_DISABLED: "1" + steps: + # The lock above is taken before this runs, so the state read here is + # current. An open PR, including one reopened since the listing, is left + # alone. GitHub reports merged PRs as MERGED, so anything not OPEN counts. + - name: Confirm the PR is not open + id: eligible + run: | + state=$(gh pr view "${{ matrix.number }}" --repo "$GITHUB_REPOSITORY" \ + --json state --jq 'if .state == "OPEN" then "no" else "yes" end') \ + || { echo "Could not read PR ${{ matrix.number }}" >&2; exit 1; } + echo "eligible=$state" >> "$GITHUB_OUTPUT" + [ "$state" = yes ] || echo "PR ${{ matrix.number }} is open again; leaving $STAGE alone." + + - name: Checkout default branch + if: steps.eligible.outputs.eligible == 'yes' + uses: actions/checkout@v6 + with: + ref: ${{ github.event.repository.default_branch }} + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + - name: Setup Vite+ + if: steps.eligible.outputs.eligible == 'yes' + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: | + args: + - --filter=t3code-relay... + + - name: Destroy preview stage + if: steps.eligible.outputs.eligible == 'yes' + run: vp run --filter t3code-relay destroy --stage "$STAGE" --yes --no-input + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + PLANETSCALE_API_TOKEN_ID: ${{ secrets.PLANETSCALE_API_TOKEN_ID }} + PLANETSCALE_API_TOKEN: ${{ secrets.PLANETSCALE_API_TOKEN }} + AXIOM_TOKEN: ${{ secrets.AXIOM_TOKEN }} + CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} + + - name: Clear the teardown marker + if: steps.eligible.outputs.eligible == 'yes' + run: gh pr edit "${{ matrix.number }}" --repo "$GITHUB_REPOSITORY" --remove-label relay:preview diff --git a/.github/workflows/relay-preview.yml b/.github/workflows/relay-preview.yml new file mode 100644 index 0000000000000..fdce22c68a59e --- /dev/null +++ b/.github/workflows/relay-preview.yml @@ -0,0 +1,83 @@ +name: Deploy relay preview + +# Labeling a same-repository PR `relay:preview` deploys Alchemy stage `pr-`. +# This job runs the PR's own code with account credentials, so it waits for an +# approval in the `relay-preview` environment on every run. Teardown is in +# relay-preview-cleanup.yml and never runs PR code. +on: + pull_request: + types: [labeled, synchronize, reopened] + +permissions: + contents: read + pull-requests: read + +concurrency: + group: relay-preview-${{ github.event.pull_request.number }} + cancel-in-progress: false + +jobs: + deploy_preview: + name: Deploy relay preview + if: >- + github.event.pull_request.head.repo.full_name == github.repository && + contains(github.event.pull_request.labels.*.name, 'relay:preview') + runs-on: blacksmith-8vcpu-ubuntu-2404 + timeout-minutes: 20 + environment: + name: relay-preview + env: + STAGE: pr-${{ github.event.pull_request.number }} + CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + PLANETSCALE_ORGANIZATION: ${{ vars.PLANETSCALE_ORGANIZATION }} + AXIOM_ORG_ID: ${{ vars.AXIOM_ORG_ID }} + RELAY_API_ZONE_NAME: ${{ vars.RELAY_API_ZONE_NAME }} + RELAY_TUNNEL_ZONE_NAME: ${{ vars.RELAY_TUNNEL_ZONE_NAME }} + CLERK_PUBLISHABLE_KEY: ${{ vars.CLERK_PUBLISHABLE_KEY }} + CLERK_JWT_AUDIENCE: ${{ vars.CLERK_JWT_AUDIENCE }} + # Previews take no alert notifier, no tunnel cleanup, and no custom + # domain, so they stay silent and reap nothing outside their own stage. + APNS_ENABLED: "false" + ALCHEMY_TELEMETRY_DISABLED: "1" + steps: + - name: Confirm the PR is still open and labeled + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + state=$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json state,labels \ + --jq 'if .state == "OPEN" and any(.labels[]; .name == "relay:preview") then "ok" else "stale" end') + if [ "$state" != "ok" ]; then + echo "PR #$PR_NUMBER is no longer open with relay:preview; not deploying." >&2 + exit 1 + fi + + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha }} + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: | + args: + - --filter=t3code-relay... + + - name: Deploy preview stage + run: vp run --filter t3code-relay deploy --stage "$STAGE" --yes --no-input + env: + T3CODE_RELAY_CLIENT_CONFIG_ENV: ${{ runner.temp }}/relay-client-config.env + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + PLANETSCALE_API_TOKEN_ID: ${{ secrets.PLANETSCALE_API_TOKEN_ID }} + PLANETSCALE_API_TOKEN: ${{ secrets.PLANETSCALE_API_TOKEN }} + AXIOM_TOKEN: ${{ secrets.AXIOM_TOKEN }} + CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} + # Unset by default, so previews keep the T3 Chat link off. + T3CHAT_LINK_SECRET: ${{ secrets.T3CHAT_LINK_SECRET }} diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 516a0a7785a95..3af4e6abeafe9 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -64,6 +64,12 @@ on: required: false default: false type: boolean + # Whether the job opens the packaged app against a seeded database and + # opens a thread before uploading it. Linux AppImage or macOS DMG. + launch_check: + required: false + default: false + type: boolean # A runner of the target's own platform and architecture, for a `runner` # that cannot execute the target. The build job skips everything that # runs the packaged binaries, and a second job runs it there. @@ -141,13 +147,6 @@ jobs: git sparse-checkout set --no-cone '/*' '!/.repos/' git -c checkout.workers=0 checkout --detach FETCH_HEAD - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1 - with: - node-version-file: package.json - cache: true - run-install: false - # pnpm checks the lockfile and policy before reusing this result. A missing # artifact leaves the cache empty, so installation runs the checks again. - name: Download dependency verification @@ -157,10 +156,35 @@ jobs: name: release-dependency-verification path: ${{ runner.temp }}/pnpm-metadata - - name: Install desktop dependencies + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps + with: + install-args: --filter=@t3tools/desktop... --filter=t3... --filter=@t3tools/scripts... env: pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata - run: vp install --filter=@t3tools/desktop... --filter=t3... --filter=@t3tools/scripts... + + # The CLI archive and the staged app install without a lockfile, so they + # ask the registry about every package and fetch platform packages the + # workspace store lacks. They share this registry metadata and store. A + # new week starts cold, which bounds how stale cached ranges can get. + - name: Resolve staged install cache + id: stage_pnpm + shell: bash + run: | + echo "dir=$RUNNER_TEMP/desktop-stage-pnpm" >> "$GITHUB_OUTPUT" + echo "prefix=desktop-stage-pnpm-v2-${{ inputs.platform }}-${{ inputs.arch }}-$(date -u +%G-%V)-" >> "$GITHUB_OUTPUT" + + - name: Restore staged install cache + id: stage_pnpm_cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: ${{ steps.stage_pnpm.outputs.dir }} + key: ${{ steps.stage_pnpm.outputs.prefix }}${{ github.run_id }}-${{ github.run_attempt }} + restore-keys: ${{ steps.stage_pnpm.outputs.prefix }} + + - name: Mark staged install cache + shell: bash + run: mkdir -p "${{ steps.stage_pnpm.outputs.dir }}" && touch "$RUNNER_TEMP/desktop-stage-pnpm.marker" - name: Cache resource monitor id: resource_monitor_cache @@ -179,6 +203,14 @@ jobs: native/hyprland-snap-shot/target/${{ inputs.rust_target }}/release/t3-hyprland-snap-shot key: linux-capture-helpers-${{ inputs.rust_target }}-${{ hashFiles('native/kde-snap-shot/Cargo.lock', 'native/kde-snap-shot/Cargo.toml', 'native/kde-snap-shot/src/**', 'native/hyprland-snap-shot/Cargo.lock', 'native/hyprland-snap-shot/Cargo.toml', 'native/hyprland-snap-shot/src/**', 'native/hyprland-snap-shot/protocols/**') }} + - name: Ensure Rosetta for macOS x64 bytecode generation + if: inputs.platform == 'mac' && inputs.arch == 'x64' + shell: bash + run: | + if [[ "$(uname -m)" == "arm64" ]] && ! /usr/bin/arch -x86_64 /usr/bin/true; then + sudo /usr/sbin/softwareupdate --install-rosetta --agree-to-license + fi + - name: Setup Rust if: steps.resource_monitor_cache.outputs.cache-hit != 'true' || (inputs.platform == 'linux' && steps.capture_helper_cache.outputs.cache-hit != 'true') uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable @@ -253,6 +285,63 @@ jobs: if [[ "${{ inputs.version }}" =~ -nightly\.[0-9]{8}\.[0-9]+$ ]]; then brand=nightly; fi node scripts/apply-web-brand-assets.ts "$brand" apps/server/dist/client + - name: Resolve target Node.js for the CLI executable + id: target_node + if: inputs.cli_archive && inputs.platform == 'win' + shell: bash + run: echo "version=$(node -p 'require("./package.json").engines.node')" >> "$GITHUB_OUTPUT" + + # The download only changes with engines.node, so a cached copy keeps + # releases off nodejs.org, which stalls on the runners some days. + - name: Restore target Node.js for the CLI executable + id: target_node_cache + if: inputs.cli_archive && inputs.platform == 'win' + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: ${{ runner.temp }}/node-download + key: node-download-v${{ steps.target_node.outputs.version }}-win-${{ inputs.arch }} + + # tsdown downloads the target Node with one fetch and no retry, and + # nodejs.org drops that download often enough to fail releases. Seed + # tsdown's cache with a retried, checksum-verified copy instead. A transfer + # under 100 KB/s for 30s counts as failed, so a stall retries. + - name: Download target Node.js for the CLI executable + if: inputs.cli_archive && inputs.platform == 'win' && steps.target_node_cache.outputs.cache-hit != 'true' + shell: bash + env: + VERSION: ${{ steps.target_node.outputs.version }} + run: | + set -euo pipefail + name="node-v$VERSION-win-${{ inputs.arch }}" + dir="$RUNNER_TEMP/node-download" + mkdir -p "$dir" + fetch=(curl -fsSL --retry 5 --retry-all-errors --connect-timeout 10 --speed-limit 102400 --speed-time 30) + "${fetch[@]}" -o "$dir/$name.zip" "https://nodejs.org/dist/v$VERSION/$name.zip" + "${fetch[@]}" -o "$dir/SHASUMS256.txt" "https://nodejs.org/dist/v$VERSION/SHASUMS256.txt" + (cd "$dir" && grep " $name.zip\$" SHASUMS256.txt | sha256sum -c -) + + # Saved right away so a later failure in this job still keeps the download. + - name: Save target Node.js for the CLI executable + if: inputs.cli_archive && inputs.platform == 'win' && steps.target_node_cache.outputs.cache-hit != 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: ${{ runner.temp }}/node-download + key: ${{ steps.target_node_cache.outputs.cache-primary-key }} + + - name: Install target Node.js for the CLI executable + if: inputs.cli_archive && inputs.platform == 'win' + shell: bash + env: + VERSION: ${{ steps.target_node.outputs.version }} + run: | + set -euo pipefail + name="node-v$VERSION-win-${{ inputs.arch }}" + dir="$RUNNER_TEMP/node-download" + dest="${XDG_CACHE_HOME:-$HOME/.cache}/tsdown/node/v$VERSION/win-${{ inputs.arch }}" + mkdir -p "$dest" + (cd "$dir" && grep " $name.zip\$" SHASUMS256.txt | sha256sum -c -) + unzip -p "$dir/$name.zip" "$name/node.exe" > "$dest/node.exe" + # Setup Vite+ selects the exact engines.node version from package.json. # Native builds embed that executable; cross-target builds download the # same version for their target. @@ -309,10 +398,22 @@ jobs: mkdir -p "$target_dir" cp "native/resource-monitor/target/${{ inputs.rust_target }}/release/${binary_name}" "$target_dir/$binary_name" + - uses: ./.github/actions/setup-apt-mirrors + if: inputs.platform == 'linux' + + # The Linux archive ships libatomic.so.1 and points t3's RUNPATH at it. + - name: Install patchelf + if: inputs.cli_archive && inputs.platform == 'linux' + shell: bash + run: command -v patchelf || { sudo apt-get update && sudo apt-get install -y patchelf; } + - name: Build CLI archive if: inputs.cli_archive shell: bash env: + pnpm_config_cache_dir: ${{ steps.stage_pnpm.outputs.dir }}/cache + pnpm_config_store_dir: ${{ steps.stage_pnpm.outputs.dir }}/store + pnpm_config_prefer_offline: "true" APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} @@ -349,9 +450,6 @@ jobs: path: release-cli/* if-no-files-found: error - - uses: ./.github/actions/setup-apt-mirrors - if: inputs.platform == 'linux' - - name: Install Linux desktop build libraries if: inputs.platform == 'linux' shell: bash @@ -432,7 +530,10 @@ jobs: - name: Build desktop artifact shell: bash env: - pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata + # pnpm only goes to the registry for packages or versions the cache lacks. + pnpm_config_cache_dir: ${{ steps.stage_pnpm.outputs.dir }}/cache + pnpm_config_store_dir: ${{ steps.stage_pnpm.outputs.dir }}/store + pnpm_config_prefer_offline: "true" T3CODE_DESKTOP_REUSE_RESOURCE_MONITOR: true T3CODE_DESKTOP_REUSE_LINUX_CAPTURE_HELPERS: ${{ steps.capture_helper_cache.outputs.cache-hit == 'true' }} CSC_LINK: ${{ secrets.CSC_LINK }} @@ -517,6 +618,55 @@ jobs: vp run dist:desktop:artifact "${args[@]}" + # Warm installs only touch the store index, so new content means a fetch. + - name: Check staged install cache for new content + id: stage_pnpm_changed + shell: bash + run: | + dir="${{ steps.stage_pnpm.outputs.dir }}" + new=$(find "$dir/cache/v12" "$dir/store/v11/files" -type f -newer "$RUNNER_TEMP/desktop-stage-pnpm.marker" 2>/dev/null | wc -l | tr -d ' ') + echo "Staged installs added $new cached files." + echo "changed=$([[ "$new" -gt 0 ]] && echo true || echo false)" >> "$GITHUB_OUTPUT" + + - name: Save staged install cache + if: steps.stage_pnpm_changed.outputs.changed == 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: ${{ steps.stage_pnpm.outputs.dir }} + key: ${{ steps.stage_pnpm_cache.outputs.cache-primary-key }} + + # A build can package fine and still not open (a missing module in the + # backend or the bundle). This launches the app from the shipped + # AppImage or DMG. + - name: Launch check + if: inputs.launch_check + shell: bash + run: | + set -euo pipefail + check=(node scripts/smoke-desktop-app.ts --artifacts-dir "$RUNNER_TEMP/desktop-launch-check") + if [[ "${{ inputs.platform }}" == "mac" ]]; then + mount="$RUNNER_TEMP/desktop-launch-dmg" + hdiutil attach -nobrowse -readonly -mountpoint "$mount" "$(ls "$GITHUB_WORKSPACE"/release/*.dmg)" + trap 'hdiutil detach "$mount" -force >/dev/null || true' EXIT + app=$(ls -d "$mount"/*.app) + executable=$(/usr/libexec/PlistBuddy -c "Print :CFBundleExecutable" "$app/Contents/Info.plist") + "${check[@]}" --executable "$app/Contents/MacOS/$executable" + else + sudo apt-get install -y xvfb + appimage=$(ls "$GITHUB_WORKSPACE"/release/*.AppImage) + chmod +x "$appimage" + (cd "$RUNNER_TEMP" && "$appimage" --appimage-extract >/dev/null) + xvfb-run -a "${check[@]}" --executable "$RUNNER_TEMP/squashfs-root/t3code" + fi + + - name: Upload launch check output + if: failure() && inputs.launch_check + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: desktop-launch-check-${{ inputs.platform }}-${{ inputs.arch }} + path: ${{ runner.temp }}/desktop-launch-check + if-no-files-found: ignore + # The verify job runs what this host could not: the packaged natives and # the CLI. Its scripts are bundled so that job needs no install. - name: Stage verification inputs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3d3baf127a939..c132d67331092 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -131,6 +131,8 @@ jobs: - name: Setup dependencies uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} env: pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata @@ -245,6 +247,8 @@ jobs: - name: Setup dependencies uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} - name: Ensure Electron runtime is installed run: vp run --filter @t3tools/desktop ensure:electron @@ -275,6 +279,8 @@ jobs: - name: Setup dependencies uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} - name: Ensure Electron runtime is installed run: vp run --filter @t3tools/desktop ensure:electron @@ -309,6 +315,8 @@ jobs: - name: Setup dependencies uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} - name: Resolve Playwright version id: playwright_version @@ -336,6 +344,12 @@ jobs: - name: Test run: vp run --filter @t3tools/web test + # Browser tests run source modules; this boots the production chunk graph. + - name: Boot the built web client + run: | + vp run --filter @t3tools/web build + node apps/web/scripts/verify-built-boot.ts + # Server test files run one at a time, so more small runners beat fewer big ones. test_server: name: Release server tests ${{ matrix.shard }} @@ -359,6 +373,8 @@ jobs: - name: Setup dependencies uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} # No Electron setup here: `t3` (apps/server) has no Electron dependency # and none of its tests touch the runtime. Only the non-server `test` @@ -379,8 +395,12 @@ jobs: (github.event_name != 'schedule' || needs.resolve_commit.outputs.has_changes == 'true') runs-on: ubuntu-24.04 timeout-minutes: 5 + # Previews build any branch, but production admits only main and v* tags. + # The empty name runs previews with no environment, so their Clerk and relay + # ids come from .env.example. Everything else matches nightly: the tracing + # read below needs only repository-level Cloudflare credentials. environment: - name: production + name: ${{ inputs.channel != 'preview' && 'production' || '' }} outputs: clerk_publishable_key: ${{ steps.public_config.outputs.clerk_publishable_key }} clerk_jwt_template: ${{ steps.public_config.outputs.clerk_jwt_template }} @@ -404,14 +424,10 @@ jobs: !/.repos/ sparse-checkout-cone-mode: false - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps with: - node-version-file: package.json - cache: true - run-install: | - args: - - --filter=t3code-relay... + install-args: --filter=t3code-relay... # The deployed stack's outputs, read from Alchemy's state store without # planning or applying. Select the backend explicitly to avoid loading @@ -450,9 +466,19 @@ jobs: - id: public_config name: Resolve production relay public config shell: bash + env: + PREVIEW: ${{ inputs.channel == 'preview' }} run: | set -euo pipefail + if [[ "$PREVIEW" == "true" ]]; then + env_example() { sed -n "s|^T3CODE_$1=||p" .env.example; } + CLERK_PUBLISHABLE_KEY="$(env_example CLERK_PUBLISHABLE_KEY)" + CLERK_JWT_TEMPLATE="$(env_example CLERK_JWT_TEMPLATE)" + CLERK_CLI_OAUTH_CLIENT_ID="$(env_example CLERK_CLI_OAUTH_CLIENT_ID)" + RELAY_DOMAIN="$(sed -n 's|^T3CODE_RELAY_URL=https://||p' .env.example)" + fi + relay_domain="${RELAY_DOMAIN:-}" if [[ -z "$relay_domain" && -n "${RELAY_API_ZONE_NAME:-}" ]]; then relay_domain="relay.$RELAY_API_ZONE_NAME" @@ -506,13 +532,6 @@ jobs: !/.repos/ sparse-checkout-cone-mode: false - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 - with: - node-version-file: package.json - cache: true - run-install: false - # pnpm checks the lockfile and policy before reusing this result. A missing # artifact leaves the cache empty, so installation runs the checks again. - name: Download dependency verification @@ -522,10 +541,12 @@ jobs: name: release-dependency-verification path: ${{ runner.temp }}/pnpm-metadata - - name: Install bundle dependencies + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps + with: + install-args: --filter=t3... --filter=@t3tools/web... --filter=@t3tools/desktop... --filter=@t3tools/scripts... env: pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata - run: vp install --filter=t3... --filter=@t3tools/web... --filter=@t3tools/desktop... --filter=@t3tools/scripts... - name: Download relay client tracing config uses: actions/download-artifact@v8 @@ -603,6 +624,7 @@ jobs: rust_target: aarch64-apple-darwin resource_key: darwin-arm64 cli_archive: true + launch_check: true desktop_mac_x64: name: Desktop macOS x64 @@ -652,6 +674,7 @@ jobs: rust_target: x86_64-unknown-linux-gnu resource_key: linux-x64 cli_archive: true + launch_check: true # node-pty has no Linux prebuild and compiles from source, so the arm64 app # and archive are built on arm64 hardware rather than cross-built. @@ -753,24 +776,25 @@ jobs: timeout-minutes: 30 permissions: contents: write + # Release notes read PR bodies and authors. + pull-requests: read steps: - name: Checkout uses: actions/checkout@v6 with: ref: ${{ needs.preflight.outputs.ref }} + # Release notes need history back to the previous tag, not file contents. + fetch-depth: 0 + filter: blob:none sparse-checkout: | /* !/.repos/ sparse-checkout-cone-mode: false - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps with: - node-version-file: package.json - cache: true - run-install: | - args: - - --filter=@t3tools/scripts... + install-args: --filter=@t3tools/scripts... - name: Download all desktop artifacts uses: actions/download-artifact@v8 @@ -887,6 +911,27 @@ jobs: echo 'EOF' } >> "$GITHUB_OUTPUT" + # A short model summary over GitHub's full list. Without a notes file, the + # release falls back to GitHub's generated notes. + - name: Write release notes + if: needs.preflight.outputs.release_channel != 'preview' && needs.preflight.outputs.previous_tag != '' + continue-on-error: true + timeout-minutes: 5 + env: + GH_TOKEN: ${{ github.token }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + run: | + node scripts/generate-release-notes.ts \ + --repository "${{ github.repository }}" \ + --tag "${{ needs.preflight.outputs.tag }}" \ + --ref "${{ needs.preflight.outputs.ref }}" \ + --previous-tag "${{ needs.preflight.outputs.previous_tag }}" \ + --version "${{ needs.preflight.outputs.version }}" \ + --output release-notes.md + if [[ -f release-notes.md ]]; then + cat release-notes.md >> "$GITHUB_STEP_SUMMARY" + fi + # A preview release gets a warning instead of generated notes. Generated # notes would list every commit since the previous preview, which is # unmerged branch history no one should read as a changelog, and would @@ -912,10 +957,11 @@ jobs: tag_name: ${{ needs.preflight.outputs.tag }} target_commitish: ${{ needs.preflight.outputs.ref }} name: ${{ needs.preflight.outputs.release_name }} - generate_release_notes: ${{ needs.preflight.outputs.release_channel != 'preview' }} - body_path: ${{ needs.preflight.outputs.release_channel == 'preview' && 'release-notes.md' || '' }} + generate_release_notes: ${{ hashFiles('release-notes.md') == '' }} + body_path: ${{ hashFiles('release-notes.md') != '' && 'release-notes.md' || '' }} previous_tag: ${{ needs.preflight.outputs.previous_tag }} prerelease: ${{ needs.preflight.outputs.is_prerelease }} + draft: ${{ needs.preflight.outputs.is_prerelease }} make_latest: ${{ needs.preflight.outputs.make_latest }} files: ${{ steps.release_files.outputs.files }} fail_on_unmatched_files: true @@ -931,11 +977,23 @@ jobs: generate_release_notes: ${{ needs.preflight.outputs.release_channel != 'preview' }} body_path: ${{ needs.preflight.outputs.release_channel == 'preview' && 'release-notes.md' || '' }} prerelease: ${{ needs.preflight.outputs.is_prerelease }} + draft: ${{ needs.preflight.outputs.is_prerelease }} make_latest: ${{ needs.preflight.outputs.make_latest }} files: ${{ steps.release_files.outputs.files }} fail_on_unmatched_files: true token: ${{ github.token }} + # The action keeps a stable release a draft until its assets finish uploading, but publishes a + # prerelease first. Desktop clients update connected servers from the CLI archives as soon as + # they see the release, so a prerelease also goes public only once every asset is attached. + - name: Publish prerelease + if: needs.preflight.outputs.is_prerelease == 'true' + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + RELEASE_TAG: ${{ needs.preflight.outputs.tag }} + run: gh release edit "$RELEASE_TAG" --draft=false + # The `t3` npm package is a launcher that downloads this version's CLI # archive from the GitHub Release, so it publishes only once that release # exists. Preview publishes too, under the `preview` dist-tag, which @@ -959,15 +1017,10 @@ jobs: !/.repos/ sparse-checkout-cone-mode: false - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps with: - node-version-file: package.json - cache: true - run-install: | - args: - - --filter=t3... - - --filter=@t3tools/scripts... + install-args: --filter=t3... --filter=@t3tools/scripts... - name: Build the npm launcher run: node scripts/build-npm-launcher.ts --version "${{ needs.preflight.outputs.version }}" --output-dir npm-packages @@ -1023,15 +1076,10 @@ jobs: !/.repos/ sparse-checkout-cone-mode: false - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps with: - node-version-file: package.json - cache: true - run-install: | - args: - - --filter=@t3tools/scripts... - - --filter=@t3tools/web... + install-args: --filter=@t3tools/scripts... --filter=@t3tools/web... - name: Download relay client tracing config uses: actions/download-artifact@v8 @@ -1127,11 +1175,9 @@ jobs: sparse-checkout-cone-mode: false - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + uses: ./.github/actions/setup-ci-deps with: - node-version-file: package.json - cache: false - run-install: false + install: false - name: Alias channel shell: bash @@ -1198,14 +1244,10 @@ jobs: !/.repos/ sparse-checkout-cone-mode: false - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps with: - node-version-file: package.json - cache: true - run-install: | - args: - - --filter=@t3tools/marketing... + install-args: --filter=@t3tools/marketing... - id: deploy name: Build staged deployment @@ -1269,11 +1311,9 @@ jobs: sparse-checkout-cone-mode: false - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + uses: ./.github/actions/setup-ci-deps with: - node-version-file: package.json - cache: false - run-install: false + install: false - name: Promote marketing site shell: bash @@ -1328,15 +1368,10 @@ jobs: echo "name=${APP_SLUG}[bot]" >> "$GITHUB_OUTPUT" echo "email=${user_id}+${APP_SLUG}[bot]@users.noreply.github.com" >> "$GITHUB_OUTPUT" - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps with: - node-version-file: package.json - cache: true - run-install: | - args: - - --filter=@t3tools/scripts... - - --filter=@t3tools/oxlint-plugin-t3code... + install-args: --filter=@t3tools/scripts... --filter=@t3tools/oxlint-plugin-t3code... - id: update_versions name: Update version strings @@ -1367,7 +1402,7 @@ jobs: git config user.email "${{ steps.app_bot.outputs.email }}" git add apps/server/package.json apps/desktop/package.json apps/web/package.json packages/contracts/package.json pnpm-lock.yaml - git commit -m "chore(release): prepare $RELEASE_TAG" + git commit --no-verify -m "chore(release): prepare $RELEASE_TAG" git push origin HEAD:main announce_discord: @@ -1393,14 +1428,10 @@ jobs: !/.repos/ sparse-checkout-cone-mode: false - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps with: - node-version-file: package.json - cache: true - run-install: | - args: - - --filter=@t3tools/scripts... + install-args: --filter=@t3tools/scripts... - name: Read published release notes if: needs.preflight.outputs.is_prerelease == 'true' diff --git a/.github/workflows/stale-needs-more-info.yml b/.github/workflows/stale-needs-more-info.yml new file mode 100644 index 0000000000000..b7b475a6117dc --- /dev/null +++ b/.github/workflows/stale-needs-more-info.yml @@ -0,0 +1,30 @@ +name: Close stale "needs more info" issues + +on: + schedule: + - cron: "17 6 * * *" + workflow_dispatch: + +permissions: + actions: write + issues: write + +jobs: + stale: + runs-on: ubuntu-latest + steps: + - uses: actions/stale@v9 + with: + only-issue-labels: "needs more info" + # The label is applied by the triage flow, so never auto-mark; close 7 days after it was added. + days-before-issue-stale: -1 + days-before-issue-close: 7 + days-before-pr-stale: -1 + days-before-pr-close: -1 + remove-issue-stale-when-updated: true + stale-issue-label: "needs more info" + close-issue-reason: not_planned + close-issue-message: >- + Closing: no reply in 7 days after we asked for more information. + Comment with the missing details and we will reopen. + operations-per-run: 200 diff --git a/.github/workflows/web-preview.yml b/.github/workflows/web-preview.yml index ab903caba617e..dfbc7389ea3e8 100644 --- a/.github/workflows/web-preview.yml +++ b/.github/workflows/web-preview.yml @@ -82,7 +82,8 @@ jobs: --archive=tgz \ --yes \ --token "$VERCEL_TOKEN" \ - --scope "$vercel_scope" + --scope "$vercel_scope" \ + --build-env "VITE_RELEASE_CHANNEL=preview" )" printf '%s\n' "$deployment_output" # Vercel can print lockfile warnings to stdout before the deployment URL. diff --git a/.gitignore b/.gitignore index 4210f857c70a2..73659bfc9657a 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,8 @@ apps/mobile/.generated/ apps/mobile/.android-cache/ /.generated/ artifacts/app-store/screenshots/ +artifacts/mobile-e2e/ +artifacts/mobile-behavior/ .github/pr-assets/ native/**/target/ node_modules/ diff --git a/.macroscope/check-run-agents/effect-service-conventions.md b/.macroscope/check-run-agents/effect-service-conventions.md index f7cef3929ab10..f005f899595f5 100644 --- a/.macroscope/check-run-agents/effect-service-conventions.md +++ b/.macroscope/check-run-agents/effect-service-conventions.md @@ -1,7 +1,7 @@ --- title: Effect Service Conventions -model: gpt-6-sol -effort: max +model: gpt-6-1-sol +reasoning: low input: incremental tools: - browse_code diff --git a/.macroscope/check-run-agents/test-signal.md b/.macroscope/check-run-agents/test-signal.md new file mode 100644 index 0000000000000..5c5319aafb739 --- /dev/null +++ b/.macroscope/check-run-agents/test-signal.md @@ -0,0 +1,27 @@ +--- +title: Test Signal +model: gpt-6-1-terra +reasoning: low +input: incremental +tools: + - browse_code + - modify_pr +# An explicit include overrides ignore.md, which skips test files for every other check. +include: + - "**/*.test.*" +exclude: + - ".repos/**" +requires: + - Check +maxBudgetPerRun: 1 +maxBudgetPerPR: 5 +conclusion: neutral +--- + +# Test signal review + +Review only the tests this pull request adds or grows. Tests it moves, renames, or deletes are not findings. The rules are in `docs/internals/testing.md` under `## What to test`; read that section first and hold new tests to it. + +Post each finding as a brief inline comment on the new test with one fix. Ask only for fewer or smaller tests, never for more coverage. + +When there are no findings, make the entire final response exactly `All clear` on one line with nothing else. diff --git a/.macroscope/check-run-agents/ui-consistency.md b/.macroscope/check-run-agents/ui-consistency.md index ab37120ff344f..905823d1d1a9e 100644 --- a/.macroscope/check-run-agents/ui-consistency.md +++ b/.macroscope/check-run-agents/ui-consistency.md @@ -1,7 +1,7 @@ --- title: UI Consistency -model: gpt-6-sol -effort: max +model: gpt-6-1-sol +reasoning: low input: incremental tools: - browse_code @@ -44,7 +44,7 @@ The reference for how this codebase wants UI built is the composer banner system - `ComposerBanner.tsx` exports one object of small slot components (`Root`, `Row`, `Icon`, `Content`, `Actions`, `Dismiss`, `Children`, and so on). Each slot owns its own Tailwind classes, exposes a `data-slot` attribute, takes `className` and spreads the rest of its props, and uses `cn` so callers can adjust without overriding. Variants and density are props on `Root`, not ad hoc class strings at call sites. Where a slot needs a real control it renders `Button` from `ui/` rather than a styled `