diff --git a/.agents/skills/contribution-triage/SKILL.md b/.agents/skills/contribution-triage/SKILL.md index 21ba3b7a6566a..e5325c398bc48 100644 --- a/.agents/skills/contribution-triage/SKILL.md +++ b/.agents/skills/contribution-triage/SKILL.md @@ -71,13 +71,12 @@ from evidence you could not access. Do not run untrusted PR code merely to decid Read the guide's linked sections before applying these checks. Inspect enough source to substantiate scope and behavioral claims; leave the full correctness, security, and performance audit to code review. -- Under [prior approval](../../../CONTRIBUTING.md#prior-approval), identify the actual failure and - intended behavior. Inspect maintainer responses in the linked issue or discussion, including what - direction and scope they approved. A link, label, or acknowledgment alone is not approval. Judge a - claimed obvious-bug exception by purpose, impact, and necessary changes, without numeric cutoffs. - Separately assess focused configuration of an established capability: identify what already exists, - what the option controls, and its effects and necessary scope. This route does not require a proven - obvious bug. Adding a setting alone establishes neither a new feature nor an approval exemption. +- Under [fix a bug](../../../CONTRIBUTING.md#prior-approval), identify the actual failure and + intended behavior. A feature, or anything on the guide's not-interested list, warrants closure + unless a maintainer in the trusted exemption list asked for that PR. Inspect the request and the + direction and scope it covers. A link, label, or acknowledgment alone is not a request. Judge a + claimed bug fix by purpose, impact, and necessary changes, without numeric cutoffs. A setting that + adds a workflow or changes a default is a feature. Check whether an alleged fix intentionally changes product behavior or overlooks an existing workflow. Preserve any useful documentation, onboarding, or discoverability problem when declining the solution. - Under [one problem](../../../CONTRIBUTING.md#one-problem), trace how each material change contributes @@ -90,27 +89,18 @@ scope and behavioral claims; leave the full correctness, security, and performan as the guide requires them. Do not demand unrelated evidence or repo-wide tests. An unavailable local platform does not defeat adequate contributor evidence. Do not infer fabrication or AI authorship from writing style, suspicion, or a check you cannot reproduce. +- Under [models](../../../CONTRIBUTING.md#models), use only the model and harness the PR names, + usually in its closing attribution line. -### Configuration and workflow examples - -- Hosting CLI-path configuration in [#11653](https://github.com/pingdotgg/t3code/pull/11653) is eligible - for deeper review under the maintainer's ruling: it makes an existing capability configurable. - It need not qualify as an obvious-bug repair or obtain prior feature approval on that basis. - Still assess one underlying problem, necessary scope and credible verification. Deeper review can - reject the configuration mechanism or its implementation. -- Preserving Files as an independent tab in [#14436](https://github.com/pingdotgg/t3code/pull/14436) - changes tab lifetime and navigation. The maintainer classified it as a broader workflow change - requiring prior product-direction approval, which is absent. Propose closure for missing approval - in a dry run, or carry out closure in authorized enforcement. Its good evidence does not make it - eligible or justify keeping it pending after that ruling. The remedy is to obtain scope approval. - -Use these examples to distinguish effects, not to exempt every configuration option. Apply current -trusted policy and reassess changed submission evidence; neither example grants permanent eligibility. +Bans in the guide are a maintainer decision. Report suspected spam to maintainers; do not ban or +threaten a ban. ## Apply the outcome Do not automatically close a PR if a maintainer in the trusted `TRIAGE_EXEMPTIONS.td` list has commented or submitted a review, including on earlier heads, or if `triage:keep-open` is present. +Comments that triage itself posted, such as an earlier notice or question, are not maintainer +involvement, even when they were posted from a maintainer's account on that maintainer's behalf. Leave protected PRs open for maintainer decision. This does not grant an author exemption, eligibility, or review approval. @@ -126,10 +116,20 @@ comment text but do not write to GitHub. Keep the eligibility finding separate f This is a routing exception, not a claim that the PR passed eligibility or correctness review. - **Closure warranted.** The assessment is complete and establishes a specific policy violation. Prepare a clear explanation under [closure and reconsideration](../../../CONTRIBUTING.md#closure-and-reconsideration), - post it, and close the PR. Verify that the explanation is present and the PR is closed. The comment + post it, then read the repository variable `PR_LIMITS_ENFORCE` + (`gh variable get PR_LIMITS_ENFORCE -R pingdotgg/t3code`). When it prints `true`, apply + `triage:closing` as the last thing you do to the PR, verify that the explanation and the label + are present, and do not close the PR yourself: + [pr-limits.yml](../../../.github/workflows/pr-limits.yml) closes it 72 hours after the label. Any + activity on the PR after the label, including a further comment or label from you, removes the + label; treat an author's reply or push as new evidence, reassess, and apply the label again only + if the finding still holds. When the command prints any other value, or fails saying the variable + was not found, the variable is off and nothing would close a labeled PR: close it yourself and + verify that the explanation is present and the PR is closed. Only another failure, such as a + permission error, means the variable cannot be read; then report the closure as incomplete. The comment must name the violated rule, cite supporting submission evidence, link the maintained guide section, and give a concrete remedy. Missing contributor evidence can warrant closure; explain what must be - established. Missing product approval calls for maintainer discussion, not an agent's product decision. + established. The remedy for a feature is an Ideas discussion, not an agent's product decision. Close for multiple problems only when the independence of those changes is supported. Closing a PR does not require a configured Macroscope label. - **Needs explanation or maintainer decision.** Name the unresolved question and who can resolve it. @@ -137,9 +137,9 @@ comment text but do not write to GitHub. Keep the eligibility finding separate f was posted. If tracing the source leaves an extra diff's necessity unclear, request the causal explanation needed. If established facts leave a product-direction choice to maintainers, identify that choice. Leave the PR open and pending that answer; do not mark it eligible. Do not substitute - "the agent was uncertain" for a violated rule. Once maintainers establish that a workflow change - requires approval and that approval is absent, apply the closure outcome instead of retaining a - pending classification. Good verification does not cure missing approval. + "the agent was uncertain" for a violated rule. Once a change is established as a feature with no + maintainer request, apply the closure outcome instead of retaining a pending classification. Good + verification does not make a feature eligible. - **Incomplete; retry required.** State the failed retrieval, missing access, truncated diff, or unfinished assessment and what is needed to resume. Do not convert operational failures into policy violations or hand the PR off as having passed. An inaccessible artifact is different from an omitted artifact. @@ -173,9 +173,10 @@ description, evidence, approvals, existing triage comments, PR open/closed state label when relevant. Reassess changes that could invalidate the finding. Reuse an existing explanation only if it still matches the current finding; avoid duplicate comments, closures, or label applications. -For closure, establish that the required explanation is posted before closing. If posting fails or its -result is ambiguous, read back the comments before retrying or proceeding. If the comment succeeds but -closure fails, preserve the comment and retry only the unfinished closure after checking current state. +For closure, establish that the required explanation is posted before applying `triage:closing` or +closing. If posting fails or its result is ambiguous, read back the comments before retrying or +proceeding. If the comment succeeds but the label or the closure fails, preserve the comment and retry +only that unfinished step after checking current state. Handle a failed or ambiguous label application the same way: inspect labels before any retry. If access or state still cannot be verified, report an incomplete action and the step needed to resume. Do not retry blindly or claim success for an unverified action. diff --git a/.agents/skills/test-t3-mobile/SKILL.md b/.agents/skills/test-t3-mobile/SKILL.md index f48bb0377ad94..a11eee4e3f4eb 100644 --- a/.agents/skills/test-t3-mobile/SKILL.md +++ b/.agents/skills/test-t3-mobile/SKILL.md @@ -36,16 +36,22 @@ vp run mobile:install 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 +when no machine has that fingerprint yet: in the `Mobile Dev Client` workflow +on Blacksmith when HEAD is pushed and `gh` may dispatch it (the command waits), +otherwise here (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. +cloud, or remote host), run `vp run mobile:install --session +--config ` from the directory you opened the session in, passing the +`--config` file from `device_open`'s `targetArgs`. Without it, a device pool's +gateway refuses the install, wrongly saying agent-device is too old. It installs +the cached build into that session (`--abi arm64-v8a` for an ARM Android device; +pool Android emulators are x86_64, the default; `--metro ` also opens it +against Metro). Without a cached build for this checkout it builds first, which can +outlast the lease, so run `vp run mobile:install --prepare` (with +the same `--abi`) before you open the device. 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/.github/SECURITY.md b/.github/SECURITY.md index 69dc53dc4393f..56e3f03525e01 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -1,7 +1,7 @@ # Security policy Report security vulnerabilities affecting T3 Code or T3 Tools-operated infrastructure to -[security@ping.gg](mailto:security@ping.gg). Please do not disclose them publicly until we have had +[security@t3.codes](mailto:security@t3.codes). Please do not disclose them publicly until we have had a reasonable opportunity to investigate and remediate them. See the [full security policy](https://t3.codes/security-policy) for reporting details, scope, diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 1c4ae817060a9..315ff5742fd5c 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -22,6 +22,7 @@ github:btsouth github:cestercian github:chrisdeeming github:chuks-qua +-github:coygeek Filed over 270 automated issues, about 50 a day at peak, far past what maintainers can triage github:cursoragent github:D3OXY github:dbalders @@ -83,6 +84,7 @@ github:tris203 github:tsouth89 github:UtkarshUsername github:vitalyiegorov +-github:wSedlacek Opened over 130 unapproved PRs in 12 days, far past the open PR limit github:Yash-Singh1 github:yashranaway github:Ymit24 diff --git a/.github/actions/ci-changes/action.yml b/.github/actions/ci-changes/action.yml index ecf787404657a..7d2adc0c9e035 100644 --- a/.github/actions/ci-changes/action.yml +++ b/.github/actions/ci-changes/action.yml @@ -5,6 +5,8 @@ outputs: value: ${{ steps.detect.outputs.typecheck }} build: value: ${{ steps.detect.outputs.build }} + desktop_launch: + value: ${{ steps.detect.outputs.desktop_launch }} test: value: ${{ steps.detect.outputs.test }} test_packages: diff --git a/.github/actions/setup-ci-deps/action.yml b/.github/actions/setup-ci-deps/action.yml index e9b7ae70a36ad..cc6dc80511aca 100644 --- a/.github/actions/setup-ci-deps/action.yml +++ b/.github/actions/setup-ci-deps/action.yml @@ -13,6 +13,10 @@ inputs: description: Arguments for `vp install`, such as filters. Empty installs the whole workspace. required: false default: "" + save-pnpm-store: + description: Set to "false" when a concurrent job with the same install args saves the pnpm store + required: false + default: "true" runs: using: composite steps: @@ -31,7 +35,7 @@ runs: - name: Cache Vite+ id: vp_cache if: steps.vp.outputs.version != '' - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.vite-plus key: vite-plus-home-v2-${{ runner.os }}-${{ runner.arch }}-${{ steps.vp.outputs.version }}-node${{ steps.vp.outputs.node }}-${{ steps.vp.outputs.pnpm }} @@ -52,7 +56,7 @@ runs: # partial bytes, so a shorter limit costs a slow download only a retry. - name: Setup Vite+ if: steps.vp_cache.outputs.cache-hit != 'true' - uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1 + uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1.15.0 env: VP_DOWNLOAD_TIMEOUT: "120" with: @@ -88,7 +92,7 @@ runs: - name: Restore pnpm store id: pnpm_cache if: inputs.install == 'true' - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ steps.pnpm_store.outputs.path }} key: pnpm-${{ steps.pnpm_store.outputs.namespace }}-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', 'package.json') }} @@ -102,7 +106,7 @@ runs: - name: Restore lockfile verification id: pnpm_verified if: inputs.install == 'true' - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ steps.pnpm_store.outputs.verified }} key: pnpm-verified-v1-${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', 'package.json') }} @@ -117,20 +121,24 @@ runs: - name: Save lockfile verification if: inputs.install == 'true' && steps.pnpm_verified.outputs.cache-hit != 'true' - uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 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. + # worth a second copy of it. A stacked PR, based on a branch other than the + # default, saves a ~1 GB store only its own reruns can read, so it only restores. + # Concurrent jobs with the same install args reserve one key, so the losers + # compress the store for nothing; all but one pass save-pnpm-store: false. - name: Save pnpm store if: >- - inputs.install == 'true' && + inputs.install == 'true' && inputs.save-pnpm-store == 'true' && + (github.event_name != 'pull_request' || github.base_ref == github.event.repository.default_branch) && 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 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ steps.pnpm_store.outputs.path }} key: ${{ steps.pnpm_cache.outputs.cache-primary-key }} diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 4a66b5d268103..94157c4135fac 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -15,16 +15,11 @@ reproduction steps or environment where relevant. --> -## Scope and approval - - +## Scope + + ## Verification diff --git a/.github/readme/hero.webp b/.github/readme/hero.webp new file mode 100644 index 0000000000000..8cc713bf541d5 Binary files /dev/null and b/.github/readme/hero.webp differ diff --git a/.github/scripts/check-nightly-release.cjs b/.github/scripts/check-nightly-release.cjs index 943bf116909d2..a9502b26c8c81 100644 --- a/.github/scripts/check-nightly-release.cjs +++ b/.github/scripts/check-nightly-release.cjs @@ -40,42 +40,67 @@ async function assertReleaseSource({ github, context, releaseChannel }) { const isNightlyTag = (tag) => /^v.*-nightly\./.test(tag) || tag.startsWith("nightly-v"); -async function findLatestNightly({ github, context }) { - const releases = await github.paginate(github.rest.repos.listReleases, { - ...context.repo, - per_page: 100, - }); - return releases - .filter((release) => !release.draft && release.published_at && isNightlyTag(release.tag_name)) - .sort((a, b) => Date.parse(b.published_at) - Date.parse(a.published_at))[0]; +// Only the three fields the gate reads. The REST list also returns every asset, +// which made a full scan tens of megabytes. +const RELEASES_QUERY = ` + query ($owner: String!, $repo: String!, $cursor: String) { + repository(owner: $owner, name: $repo) { + releases(first: 100, after: $cursor, orderBy: { field: CREATED_AT, direction: DESC }) { + pageInfo { hasNextPage endCursor } + nodes { tagName isDraft publishedAt } + } + } + } +`; + +// Returns the nightly with the newest publishedAt. Release order follows the +// tagged commit date, not publication, so every page is read unless `isSettled` +// says the newest nightly seen so far already decides the caller's answer. +async function findLatestNightly({ github, context, isSettled = () => false }) { + let latest; + let cursor = null; + do { + const { repository } = await github.graphql(RELEASES_QUERY, { ...context.repo, cursor }); + const { nodes, pageInfo } = repository.releases; + for (const release of nodes) { + if (release.isDraft || !release.publishedAt || !isNightlyTag(release.tagName)) continue; + if (!latest || Date.parse(release.publishedAt) > Date.parse(latest.publishedAt)) { + latest = release; + } + } + if (latest && isSettled(latest)) return latest; + cursor = pageInfo.hasNextPage ? pageInfo.endCursor : null; + } while (cursor); + return latest; } async function shouldReleaseNightly({ github, context, core, now = Date.now() }) { - const lastNightly = await findLatestNightly({ github, context }); + const isInGap = (release) => now - Date.parse(release.publishedAt) < MINIMUM_RELEASE_GAP_MS; + // Any nightly inside the gap means the newest one is too, so stop reading. + const lastNightly = await findLatestNightly({ github, context, isSettled: isInGap }); if (!lastNightly) { core.info("No published nightly found. Proceeding with release."); return true; } - if (now - Date.parse(lastNightly.published_at) < MINIMUM_RELEASE_GAP_MS) { - core.info(`Nightly ${lastNightly.tag_name} was published less than six hours ago. Skipping.`); + const tag = lastNightly.tagName; + if (isInGap(lastNightly)) { + core.info(`Nightly ${tag} was published less than six hours ago. Skipping.`); return false; } const { data: comparison } = await github.rest.repos.compareCommitsWithBasehead({ ...context.repo, - basehead: `${lastNightly.tag_name}...${context.sha}`, + basehead: `${tag}...${context.sha}`, per_page: 1, }); if (comparison.status !== "ahead") { - core.info( - `Candidate commit is ${comparison.status} relative to ${lastNightly.tag_name}. Skipping.`, - ); + core.info(`Candidate commit is ${comparison.status} relative to ${tag}. Skipping.`); return false; } - core.info(`New commits since ${lastNightly.tag_name}, and the six-hour gap has passed.`); + core.info(`New commits since ${tag}, and the six-hour gap has passed.`); return true; } @@ -85,7 +110,7 @@ async function resolveLatestNightlyCommit({ github, context, core }) { throw new Error("No published nightly found. Stable releases build the latest nightly commit."); } - const tag = lastNightly.tag_name; + const tag = lastNightly.tagName; const { data: commit } = await github.rest.repos.getCommit({ ...context.repo, ref: tag }); const version = /^(?:nightly-)?v(\d+\.\d+\.\d+)-nightly\./.exec(tag)?.[1]; if (!version) { @@ -96,9 +121,54 @@ async function resolveLatestNightlyCommit({ github, context, core }) { return { tag, sha: commit.sha, version }; } +// The two places a nightly install looks for an update: the `update-feed` pointer, and +// releases.atom, which holds the 10 newest git tags of any kind. Older installs take the first +// nightly in the feed. Reports what each names beside the newest published nightly. +async function readNightlyUpdateFeed({ github, context, fetch = globalThis.fetch }) { + const latest = await findLatestNightly({ github, context }); + if (!latest) return {}; + + const base = `https://github.com/${context.repo.owner}/${context.repo.repo}`; + // Only a missing or malformed pointer is stale. Rewriting deletes the old file first, so a + // GitHub outage must not look like one. + // Node waits five minutes for a stalled response, which is this job's whole time limit. + const signal = () => AbortSignal.timeout(30_000); + const pointer = await fetch(`${base}/releases/download/update-feed/nightly.json`, { + signal: signal(), + }); + if (!pointer.ok && pointer.status !== 404) { + throw new Error(`The update-feed pointer answered HTTP ${pointer.status}.`); + } + let pointerTag; + if (pointer.ok) { + // A body that fails to arrive throws here; only one that is not JSON counts as malformed. + const body = await pointer.text(); + try { + pointerTag = JSON.parse(body)?.tag_name; + } catch {} + } + + // An unreadable feed leaves `feedTags` unset, so the pointer is still repaired. An error page + // can answer HTTP 200, so only an Atom document counts as read. + let feedTags; + try { + const feed = await fetch(`${base}/releases.atom`, { signal: signal() }); + const body = feed.ok ? await feed.text() : ""; + if (/]/.test(body)) { + feedTags = Array.from(body.matchAll(/href="[^"]*\/releases\/tag\/([^"]+)"/g), (match) => + decodeURIComponent(match[1]), + ); + } + } catch {} + const feedNightly = feedTags?.find((tag) => /^v?\d+\.\d+\.\d+-nightly\./.test(tag)); + + return { tag: latest.tagName, pointerTag, feedNightly, feedTags }; +} + module.exports = { assertCommitOnDefaultBranch, assertReleaseSource, shouldReleaseNightly, resolveLatestNightlyCommit, + readNightlyUpdateFeed, }; diff --git a/.github/scripts/check-nightly-release.test.cjs b/.github/scripts/check-nightly-release.test.cjs index f55d29c10dc6a..2f3d385755ca6 100644 --- a/.github/scripts/check-nightly-release.test.cjs +++ b/.github/scripts/check-nightly-release.test.cjs @@ -9,9 +9,9 @@ const { const now = Date.parse("2026-09-05T12:00:00Z"); const hour = 60 * 60 * 1000; const nightly = (hoursAgo, overrides = {}) => ({ - tag_name: "v1.0.1-nightly.20260905.123", - draft: false, - published_at: new Date(now - hoursAgo * hour).toISOString(), + tagName: "v1.0.1-nightly.20260905.123", + isDraft: false, + publishedAt: new Date(now - hoursAgo * hour).toISOString(), ...overrides, }); @@ -73,10 +73,13 @@ for (const comparisonStatus of ["behind", "diverged"]) { }); } -function fixture({ releases = [nightly(7)], comparisonStatus = "ahead" } = {}) { +// `pages` holds the GraphQL release pages; `releases` is shorthand for one page. +function fixture({ releases = [nightly(7)], pages = [releases], comparisonStatus = "ahead" } = {}) { const calls = []; + const cursors = []; return { calls, + cursors, options: { now, context: { repo: { owner: "example", repo: "app" }, sha: "new" }, @@ -84,15 +87,25 @@ function fixture({ releases = [nightly(7)], comparisonStatus = "ahead" } = {}) { github: { rest: { repos: { - listReleases() {}, async compareCommitsWithBasehead(params) { calls.push(params); return { data: { status: comparisonStatus } }; }, }, }, - async paginate() { - return releases; + async graphql(_query, { owner, repo, cursor }) { + assert.deepEqual({ owner, repo }, { owner: "example", repo: "app" }); + cursors.push(cursor); + const index = cursor === null ? 0 : Number(cursor); + const hasNextPage = index + 1 < pages.length; + return { + repository: { + releases: { + nodes: pages[index], + pageInfo: { hasNextPage, endCursor: hasNextPage ? String(index + 1) : null }, + }, + }, + }; }, }, }, @@ -101,28 +114,65 @@ function fixture({ releases = [nightly(7)], comparisonStatus = "ahead" } = {}) { test("releases the first nightly when no nightly is published", async () => { const { options } = fixture({ - releases: [nightly(0, { tag_name: "v1.0.0" }), nightly(0, { draft: true })], + releases: [nightly(0, { tagName: "v1.0.0" }), nightly(0, { isDraft: true })], }); assert.equal(await shouldReleaseNightly(options), true); }); test("uses publication time, not release order or the tagged commit date", async () => { const { options } = fixture({ - releases: [nightly(10), nightly(1), nightly(20, { tag_name: "nightly-v0.9.0" })], + releases: [nightly(10), nightly(1), nightly(20, { tagName: "nightly-v0.9.0" })], }); assert.equal(await shouldReleaseNightly(options), false); }); +test("the six-hour gap ends exactly six hours after publication", async () => { + const atBoundary = fixture({ releases: [nightly(6)] }); + assert.equal(await shouldReleaseNightly(atBoundary.options), true); + const justInside = fixture({ releases: [nightly(6 - 1 / 3_600_000)] }); + assert.equal(await shouldReleaseNightly(justInside.options), false); + assert.equal(justInside.calls.length, 0); +}); + +test("stops reading releases once a nightly inside the gap is seen", async () => { + const { options, cursors } = fixture({ pages: [[nightly(10), nightly(2)], [nightly(30)]] }); + assert.equal(await shouldReleaseNightly(options), false); + assert.deepEqual(cursors, [null]); +}); + +test("reads every page when no nightly seen so far settles the gap", async () => { + const { options, cursors, calls } = fixture({ + pages: [ + [nightly(10), nightly(0, { tagName: "v1.0.0" })], + [nightly(1, { tagName: "nightly-v0.9.0" })], + ], + }); + assert.equal(await shouldReleaseNightly(options), false); + assert.deepEqual(cursors, [null, "1"]); + assert.equal(calls.length, 0); +}); + +test("compares against the newest nightly across pages once the gap has passed", async () => { + const { options, calls } = fixture({ + pages: [[nightly(10, { tagName: "v1.0.1-nightly.20260905.100" })], [nightly(8)]], + }); + assert.equal(await shouldReleaseNightly(options), true); + assert.deepEqual( + calls.map((call) => call.basehead), + ["v1.0.1-nightly.20260905.123...new"], + ); +}); + test("ignores stable releases and drafts when checking the gap", async () => { const { options } = fixture({ - releases: [nightly(0, { tag_name: "v1.0.0" }), nightly(0, { draft: true }), nightly(7)], + releases: [nightly(0, { tagName: "v1.0.0" }), nightly(0, { isDraft: true }), nightly(7)], }); assert.equal(await shouldReleaseNightly(options), true); }); test("fails instead of releasing when GitHub cannot supply release state", async () => { const { options } = fixture(); - options.github.paginate = async () => { + options.github.graphql = async () => { throw new Error("GitHub unavailable"); }; await assert.rejects(shouldReleaseNightly(options), /GitHub unavailable/); @@ -137,9 +187,9 @@ for (const status of ["behind", "diverged"]) { const { resolveLatestNightlyCommit } = require("./check-nightly-release.cjs"); -function nightlyCommitFixture({ releases, commitSha = "abc123" }) { +function nightlyCommitFixture({ releases, pages, commitSha = "abc123" }) { const refs = []; - const { options } = fixture({ releases }); + const { options } = fixture({ releases, pages }); options.github.rest.repos.getCommit = async ({ ref }) => { refs.push(ref); return { data: { sha: commitSha } }; @@ -150,10 +200,10 @@ function nightlyCommitFixture({ releases, commitSha = "abc123" }) { test("stable releases resolve the commit of the newest published nightly", async () => { const { options, refs } = nightlyCommitFixture({ releases: [ - nightly(10, { tag_name: "v1.0.1-nightly.20260905.100" }), - nightly(1, { tag_name: "v1.0.1-nightly.20260905.123" }), - nightly(0, { tag_name: "v1.0.0" }), - nightly(0, { draft: true, tag_name: "v1.0.1-nightly.20260905.999" }), + nightly(10, { tagName: "v1.0.1-nightly.20260905.100" }), + nightly(1, { tagName: "v1.0.1-nightly.20260905.123" }), + nightly(0, { tagName: "v1.0.0" }), + nightly(0, { isDraft: true, tagName: "v1.0.1-nightly.20260905.999" }), ], commitSha: "deadbeef", }); @@ -167,7 +217,101 @@ test("stable releases resolve the commit of the newest published nightly", async test("stable releases derive the version from legacy nightly tags", async () => { const { options } = nightlyCommitFixture({ - releases: [nightly(1, { tag_name: "nightly-v0.9.0-nightly.20260905.5" })], + releases: [nightly(1, { tagName: "nightly-v0.9.0-nightly.20260905.5" })], }); assert.equal((await resolveLatestNightlyCommit(options)).version, "0.9.0"); }); + +test("stable releases read every page, even after a recent nightly", async () => { + const { options, refs } = nightlyCommitFixture({ + pages: [ + [nightly(2, { tagName: "v1.0.1-nightly.20260905.100" })], + [nightly(1, { tagName: "v1.0.1-nightly.20260905.123" })], + ], + }); + assert.equal((await resolveLatestNightlyCommit(options)).tag, "v1.0.1-nightly.20260905.123"); + assert.deepEqual(refs, ["v1.0.1-nightly.20260905.123"]); +}); + +test("stable releases fail when no nightly is published", async () => { + const { options } = nightlyCommitFixture({ releases: [nightly(1, { tagName: "v1.0.0" })] }); + await assert.rejects(resolveLatestNightlyCommit(options), /No published nightly found/); +}); + +const { readNightlyUpdateFeed } = require("./check-nightly-release.cjs"); + +// GitHub percent-encodes tag names in the feed, and any tag in the repository takes a slot. +const atomEntry = (tag) => + ``; +const atomFeed = (tags) => new Response(`${tags.map(atomEntry).join("")}`); +const NEWEST = "v1.0.1-nightly.20260905.123"; +const OLDER = "v1.0.1-nightly.20260905.100"; + +function updateFeedFixture({ feed, pointer }) { + const { options } = fixture({ + releases: [nightly(1), nightly(0, { tagName: "v1.0.2-preview.1" })], + }); + options.fetch = async (url) => + url === "https://github.com/example/app/releases.atom" + ? feed + : url === "https://github.com/example/app/releases/download/update-feed/nightly.json" + ? pointer + : assert.fail(`unexpected fetch ${url}`); + return options; +} + +const missing = () => new Response("Not Found", { status: 404 }); +// HTTP 200, then the connection drops before the body arrives. +const brokenBody = () => + new Response(new ReadableStream({ start: (controller) => controller.error(new Error("reset")) })); +for (const [name, { feed, pointer }, expected] of [ + [ + "a nightly crowded out of the feed and a missing pointer", + { feed: atomFeed(["v1.0.2-preview.1", "mobile-android-build/2.3.0/abc"]), pointer: missing() }, + { pointerTag: undefined, feedNightly: undefined }, + ], + [ + "the first nightly in the feed, which is the one older installs take", + { feed: atomFeed(["update-feed", OLDER, NEWEST]), pointer: Response.json({ tag_name: OLDER }) }, + { pointerTag: OLDER, feedNightly: OLDER }, + ], + [ + "a malformed pointer as stale and an unreadable feed as naming nothing", + { feed: new Response("", { status: 503 }), pointer: Response.json(null) }, + { pointerTag: undefined, feedNightly: undefined, unreadable: true }, + ], + [ + "an HTML page with release links as an unreadable feed", + { + feed: new Response( + `x`, + ), + pointer: Response.json({ tag_name: NEWEST }), + }, + { pointerTag: NEWEST, feedNightly: undefined, unreadable: true }, + ], + [ + "the pointer when the feed's body fails to arrive", + { feed: brokenBody(), pointer: Response.json({ tag_name: NEWEST }) }, + { pointerTag: NEWEST, feedNightly: undefined, unreadable: true }, + ], +]) { + test(`update feed check reports ${name}`, async () => { + const { tag, pointerTag, feedNightly, feedTags } = await readNightlyUpdateFeed( + updateFeedFixture({ feed, pointer }), + ); + const { unreadable = false, ...named } = expected; + assert.deepEqual({ tag, pointerTag, feedNightly }, { tag: NEWEST, ...named }); + assert.equal(feedTags === undefined, unreadable); + }); +} + +for (const [name, pointer, error] of [ + ["answers with an error", () => new Response("", { status: 503 }), /pointer answered HTTP 503/], + ["drops the connection", brokenBody, /reset/], +]) { + test(`update feed check does not call a pointer stale when GitHub ${name}`, async () => { + const options = updateFeedFixture({ feed: atomFeed([NEWEST]), pointer: pointer() }); + await assert.rejects(readNightlyUpdateFeed(options), error); + }); +} diff --git a/.github/scripts/ci-changes.cjs b/.github/scripts/ci-changes.cjs index ed32670755ff6..7b7332903ed9f 100644 --- a/.github/scripts/ci-changes.cjs +++ b/.github/scripts/ci-changes.cjs @@ -51,6 +51,21 @@ const BUILD_ONLY_DEPS = { t3: ["@t3tools/web"] }; 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\//; +// Startup, migration, settlement, and sidebar paths that decide whether an upgraded user's +// threads show after launch. Build runs scripts/smoke-desktop-app.ts for them, so they do not +// first fail in the release launch check. +const DESKTOP_LAUNCH = [ + /^apps\/desktop\/src\/((main|boot|compileCache|serverCompileCache)\.ts$|(app|backend|window)\/)/, + /^apps\/desktop\/src\/electron\/(ElectronApp|ElectronProtocol|ElectronWindow)\.ts$/, + /^apps\/web\/src\/((main|AppRoot|appInit)\.tsx|(bootstrap|router)\.ts)$/, + /^apps\/web\/src\/.*sidebar/i, + /^apps\/server\/src\/(bin|binMain|server|serverRuntimeStartup)\.ts$/, + /^apps\/server\/src\/persistence\/Migrations/, + /^apps\/server\/src\/orchestration-v2\/((ThreadSettlementService|ProjectionStore)\.ts$|legacy\/)/, + /^packages\/contracts\/src\/settings\.ts$/, + /^scripts\/(smoke-desktop-app\.ts|fixtures\/desktop-launch\/)/, +]; + const OWN_TEST_JOB = new Set(["t3", "@t3tools/web", "@t3tools/monorepo"]); function readWorkspace() { @@ -151,12 +166,18 @@ function decide(files, packages, full) { (pkg) => pkg.hasTest && !OWN_TEST_JOB.has(pkg.name) && affected.has(pkg.name), ); const has = (name) => affected.has(name); + const desktopLaunch = + everything || + files.some( + (file) => !/\.test\.tsx?$/.test(file) && DESKTOP_LAUNCH.some((pattern) => pattern.test(file)), + ); return { affected: [...affected].sort(), outputs: { typecheck: affected.size > 0, - build: has("@t3tools/desktop") || has("t3") || has("@t3tools/web"), + build: has("@t3tools/desktop") || has("t3") || has("@t3tools/web") || desktopLaunch, + desktop_launch: desktopLaunch, test: tested.length > 0, // Empty means every package the Test job covers. test_packages: everything ? "" : tested.map((pkg) => pkg.name).join(" "), diff --git a/.github/scripts/ci-changes.d.cts b/.github/scripts/ci-changes.d.cts index afe6f61339362..4640e89776b0d 100644 --- a/.github/scripts/ci-changes.d.cts +++ b/.github/scripts/ci-changes.d.cts @@ -16,6 +16,7 @@ export function decide( outputs: { typecheck: boolean; build: boolean; + desktop_launch: boolean; test: boolean; test_packages: string; test_web: boolean; diff --git a/.github/scripts/mobile-preview-comment.cjs b/.github/scripts/mobile-preview-comment.cjs new file mode 100644 index 0000000000000..e4876b0842eaa --- /dev/null +++ b/.github/scripts/mobile-preview-comment.cjs @@ -0,0 +1,126 @@ +// The Android half of the mobile preview comment. The android job writes the fingerprint and +// the update; the android-build job adds the client's install link a few minutes later. Each +// run merges what it knows into the state kept in the comment's first line. +const MARKER = "mobile-preview-android"; +const PROJECT_ID = "d763fcb8-d37c-41ea-a773-b54a0ab4a454"; +const APP_SCHEME = "t3-code"; + +function readState(body) { + const match = new RegExp(`^`).exec(body ?? ""); + if (!match) return null; + try { + const state = JSON.parse(match[1]); + const text = (value) => (typeof value === "string" ? value : ""); + return { + fingerprint: text(state.fingerprint), + client: text(state.client), + group: text(state.group), + }; + } catch { + return null; + } +} + +/** + * New values win; a new fingerprint drops the old fingerprint's client. A build that finishes + * after the PR moved to another fingerprint changes nothing: its client cannot run the update + * the comment now shows. + */ +function mergeState(previous, next) { + const buildOnly = Boolean(next.client) && !next.group; + if ( + buildOnly && + previous?.fingerprint && + next.fingerprint && + previous.fingerprint !== next.fingerprint + ) { + return previous; + } + const fingerprint = next.fingerprint || previous?.fingerprint || ""; + const sameFingerprint = !previous?.fingerprint || previous.fingerprint === fingerprint; + return { + fingerprint, + client: next.client || (sameFingerprint ? (previous?.client ?? "") : ""), + group: next.group || previous?.group || "", + }; +} + +function render(state, runUrl) { + const qr = `https://qr.expo.dev/eas-update?appScheme=${APP_SCHEME}&projectId=${PROJECT_ID}&groupId=${state.group}`; + const rows = [ + ``, + "🤖 Android preview", + "", + "  | 🤖 Android", + "--- | ---", + `Fingerprint | ${state.fingerprint || "n/a"}`, + `Dev client | ${state.client ? `[Install](${state.client})` : `Building on Blacksmith ([run](${runUrl})); this comment gets the install link when it finishes`}`, + ]; + if (state.group) { + rows.push( + `Update Details | [Update Permalink](https://expo.dev/projects/${PROJECT_ID}/updates/${state.group})`, + `Update QR | `, + ); + } + return rows.join("\n"); +} + +async function github(path, init = {}) { + const response = await fetch(`https://api.github.com${path}`, { + ...init, + headers: { + authorization: `Bearer ${process.env.GH_TOKEN}`, + accept: "application/vnd.github+json", + "content-type": "application/json", + ...init.headers, + }, + }); + if (!response.ok) { + throw new Error( + `GitHub ${init.method ?? "GET"} ${path}: ${response.status} ${await response.text()}`, + ); + } + return response.json(); +} + +async function main() { + const repository = process.env.GITHUB_REPOSITORY; + const pr = process.env.PR; + if (!repository || !pr || !process.env.GH_TOKEN) { + throw new Error("GITHUB_REPOSITORY, PR and GH_TOKEN are required."); + } + const runUrl = `${process.env.GITHUB_SERVER_URL}/${repository}/actions/runs/${process.env.GITHUB_RUN_ID}`; + let existing = null; + for (let page = 1; existing === null; page++) { + const comments = await github( + `/repos/${repository}/issues/${pr}/comments?per_page=100&page=${page}`, + ); + // Only this workflow's own comment: anyone can post the marker, and nobody else's is editable. + existing = + comments.find( + (comment) => + comment.user?.login === "github-actions[bot]" && readState(comment.body) !== null, + ) ?? null; + if (comments.length < 100) break; + } + const state = mergeState(readState(existing?.body), { + fingerprint: process.env.FINGERPRINT ?? "", + client: process.env.CLIENT ?? "", + group: process.env.GROUP ?? "", + }); + const body = JSON.stringify({ body: render(state, runUrl) }); + if (existing) { + await github(`/repos/${repository}/issues/comments/${existing.id}`, { method: "PATCH", body }); + } else { + await github(`/repos/${repository}/issues/${pr}/comments`, { method: "POST", body }); + } +} + +if (require.main === module) { + main().catch((error) => { + console.error(error); + process.exitCode = 1; + }); +} + +module.exports = { mergeState, readState, render }; diff --git a/.github/scripts/mobile-preview-comment.test.cjs b/.github/scripts/mobile-preview-comment.test.cjs new file mode 100644 index 0000000000000..99bfc45d4d2b9 --- /dev/null +++ b/.github/scripts/mobile-preview-comment.test.cjs @@ -0,0 +1,62 @@ +const assert = require("node:assert/strict"); +const test = require("node:test"); + +const { mergeState, readState, render } = require("./mobile-preview-comment.cjs"); + +const fingerprint = "c31608b37a8a2dca35ccee39cfd2ec7305042629"; +const run = "https://github.com/pingdotgg/t3code/actions/runs/1"; + +test("the comment round-trips its state", () => { + const state = { fingerprint, client: "", group: "8e413a0a" }; + const body = render(state, run); + assert.deepEqual(readState(body), state); + assert.match( + body, + /Building on Blacksmith \(\[run\]\(https:\/\/github\.com\/pingdotgg\/t3code\/actions\/runs\/1\)\)/, + ); + assert.match( + body, + /qr\.expo\.dev\/eas-update\?appScheme=t3-code&projectId=[0-9a-f-]+&groupId=8e413a0a/, + ); + assert.equal(readState("🚀 Expo continuous deployment is ready!"), null); +}); + +test("the build job adds the install link without losing the update", () => { + const first = mergeState(null, { fingerprint, client: "", group: "8e413a0a" }); + const built = mergeState(first, { fingerprint, client: "https://expo.dev/a/b", group: "" }); + assert.deepEqual(built, { fingerprint, client: "https://expo.dev/a/b", group: "8e413a0a" }); + assert.match(render(built, run), /Dev client \| \[Install\]\(https:\/\/expo\.dev\/a\/b\)/); +}); + +test("a new fingerprint drops the old client; the same one keeps it", () => { + const built = { fingerprint, client: "https://expo.dev/a/b", group: "old" }; + assert.deepEqual(mergeState(built, { fingerprint, client: "", group: "new" }), { + fingerprint, + client: "https://expo.dev/a/b", + group: "new", + }); + assert.deepEqual(mergeState(built, { fingerprint: "f".repeat(40), client: "", group: "new" }), { + fingerprint: "f".repeat(40), + client: "", + group: "new", + }); +}); + +test("a build that finishes after the PR moved to another fingerprint changes nothing", () => { + const current = { fingerprint: "f".repeat(40), client: "", group: "new" }; + assert.deepEqual( + mergeState(current, { fingerprint, client: "https://expo.dev/a/b", group: "" }), + current, + ); +}); + +test("state from the comment is always three strings", () => { + assert.deepEqual( + readState('\nx'), + { + fingerprint: "", + client: "", + group: "", + }, + ); +}); diff --git a/.github/scripts/pr-limits.cjs b/.github/scripts/pr-limits.cjs new file mode 100644 index 0000000000000..77c09785e4104 --- /dev/null +++ b/.github/scripts/pr-limits.cjs @@ -0,0 +1,320 @@ +const fs = require("node:fs"); + +const HOUR_MS = 60 * 60 * 1000; +const OPEN_LIMIT = 3; +const CONFLICT_IDLE_MS = 7 * 24 * HOUR_MS; +const NOTICE_MS = 72 * HOUR_MS; +// A first run meets the whole backlog. These keep one run from flooding watchers or GitHub's +// limit on how fast a token may comment. +const MAX_REQUESTS_PER_RUN = 100; +const MAX_CLOSES_PER_RUN = 50; +// A last look that ends in "not today" closes nothing, so the looks need a cap of their own. +const MAX_RECHECKS_PER_RUN = 150; +const WRITE_PAUSE_MS = 1_000; +// Measured here: about two and a half minutes until every open PR has an answer. +const MERGEABLE_WAIT_MS = 150_000; +const READ_ATTEMPTS = 4; +const READ_RETRY_MS = 5_000; +// GitHub stamps a label event and the PR's updatedAt a second or two apart. +const SAME_MOMENT_MS = 15_000; + +const REBASE_LABEL = "needs-rebase"; +const CLOSING_LABEL = "triage:closing"; +const KEEP_OPEN_LABEL = "triage:keep-open"; +const UNVOUCHED_LABEL = "vouch:unvouched"; +const WRITE_PERMISSIONS = new Set(["admin", "maintain", "write"]); +const EXEMPTIONS_PATH = ".github/TRIAGE_EXEMPTIONS.td"; +const VOUCHED_PATH = ".github/VOUCHED.td"; +const LOGIN_PATTERN = /^github:([A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38})$/; + +// `labels` matches a PR that carries any one of them. +const SWEEP_QUERY = ` + query ($owner: String!, $repo: String!, $labels: [String!], $cursor: String) { + repository(owner: $owner, name: $repo) { + pullRequests(states: OPEN, labels: $labels, first: 50, after: $cursor) { + pageInfo { hasNextPage endCursor } + nodes { + number + mergeable + baseRefName + updatedAt + author { login } + labels(first: 100) { nodes { name } } + timelineItems(itemTypes: [LABELED_EVENT], last: 20) { + nodes { ... on LabeledEvent { createdAt label { name } } } + } + } + } + } + }`; + +const RECHECK_QUERY = ` + query ($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + state + mergeable + updatedAt + labels(first: 100) { nodes { name } } + } + } + }`; + +const AUTHOR_OPEN_PRS_QUERY = ` + query ($q: String!) { + search(query: $q, type: ISSUE, first: 50) { + nodes { ... on PullRequest { number } } + } + }`; + +const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +// GitHub answers a heavy list request with a 502 now and then, and one of those must not end the +// run. Only reads go through here: repeating a read is safe, repeating a comment is not. +async function read(github, query, variables) { + for (let attempt = 1; ; attempt += 1) { + try { + return await github.graphql(query, variables); + } catch (error) { + const transient = typeof error.status === "number" && error.status >= 500; + if (attempt === READ_ATTEMPTS || !transient) throw error; + await pause(attempt * READ_RETRY_MS); + } + } +} + +// Fails closed: a missing, empty, or malformed list must never read as "nobody is exempt". +function parseExemptions(text) { + const logins = new Set(); + for (const raw of text.split("\n")) { + const line = raw.trim(); + if (line === "" || line.startsWith("#")) continue; + const match = LOGIN_PATTERN.exec(line); + if (!match) throw new Error(`${EXEMPTIONS_PATH}: malformed entry "${line}"`); + const login = match[1].toLowerCase(); + if (logins.has(login)) throw new Error(`${EXEMPTIONS_PATH}: duplicate entry "${line}"`); + logins.add(login); + } + if (logins.size === 0) throw new Error(`${EXEMPTIONS_PATH}: no entries`); + return logins; +} + +// VOUCHED.td: one `github:login` per line, `#` comments, and `-github:login reason` to denounce. +function vouchedLogins(text) { + const logins = new Set(); + for (const raw of text.split("\n")) { + const entry = raw.trim().split(/\s+/)[0]; + if (entry.startsWith("github:")) logins.add(entry.slice("github:".length).toLowerCase()); + } + if (logins.size === 0) throw new Error(`${VOUCHED_PATH}: no entries`); + return logins; +} + +function labeledAt(pr, name) { + const last = pr.timelineItems.nodes.filter((event) => event.label?.name === name).at(-1); + return last ? Date.parse(last.createdAt) : undefined; +} + +// The whole policy for the two windows. Activity is anything that moves the PR's updatedAt: a +// push, a comment, a review, a label. Nobody can set that time, so nothing here trusts the author. +function decide(pr, exemptions, now) { + const labels = new Set(pr.labels.nodes.map((label) => label.name)); + if (labels.has(KEEP_OPEN_LABEL)) return "skip"; + const updatedAt = Date.parse(pr.updatedAt); + + if (labels.has(CLOSING_LABEL)) { + const since = labeledAt(pr, CLOSING_LABEL); + if (since === undefined) return "unknown-notice"; + // Any activity after the notice sends the PR back to triage. + if (updatedAt - since > SAME_MOMENT_MS) return "clear-notice"; + return now - since >= NOTICE_MS ? "close-notice" : "skip"; + } + + const exempt = exemptions.has((pr.author?.login ?? "").toLowerCase()); + const subject = labels.has(UNVOUCHED_LABEL) && !exempt; + if (labels.has(REBASE_LABEL)) { + if (!subject || pr.mergeable === "MERGEABLE") return "clear-rebase"; + // Activity restarts the seven days and keeps the label, so a PR is asked to rebase once. + const idle = now - updatedAt >= CONFLICT_IDLE_MS; + return idle && pr.mergeable === "CONFLICTING" ? "close-conflict" : "skip"; + } + return subject && pr.mergeable === "CONFLICTING" ? "request-rebase" : "skip"; +} + +function guideLink(context, anchor) { + return `https://github.com/${context.repo.owner}/${context.repo.repo}/blob/main/CONTRIBUTING.md#${anchor}`; +} + +async function removeLabel(github, context, number, name) { + try { + await github.rest.issues.removeLabel({ ...context.repo, issue_number: number, name }); + } catch (error) { + if (error.status !== 404) throw error; + } +} + +async function closeWithComment(github, context, number, body) { + await github.rest.issues.createComment({ ...context.repo, issue_number: number, body }); + await github.rest.pulls.update({ ...context.repo, pull_number: number, state: "closed" }); +} + +async function sweep({ github, context, core, dryRun }) { + const exemptions = parseExemptions(fs.readFileSync(EXEMPTIONS_PATH, "utf8")); + const repo = { owner: context.repo.owner, repo: context.repo.repo }; + + const list = async () => { + const prs = []; + for (let cursor = null; ;) { + const page = ( + await read(github, SWEEP_QUERY, { + ...repo, + cursor, + labels: [UNVOUCHED_LABEL, REBASE_LABEL, CLOSING_LABEL], + }) + ).repository.pullRequests; + prs.push(...page.nodes); + if (!page.pageInfo.hasNextPage) return prs; + cursor = page.pageInfo.endCursor; + } + }; + // GitHub computes mergeability lazily and forgets it whenever the base moves. Asking starts the + // computation, so ask once more. A PR that is still unknown waits for tomorrow's run. + let prs = await list(); + if (prs.some((pr) => pr.mergeable === "UNKNOWN")) { + await pause(MERGEABLE_WAIT_MS); + prs = await list(); + } + + const now = Date.now(); + // Start further down the list each day. The step is no larger than a run's cap, so a cap that + // is reached cannot leave the same PRs waiting behind the same others every day. + const start = (Math.floor(now / (24 * HOUR_MS)) * MAX_RECHECKS_PER_RUN) % (prs.length || 1); + prs = [...prs.slice(start), ...prs.slice(0, start)]; + const count = {}; + let rechecks = 0; + for (const pr of prs) { + const { number } = pr; + let action = decide(pr, exemptions, now); + if (action === "skip") continue; + if (action === "unknown-notice") { + core.warning(`#${number}: cannot tell when ${CLOSING_LABEL} was applied, leaving it open.`); + continue; + } + if (action === "request-rebase" && (count[action] ?? 0) >= MAX_REQUESTS_PER_RUN) continue; + if (action.startsWith("close")) { + if ((count["close-conflict"] ?? 0) + (count["close-notice"] ?? 0) >= MAX_CLOSES_PER_RUN) { + continue; + } + if (rechecks >= MAX_RECHECKS_PER_RUN) continue; + rechecks += 1; + // The list is minutes old by now. Closing is the one step worth a second look. + const fresh = (await read(github, RECHECK_QUERY, { ...repo, number })).repository.pullRequest; + if (fresh.state !== "OPEN") continue; + // A merge to the base can resolve a conflict without touching the PR, so this is re-read + // too. An answer that is not ready yet reads as "not conflicting" and waits for tomorrow. + action = decide({ ...pr, ...fresh }, exemptions, now); + if (!action.startsWith("close")) continue; + } + + count[action] = (count[action] ?? 0) + 1; + core.info(`#${number} ${action}${dryRun ? " (dry run)" : ""}`); + if (dryRun) continue; + + if (action === "request-rebase") { + // The request goes first: a label without it would start a clock nobody was told about. + await github.rest.issues.createComment({ + ...context.repo, + issue_number: number, + body: [ + `This PR conflicts with \`${pr.baseRefName}\`. Rebase it or merge \`${pr.baseRefName}\` into it. If it still conflicts after 7 days with no activity, it will be closed automatically.`, + "", + `See the [conflict window](${guideLink(context, "conflict-window")}).`, + ].join("\n"), + }); + await github.rest.issues.addLabels({ + ...context.repo, + issue_number: number, + labels: [REBASE_LABEL], + }); + } else if (action === "clear-rebase") { + await removeLabel(github, context, number, REBASE_LABEL); + } else if (action === "clear-notice") { + await removeLabel(github, context, number, CLOSING_LABEL); + } else { + const conflict = action === "close-conflict"; + await closeWithComment( + github, + context, + number, + conflict + ? [ + `Closing: this PR still conflicts with \`${pr.baseRefName}\` and has had no activity for 7 days since the rebase request.`, + "", + `To continue, rebase the branch and open a new PR that links this one. See the [conflict window](${guideLink(context, "conflict-window")}).`, + ].join("\n") + : [ + "Closing: 72 hours have passed since the triage notice above with no activity on this PR.", + "", + `To ask for reconsideration, address the notice and comment here, or open a focused replacement PR that links this one. See [closure and reconsideration](${guideLink(context, "closure-and-reconsideration")}).`, + ].join("\n"), + ); + // Without this a maintainer's reopen would be closed again by the next run. + await removeLabel(github, context, number, conflict ? REBASE_LABEL : CLOSING_LABEL); + } + await pause(WRITE_PAUSE_MS); + } + + const summary = Object.entries(count).map(([action, n]) => `${n} ${action}`); + core.info( + `${dryRun ? "Dry run. " : ""}${prs.length} PRs read: ${summary.join(", ") || "nothing to do"}.`, + ); + return count; +} + +async function enforceOpenLimit({ github, context, core, dryRun }) { + const pr = context.payload.pull_request; + const author = pr.user.login; + // Who is trusted is decided here rather than by the vouch action, which reports its own failed + // lookups as "not vouched". Every read below throws on failure, and a throw closes nothing. + if (pr.user.type === "Bot") return; + if (parseExemptions(fs.readFileSync(EXEMPTIONS_PATH, "utf8")).has(author.toLowerCase())) return; + if (vouchedLogins(fs.readFileSync(VOUCHED_PATH, "utf8")).has(author.toLowerCase())) return; + const { data: access } = await github.rest.repos.getCollaboratorPermissionLevel({ + ...context.repo, + username: author, + }); + if (WRITE_PERMISSIONS.has(access.permission)) return; + if (pr.labels.some((label) => label.name === KEEP_OPEN_LABEL)) return; + // Someone else reopening a PR is a maintainer overriding the limit. + const reopened = context.payload.action === "reopened"; + if (reopened && context.payload.sender.login.toLowerCase() !== author.toLowerCase()) return; + + const q = `repo:${context.repo.owner}/${context.repo.repo} is:pr is:open author:${author} sort:created-asc`; + const { search } = await read(github, AUTHOR_OPEN_PRS_QUERY, { q }); + // A new PR counts only the older ones, so two opened together cannot close each other. + const others = search.nodes.filter( + (node) => node.number && (reopened ? node.number !== pr.number : node.number < pr.number), + ).length; + if (others < OPEN_LIMIT) { + core.info(`PR #${pr.number}: ${author} has ${others} other open PRs.`); + return; + } + if (dryRun) { + core.notice(`Dry run. PR #${pr.number} would close: ${author} has ${others} other open PRs.`); + return; + } + + await closeWithComment( + github, + context, + pr.number, + [ + `Closing: ${author} already has ${OPEN_LIMIT} or more open PRs here, which is the limit for contributors who are not vouched.`, + "", + `Once one of them is merged or closed, open this again. See the [open PR limit](${guideLink(context, "open-pr-limit")}).`, + ].join("\n"), + ); + core.info(`PR #${pr.number}: closed, ${author} has ${others} other open PRs.`); +} + +module.exports = { decide, enforceOpenLimit, parseExemptions, sweep }; diff --git a/.github/scripts/test-delta.cjs b/.github/scripts/test-delta.cjs new file mode 100644 index 0000000000000..c9789f644d272 --- /dev/null +++ b/.github/scripts/test-delta.cjs @@ -0,0 +1,78 @@ +// Counts the test cases a pull request adds and removes, from a zero-context diff, so PR Size can +// say when a change grows the suite. Counting declarations keeps the number deterministic, unlike +// CI timings, which move with runner load. +const MARKER = ""; +const NET_CASES_TO_REPORT = 20; +const TEST_FILE = /\.(?:test|spec)\.[cm]?[jt]sx?$/; +// `<` admits type arguments, as in `it.each(...)`, which may span lines. +const TEST_CASE = /^\s*(?:it|test)(?:\.\w+)*\s*[(<]/; +const isBrowserTest = (path) => path.includes(".browser.test."); + +function kindOf(path) { + if (isBrowserTest(path)) return "browser"; + if (path.startsWith("apps/server/") || path.includes(".integration.")) return "server"; + return "unit"; +} + +/** @param {string} diff output of `git diff -U0 base...head` */ +function countTestDelta(diff) { + const kinds = { browser: 0, server: 0, unit: 0 }; + const newBrowserFiles = []; + let path = null; + // A browser test file is new when it is added, or renamed from a file that was not one. + let file = null; + const flush = () => { + if (file?.isNew && isBrowserTest(file.target)) newBrowserFiles.push(file.target); + }; + for (const line of diff.split("\n")) { + if (line.startsWith("diff --git ")) { + flush(); + path = null; + file = { target: line.replace(/^diff --git a\/\S+ b\//, ""), isNew: false }; + continue; + } + if (line.startsWith("new file mode") && file) file.isNew = true; + if (line.startsWith("rename from ") && file) file.isNew = !isBrowserTest(line.slice(12)); + if (line.startsWith("--- ")) { + const source = line.slice(4).replace(/^a\//, ""); + path = TEST_FILE.test(source) ? source : null; + continue; + } + if (line.startsWith("+++ ")) { + const target = line.slice(4).replace(/^b\//, ""); + // A deleted file's target is /dev/null; keep its old path to count the removed cases. + if (target !== "/dev/null") path = TEST_FILE.test(target) ? target : null; + continue; + } + if (line.startsWith("new file mode") || !path) continue; + const sign = line[0] === "+" ? 1 : line[0] === "-" ? -1 : 0; + if (sign !== 0 && TEST_CASE.test(line.slice(1))) kinds[kindOf(path)] += sign; + } + flush(); + return { kinds, net: kinds.browser + kinds.server + kinds.unit, newBrowserFiles }; +} + +/** Returns the comment body, or null when the change does not grow the suite enough to mention. */ +function render(delta) { + if (delta.net < NET_CASES_TO_REPORT && delta.newBrowserFiles.length === 0) return null; + const signed = (n) => (n > 0 ? `+${n}` : String(n)); + const lines = [ + MARKER, + `This PR changes the test suite by **${signed(delta.net)} test cases** ` + + `(browser ${signed(delta.kinds.browser)}, unit ${signed(delta.kinds.unit)}, server ${signed(delta.kinds.server)}).`, + ]; + if (delta.newBrowserFiles.length > 0) { + lines.push( + "", + `New browser test files: ${delta.newBrowserFiles.map((file) => `\`${file}\``).join(", ")}.`, + "Each browser test file costs CI seconds of module loading before its first test runs; a unit test costs milliseconds.", + ); + } + lines.push( + "", + "Keep tests that would fail for a real reason, and test logic below the UI where it can be. See [Testing](https://github.com/pingdotgg/t3code/blob/main/docs/internals/testing.md).", + ); + return lines.join("\n"); +} + +module.exports = { MARKER, countTestDelta, render }; diff --git a/.github/scripts/test-delta.test.cjs b/.github/scripts/test-delta.test.cjs new file mode 100644 index 0000000000000..82a3e00862d2f --- /dev/null +++ b/.github/scripts/test-delta.test.cjs @@ -0,0 +1,59 @@ +const assert = require("node:assert/strict"); +const test = require("node:test"); + +const { countTestDelta, render } = require("./test-delta.cjs"); + +const diff = `diff --git a/apps/web/src/Panel.browser.test.tsx b/apps/web/src/Panel.browser.test.tsx +new file mode 100644 +--- /dev/null ++++ b/apps/web/src/Panel.browser.test.tsx +@@ -0,0 +1,4 @@ ++it("opens", async () => {}); ++ it.each<{ n: number }>([{ n: 1 }])("renders $n", async () => {}); ++// it("is a comment, still counted only when it starts the line") +diff --git a/apps/server/src/thing.test.ts b/apps/server/src/thing.test.ts +--- a/apps/server/src/thing.test.ts ++++ b/apps/server/src/thing.test.ts +@@ -3,2 +3,1 @@ +-it.effect("old", () => {}); +-test("older", () => {}); ++ it.effect("new", () => {}); +diff --git a/packages/shared/src/gone.test.ts b/packages/shared/src/gone.test.ts +deleted file mode 100644 +--- a/packages/shared/src/gone.test.ts ++++ /dev/null +@@ -1,1 +0,0 @@ +-it("goes", () => {}); +diff --git a/apps/web/src/Menu.test.tsx b/apps/web/src/Menu.browser.test.tsx +similarity index 100% +rename from apps/web/src/Menu.test.tsx +rename to apps/web/src/Menu.browser.test.tsx +diff --git a/apps/web/src/Old.browser.test.tsx b/apps/web/src/New.browser.test.tsx +similarity index 100% +rename from apps/web/src/Old.browser.test.tsx +rename to apps/web/src/New.browser.test.tsx +diff --git a/apps/web/src/Panel.tsx b/apps/web/src/Panel.tsx +--- a/apps/web/src/Panel.tsx ++++ b/apps/web/src/Panel.tsx +@@ -1 +1 @@ ++it("is not a test file", () => {}); +`; + +test("counts cases by kind, including deleted files, and finds new or renamed browser files", () => { + assert.deepEqual(countTestDelta(diff), { + kinds: { browser: 2, server: -1, unit: -1 }, + net: 0, + newBrowserFiles: ["apps/web/src/Panel.browser.test.tsx", "apps/web/src/Menu.browser.test.tsx"], + }); +}); + +test("reports only growth worth a reviewer's attention", () => { + const quiet = { kinds: { browser: 0, server: 3, unit: 5 }, net: 8, newBrowserFiles: [] }; + assert.equal(render(quiet), null); + const growth = { kinds: { browser: 4, server: 10, unit: 12 }, net: 26, newBrowserFiles: [] }; + assert.match(render(growth), /\*\*\+26 test cases\*\* \(browser \+4, unit \+12, server \+10\)/); + assert.match( + render(countTestDelta(diff)), + /New browser test files: `apps\/web\/src\/Panel\.browser\.test\.tsx`/, + ); +}); diff --git a/.github/scripts/thread-transfer-report.cjs b/.github/scripts/thread-transfer-report.cjs index 09b25131e4c48..304b56d57e400 100644 --- a/.github/scripts/thread-transfer-report.cjs +++ b/.github/scripts/thread-transfer-report.cjs @@ -3,6 +3,7 @@ const path = require("node:path"); const ARTIFACT_NAME = "thread-transfer-results"; const RESULT_FILE = "thread-transfer-result.json"; +const SCENARIOS_FILE = "thread-transfer-scenarios.json"; const COMMENT_MARKER = ""; const PROVIDERS = ["codex", "claudeAgent"]; const OBSERVED_KEYS = [ @@ -29,6 +30,12 @@ const SCENARIO_KEYS = [ "measuredMcpResultBytes", ]; +const SCENARIO_ROW_KEYS = ["phase", "metric", "unit", "observed", "ceiling"]; +// These labels come from the PR and reach a comment the bot posts: no markup, links or mentions. +// GitHub still links plain text such as `GH-26`, so they are rendered as code spans. +const SCENARIO_LABEL = /^[A-Za-z0-9][A-Za-z0-9 ,-]{0,79}$/; +const MAX_SCENARIO_ROWS = 100; + function resultShaMarker(sha) { return ``; } @@ -85,15 +92,63 @@ function validateResult(value) { return value; } -function readResult(directory) { +function validateScenarios(value) { + assertExactKeys(value, ["schemaVersion", "providers"], "scenarios"); + if (value.schemaVersion !== 1) { + throw new Error("scenarios.schemaVersion must be 1"); + } + assertExactKeys(value.providers, PROVIDERS, "scenarios.providers"); + for (const provider of PROVIDERS) { + const rows = value.providers[provider]; + if (!Array.isArray(rows) || rows.length > MAX_SCENARIO_ROWS) { + throw new Error( + `scenarios.providers.${provider} must list at most ${MAX_SCENARIO_ROWS} rows`, + ); + } + const seen = new Set(); + for (const [index, row] of rows.entries()) { + const label = `scenarios.providers.${provider}[${index}]`; + assertExactKeys(row, SCENARIO_ROW_KEYS, label); + for (const key of ["phase", "metric"]) { + if (typeof row[key] !== "string" || !SCENARIO_LABEL.test(row[key])) { + throw new Error(`${label}.${key} must be a short plain label`); + } + } + if (row.unit !== "bytes" && row.unit !== "count") { + throw new Error(`${label}.unit must be bytes or count`); + } + assertMetric(row.observed, `${label}.observed`); + assertMetric(row.ceiling, `${label}.ceiling`); + const key = scenarioKey(row); + if (seen.has(key)) throw new Error(`${label} repeats an earlier row`); + seen.add(key); + } + } + return value; +} + +function scenarioKey(row) { + return `${row.phase}|${row.metric}`; +} + +function readArtifactFile(directory, name, validate) { if (!directory) return undefined; - const file = path.join(directory, RESULT_FILE); + const file = path.join(directory, name); if (!fs.existsSync(file)) return undefined; const stat = fs.lstatSync(file); if (!stat.isFile() || stat.size > 64 * 1_024) { - throw new Error("thread transfer result must be a regular file smaller than 64 KiB"); + throw new Error(`${name} must be a regular file smaller than 64 KiB`); } - return validateResult(JSON.parse(fs.readFileSync(file, "utf8"))); + return validate(JSON.parse(fs.readFileSync(file, "utf8"))); +} + +function readResult(directory) { + return readArtifactFile(directory, RESULT_FILE, validateResult); +} + +// Older runs upload only the result file, so a run without scenarios is still a valid run. +function readScenarios(directory) { + return readArtifactFile(directory, SCENARIOS_FILE, validateScenarios); } function formatBytes(bytes) { @@ -103,7 +158,7 @@ function formatBytes(bytes) { } function formatValue(value, kind) { - return kind === "messages" ? value.toLocaleString("en-US") : formatBytes(value); + return kind === "bytes" ? formatBytes(value) : value.toLocaleString("en-US"); } function formatImpact(current, baseline, kind) { @@ -136,6 +191,77 @@ const METRICS = [ { key: "measuredTurnWebSocketMessages", label: "Live turn messages", kind: "messages" }, ]; +function providerLabel(provider) { + return provider === "codex" ? "Codex" : "Claude"; +} + +/** + * The scenario rows of a run against the same rows on `main`. Rows that fail or differ from + * `main` are listed in the open; the full table is collapsed below them. + */ +function renderScenarios(current, baseline, ceilingChanges) { + if (!current) return { lines: [], failed: false }; + const header = [ + "| Provider | Scenario | Metric | Main baseline | This PR | Impact | PR ceiling | |", + "| --- | --- | --- | ---: | ---: | ---: | ---: | --- |", + ]; + const all = []; + const notable = []; + let failed = false; + let withoutBaseline = 0; + + for (const provider of PROVIDERS) { + const baselineRows = new Map( + (baseline?.providers[provider] ?? []).map((row) => [scenarioKey(row), row]), + ); + for (const row of current.providers[provider]) { + const before = baselineRows.get(scenarioKey(row)); + const beforeObserved = before?.unit === row.unit ? before.observed : undefined; + const pass = row.observed <= row.ceiling; + failed ||= !pass; + if (beforeObserved === undefined) withoutBaseline += 1; + const line = `| ${providerLabel(provider)} | \`${row.phase}\` | \`${row.metric}\` | ${beforeObserved === undefined ? "—" : formatValue(beforeObserved, row.unit)} | ${formatValue(row.observed, row.unit)} | ${formatImpact(row.observed, beforeObserved, row.unit)} | ${formatValue(row.ceiling, row.unit)} | ${pass ? "✅" : "❌"} |`; + all.push(line); + if (!pass || (beforeObserved !== undefined && beforeObserved !== row.observed)) { + notable.push(line); + } + if (beforeObserved !== undefined && before.ceiling !== row.ceiling) { + ceilingChanges.push( + `- ${providerLabel(provider)} \`${row.phase}\` \`${row.metric}\`: ${formatValue(before.ceiling, row.unit)} → ${formatValue(row.ceiling, row.unit)}`, + ); + } + } + } + + const compared = all.length - withoutBaseline; + const summary = !baseline + ? `No \`main\` baseline exists yet for these ${all.length} scenario measurements.` + : [ + notable.length > 0 + ? `${notable.length} of ${all.length} scenario measurements changed or failed.` + : `${compared === all.length ? "All " : ""}${compared} scenario measurements match \`main\`.`, + ...(withoutBaseline > 0 ? [`${withoutBaseline} have no \`main\` baseline.`] : []), + ].join(" "); + return { + failed, + lines: [ + "### Other scenarios", + "", + summary, + ...(notable.length > 0 ? ["", ...header, ...notable] : []), + "", + "
", + `All ${all.length} scenario measurements`, + "", + ...header, + ...all, + "", + "
", + "", + ], + }; +} + function renderComment(input) { const current = input.current; const baseline = input.baseline; @@ -154,16 +280,22 @@ function renderComment(input) { const pass = observed <= ceiling; failed ||= !pass; rows.push( - `| ${provider === "codex" ? "Codex" : "Claude"} | ${metric.label} | ${baselineObserved === undefined ? "—" : formatValue(baselineObserved, metric.kind)} | ${formatValue(observed, metric.kind)} | ${formatImpact(observed, baselineObserved, metric.kind)} | ${formatValue(ceiling, metric.kind)} | ${pass ? "✅" : "❌"} |`, + `| ${providerLabel(provider)} | ${metric.label} | ${baselineObserved === undefined ? "—" : formatValue(baselineObserved, metric.kind)} | ${formatValue(observed, metric.kind)} | ${formatImpact(observed, baselineObserved, metric.kind)} | ${formatValue(ceiling, metric.kind)} | ${pass ? "✅" : "❌"} |`, ); if (comparable && baseline.providers[provider].ceiling[metric.key] !== ceiling) { ceilingChanges.push( - `- ${provider === "codex" ? "Codex" : "Claude"} ${metric.label}: ${formatValue(baseline.providers[provider].ceiling[metric.key], metric.kind)} → ${formatValue(ceiling, metric.kind)}`, + `- ${providerLabel(provider)} ${metric.label}: ${formatValue(baseline.providers[provider].ceiling[metric.key], metric.kind)} → ${formatValue(ceiling, metric.kind)}`, ); } } } + const scenarios = renderScenarios( + input.currentScenarios, + input.baselineScenarios, + ceilingChanges, + ); + failed ||= scenarios.failed; const baselineLink = input.baselineRun ? `[\`${input.baselineRun.sha.slice(0, 7)}\`](${input.baselineRun.url})` @@ -203,6 +335,7 @@ function renderComment(input) { "| --- | --- | ---: | ---: | ---: | ---: | --- |", ...rows, "", + ...scenarios.lines, `Baseline: ${baselineLink} · PR result: ${currentLink} · Source CI: ${input.currentRun.conclusion}`, "", "
", @@ -212,7 +345,7 @@ function renderComment(input) { "", ...PROVIDERS.map( (provider) => - `- ${provider === "codex" ? "Codex" : "Claude"} decoded thread snapshot: ${formatBytes(current.providers[provider].observed.threadSnapshotDecodedBytes)}`, + `- ${providerLabel(provider)} decoded thread snapshot: ${formatBytes(current.providers[provider].observed.threadSnapshotDecodedBytes)}`, ), "", "
", @@ -437,7 +570,14 @@ async function publish({ github, context, core }) { url: `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${process.env.BASELINE_RUN_ID}`, } : undefined; - const body = renderComment({ current, baseline, currentRun, baselineRun }); + const body = renderComment({ + current, + baseline, + currentScenarios: readScenarios(process.env.PR_RESULT_DIR), + baselineScenarios: readScenarios(process.env.BASELINE_RESULT_DIR), + currentRun, + baselineRun, + }); const published = await upsertCommentForCurrentHead( github, context, @@ -458,4 +598,5 @@ module.exports = { resolve, upsertCommentForCurrentHead, validateResult, + validateScenarios, }; diff --git a/.github/scripts/thread-transfer-report.test.cjs b/.github/scripts/thread-transfer-report.test.cjs index 8c4595ccf0c6a..3a953a8bfc16b 100644 --- a/.github/scripts/thread-transfer-report.test.cjs +++ b/.github/scripts/thread-transfer-report.test.cjs @@ -2,9 +2,11 @@ const assert = require("node:assert/strict"); const test = require("node:test"); const { + renderComment, resolve, upsertCommentForCurrentHead, validateResult, + validateScenarios, } = require("./thread-transfer-report.cjs"); function result(overrides = {}) { @@ -40,6 +42,29 @@ function result(overrides = {}) { }; } +function scenarios(overrides = {}) { + const rows = [ + { + phase: "long turn finished, cold open", + metric: "HTTP decoded", + unit: "bytes", + observed: 390_000, + ceiling: 420_000, + }, + { + phase: "idle thread resume", + metric: "WebSocket messages", + unit: "count", + observed: 1, + ceiling: 1, + }, + ]; + return { + schemaVersion: 1, + providers: { codex: [{ ...rows[0], ...overrides }, rows[1]], claudeAgent: rows }, + }; +} + test("validates the fixed artifact schema", () => { assert.equal(validateResult(result()).schemaVersion, 1); assert.throws( @@ -50,6 +75,60 @@ test("validates the fixed artifact schema", () => { () => validateResult(result({ totalWireBytes: "lots" })), /non-negative safe integer/, ); + + assert.equal(validateScenarios(scenarios()).schemaVersion, 1); + for (const phase of ["@everyone", "[link](https://example.com)", "a | b", "", ""]) { + assert.throws(() => validateScenarios(scenarios({ phase })), /short plain label/); + } + assert.throws(() => validateScenarios(scenarios({ note: "extra" })), /unexpected fields/); + assert.throws(() => validateScenarios(scenarios({ ceiling: null })), /non-negative safe integer/); + assert.throws( + () => + validateScenarios(scenarios({ phase: "idle thread resume", metric: "WebSocket messages" })), + /repeats an earlier row/, + ); +}); + +test("lists scenario rows that changed or failed above the collapsed table", () => { + const run = { sha: "abcdef1234", url: "https://example.com/run", conclusion: "success" }; + const render = (currentScenarios, baselineScenarios) => + renderComment({ + current: result(), + baseline: result(), + currentScenarios, + baselineScenarios, + currentRun: run, + baselineRun: { ...run, matchesBase: true }, + }); + const open = (body) => body.slice(0, body.indexOf("
")); + + const unchanged = render(scenarios(), scenarios()); + assert.match(unchanged, /✅ Thread transfer remains within every enforced ceiling/); + assert.match(open(unchanged), /All 4 scenario measurements match `main`/); + assert.doesNotMatch(open(unchanged), /long turn finished, cold open/); + + const grown = render(scenarios({ observed: 400_000 }), scenarios()); + assert.match(open(grown), /1 of 4 scenario measurements changed or failed/); + assert.match( + open(grown), + /\| Codex \| `long turn finished, cold open` \| `HTTP decoded` \| 380\.9 KiB \| 390\.6 KiB \| \+9\.8 KiB \(\+2\.6%\) \| 410\.2 KiB \| ✅ \|/, + ); + + const over = render(scenarios({ observed: 430_000, ceiling: 425_000 }), scenarios()); + assert.match(over, /❌ One or more thread transfer ceilings were exceeded/); + assert.match( + over, + /Codex `long turn finished, cold open` `HTTP decoded`: 410\.2 KiB → 415\.0 KiB/, + ); + + // A row `main` does not have is not reported as matching it. + const added = render(scenarios({ phase: "GH-26" }), scenarios()); + assert.match(open(added), /3 scenario measurements match `main`\. 1 have no `main` baseline\./); + assert.match(added, /\| Codex \| `GH-26` \|/); + + // A main run from before the scenario file has nothing to compare; a PR run from before it has no section. + assert.match(open(render(scenarios(), undefined)), /No `main` baseline exists yet/); + assert.doesNotMatch(render(undefined, scenarios()), /Other scenarios/); }); test("resolves a fallback PR with a redacted head repo and exact main baseline", async () => { diff --git a/.github/workflows/agent-context-budget.yml b/.github/workflows/agent-context-budget.yml index 41efb7b69a5a2..5935fb4653c32 100644 --- a/.github/workflows/agent-context-budget.yml +++ b/.github/workflows/agent-context-budget.yml @@ -10,10 +10,7 @@ on: permissions: contents: read -concurrency: - group: agent-context-budget-${{ github.event.pull_request.number }} - cancel-in-progress: true - +# No concurrency group: label events arrive in bursts, and a cancelled run reports as a failed check. jobs: budget: name: Agent context budget @@ -24,7 +21,7 @@ jobs: ALLOW_INCREASE: ${{ contains(github.event.pull_request.labels.*.name, 'agent-context-increase') }} steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: sparse-checkout: apps/server/src/provider/agentContextBudget.json sparse-checkout-cone-mode: false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc12d15d17860..6bfb648fe368c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,7 @@ jobs: outputs: typecheck: ${{ steps.detect.outputs.typecheck }} build: ${{ steps.detect.outputs.build }} + desktop_launch: ${{ steps.detect.outputs.desktop_launch }} test: ${{ steps.detect.outputs.test }} test_packages: ${{ steps.detect.outputs.test_packages }} test_web: ${{ steps.detect.outputs.test_web }} @@ -38,7 +39,7 @@ jobs: # Only the package manifests the script reads to build the dependency graph. # Depth 2 brings the base commit the change is diffed against. - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: fetch-depth: 2 sparse-checkout: | @@ -67,7 +68,7 @@ jobs: timeout-minutes: 10 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: sparse-checkout: | /* @@ -91,7 +92,7 @@ jobs: # electron's install.js (run by ensure:electron) reads this cache before it # downloads from GitHub. The key changes only with the Electron version. - name: Cache Electron download - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/electron key: electron-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('node_modules/.pnpm/electron@*/node_modules/electron/package.json') }} @@ -123,7 +124,7 @@ jobs: timeout-minutes: 10 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: sparse-checkout: | /* @@ -135,13 +136,20 @@ jobs: if: needs.changes.outputs.build != 'false' # Runs in the background while Vite+ installs; the step before the build waits for it. + # Xvfb is for the desktop launch check below. - name: Start installing browser secret helper build libraries if: needs.changes.outputs.build != 'false' run: | - sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources + # Fetch only the indexes these main and universe packages need. The full set is ~42 MB, + # which a slow mirror can stretch past the wait below. + sudo sed -i -e 's|http://|https://|g' -e 's/^Components: .*/Components: main universe/' \ + /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources ( set +e - sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config + sudo apt-get update -o Acquire::Languages=none \ + -o Acquire::IndexTargets::deb::DEP-11::DefaultEnabled=false \ + -o Acquire::IndexTargets::deb::CNF::DefaultEnabled=false && + sudo apt-get install -y libsecret-1-dev pkg-config xvfb echo "$?" > "$RUNNER_TEMP/apt-status" ) > "$RUNNER_TEMP/apt.log" 2>&1 < /dev/null & @@ -153,7 +161,7 @@ jobs: # electron's install.js (run by ensure:electron) reads this cache before it # downloads from GitHub. The key changes only with the Electron version. - name: Cache Electron download - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/electron key: electron-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('node_modules/.pnpm/electron@*/node_modules/electron/package.json') }} @@ -192,6 +200,60 @@ jobs: if: ${{ !cancelled() && steps.build.outcome == 'success' }} run: node apps/desktop/scripts/verify-preload-bundle.mjs + # The release launch check, run unpackaged against the same old-state fixture, for + # startup and sidebar changes. Packaging and signing stay release-only. + - name: Open the seeded thread in the built desktop app + id: desktop_launch + if: ${{ !cancelled() && steps.build.outcome == 'success' && needs.changes.outputs.desktop_launch != 'false' }} + timeout-minutes: 4 + run: | + electron=$(node -p "require('./apps/desktop/node_modules/electron')") + xvfb-run -a node scripts/smoke-desktop-app.ts --executable "$electron" --app apps/desktop \ + --artifacts-dir "$RUNNER_TEMP/desktop-launch-check" + + - name: Upload desktop launch check + if: ${{ failure() && steps.desktop_launch.outcome == 'failure' }} + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: desktop-launch-check + path: ${{ runner.temp }}/desktop-launch-check + if-no-files-found: ignore + + # Browser tests run source modules; this boots the production chunk graph. The + # desktop build above already produced apps/web/dist, and the client waits about + # 15s for a session that never comes, so this runs here instead of in Test Web, + # which gates the whole run. Chromium comes from the cache Test Web shares. + - name: Resolve Playwright version + id: playwright_version + if: ${{ !cancelled() && steps.build.outcome == 'success' && needs.changes.outputs.test_web != 'false' }} + run: echo "version=$(node -p "require('./apps/web/node_modules/playwright/package.json').version")" >> "$GITHUB_OUTPUT" + + - name: Cache Chromium + id: chromium_cache + if: ${{ !cancelled() && steps.build.outcome == 'success' && needs.changes.outputs.test_web != 'false' }} + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.cache/ms-playwright + key: playwright-chromium-shell-${{ runner.os }}-${{ runner.arch }}-${{ steps.playwright_version.outputs.version }} + + - name: Install Chromium for the built web client boot check + if: ${{ !cancelled() && steps.build.outcome == 'success' && needs.changes.outputs.test_web != 'false' && steps.chromium_cache.outputs.cache-hit != 'true' }} + run: node apps/web/node_modules/playwright/cli.js install --only-shell chromium + + - name: Install missing Chromium system dependencies + if: ${{ !cancelled() && steps.build.outcome == 'success' && needs.changes.outputs.test_web != 'false' }} + run: | + browser=$(find "$HOME/.cache/ms-playwright" -name chrome-headless-shell -type f -print -quit) + test -n "$browser" + if ldd "$browser" | grep '=> not found'; then + node apps/web/node_modules/playwright/cli.js install-deps chromium + fi + + - name: Boot the built web client + if: ${{ !cancelled() && steps.build.outcome == 'success' && needs.changes.outputs.test_web != 'false' }} + timeout-minutes: 3 + run: node apps/web/scripts/verify-built-boot.ts + - name: Typecheck if: ${{ !cancelled() && steps.typecheck.outcome == 'success' }} timeout-minutes: 5 @@ -217,7 +279,7 @@ jobs: timeout-minutes: 10 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: sparse-checkout: | /* @@ -231,10 +293,16 @@ jobs: # the background while Vite+ installs; the step before the tests waits for it. - name: Start installing browser secret helper build libraries run: | - sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources + # Fetch only the indexes these main and universe packages need. The full set is ~42 MB, + # which a slow mirror can stretch past the wait below. + sudo sed -i -e 's|http://|https://|g' -e 's/^Components: .*/Components: main universe/' \ + /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources ( set +e - sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config + sudo apt-get update -o Acquire::Languages=none \ + -o Acquire::IndexTargets::deb::DEP-11::DefaultEnabled=false \ + -o Acquire::IndexTargets::deb::CNF::DefaultEnabled=false && + sudo apt-get install -y libsecret-1-dev pkg-config echo "$?" > "$RUNNER_TEMP/apt-status" ) > "$RUNNER_TEMP/apt.log" 2>&1 < /dev/null & @@ -246,7 +314,7 @@ jobs: # electron's install.js (run by ensure:electron) reads this cache before it # downloads from GitHub. The key changes only with the Electron version. - name: Cache Electron download - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/electron key: electron-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('node_modules/.pnpm/electron@*/node_modules/electron/package.json') }} @@ -258,7 +326,7 @@ jobs: run: python3 -B .github/scripts/stage-preview-bundle.test.py - name: Test release checks - run: node --test .github/scripts/check-nightly-release.test.cjs .github/scripts/vercel-deployment-output.test.cjs + run: node --test .github/scripts/check-nightly-release.test.cjs .github/scripts/vercel-deployment-output.test.cjs .github/scripts/mobile-preview-comment.test.cjs .github/scripts/test-delta.test.cjs - name: Finish installing browser secret helper build libraries timeout-minutes: 5 @@ -287,19 +355,25 @@ jobs: # The web suite is the largest outside the server. In the shared Test job it # started last and ran alone for about 40s after everything else finished. - # release.yml mirrors this test, test_web, and test_server split. + # Browser tests are bound by the Vite server transforming modules for each + # file, not by Chromium, so a second runner (and Vite server) halves the job + # that gated every run. release.yml mirrors this test, test_web, and test_server split. test_web: permissions: contents: read id-token: write - name: Test Web + name: Test Web ${{ matrix.shard }} needs: changes if: ${{ !cancelled() && needs.changes.outputs.test_web != 'false' }} runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + shard: [1, 2] steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: sparse-checkout: | /* @@ -320,7 +394,7 @@ jobs: - name: Cache Chromium id: chromium_cache - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/ms-playwright key: playwright-chromium-shell-${{ runner.os }}-${{ runner.arch }}-${{ steps.playwright_version.outputs.version }} @@ -338,13 +412,7 @@ jobs: fi - 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 + run: vp run --filter @t3tools/web test --shard ${{ matrix.shard }}/${{ strategy.job-total }} # apps/server sets `fileParallelism: false`, so its test files run strictly # one at a time. Sharding spreads them over separate runners instead of @@ -370,7 +438,7 @@ jobs: steps: # Depth 2 brings the base commit the change is diffed against. - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: fetch-depth: 2 sparse-checkout: | @@ -401,7 +469,7 @@ jobs: - name: Cache Chromium id: chromium_cache if: steps.changes.outputs.test_server != 'false' - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/ms-playwright key: playwright-chromium-shell-${{ runner.os }}-${{ runner.arch }}-${{ steps.playwright_version.outputs.version }} @@ -419,23 +487,35 @@ jobs: node apps/server/node_modules/playwright-core/cli.js install-deps chromium fi + # Each server test file runs in its own Vitest process, and a fresh install starts + # Vitest's content-keyed module cache empty, so every file re-transformed its imports. + # Only pushes to main save it, so pull requests never evict each other's entries. + - name: Restore Vitest module cache + if: steps.changes.outputs.test_server != 'false' + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: node_modules/.vitest-cache + key: vitest-modules-server-${{ matrix.shard }}-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.sha }} + restore-keys: vitest-modules-server-${{ matrix.shard }}-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}- + - name: Test if: steps.changes.outputs.test_server != 'false' env: T3CODE_TRANSFER_BUDGET_REPORT_PATH: ${{ runner.temp }}/t3code-transfer-budget.md T3CODE_TRANSFER_BUDGET_RESULT_PATH: ${{ runner.temp }}/thread-transfer-result.json + T3CODE_TRANSFER_BUDGET_SCENARIOS_PATH: ${{ runner.temp }}/thread-transfer-scenarios.json + # One process per file spends ~2s starting before its tests run; one file per vCPU overlaps that. run: >- - vp run test:cached -- --shard ${{ matrix.shard }}/${{ strategy.job-total }} + vp run test:cached -- --shard ${{ matrix.shard }}/${{ strategy.job-total }} --concurrency 4 --uncached apps/server/integration/transferBudgetV2.integration.test.ts - --verify-hits 1 --report "${{ runner.temp }}/test-cache-${{ matrix.shard }}.json" apps/server + --verify-hits 1 apps/server - - name: Upload test cache measurements - if: always() && steps.changes.outputs.test_server != 'false' - uses: actions/upload-artifact@v7 + - name: Save Vitest module cache + if: github.event_name == 'push' && steps.changes.outputs.test_server != 'false' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - name: test-cache-${{ matrix.shard }} - path: ${{ runner.temp }}/test-cache-${{ matrix.shard }}.json - if-no-files-found: error + path: node_modules/.vitest-cache + key: vitest-modules-server-${{ matrix.shard }}-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.sha }} # integration/transferBudgetV2.integration.test.ts writes the report; exactly one shard # produces these files. Gating the upload on their presence keeps a @@ -462,10 +542,12 @@ jobs: - name: Upload thread transfer result if: always() && steps.transfer_budget.outputs.present == 'true' - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: thread-transfer-results - path: ${{ runner.temp }}/thread-transfer-result.json + path: | + ${{ runner.temp }}/thread-transfer-result.json + ${{ runner.temp }}/thread-transfer-scenarios.json if-no-files-found: ignore retention-days: 30 @@ -479,7 +561,7 @@ jobs: timeout-minutes: 10 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: sparse-checkout: | /* @@ -488,8 +570,9 @@ jobs: sparse-checkout-cone-mode: false - name: Setup Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master with: + toolchain: stable components: rustfmt - name: Check Rust formatting @@ -508,11 +591,11 @@ jobs: name: Mobile Native Static Analysis needs: changes if: ${{ !cancelled() && needs.changes.outputs.mobile_native != 'false' }} - runs-on: blacksmith-6vcpu-macos-26 + runs-on: blacksmith-6vcpu-macos-27 timeout-minutes: 10 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: sparse-checkout: | /* @@ -520,7 +603,7 @@ jobs: sparse-checkout-cone-mode: false - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1.15.0 with: node-version-file: package.json cache: true @@ -542,10 +625,10 @@ jobs: needs: changes if: ${{ !cancelled() && needs.changes.outputs.release_smoke != 'false' }} runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 10 + timeout-minutes: 15 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: sparse-checkout: | /* @@ -593,7 +676,7 @@ jobs: # not needed. Gates are keyed by job name, except merged Build and the # macOS lint. echo "$RESULTS" | jq -e '.changes.outputs as $gates - | {build: ["build", "typecheck"], mobile_native_static_analysis: ["mobile_native"]} as $renamed + | {build: ["build", "typecheck", "desktop_launch"], mobile_native_static_analysis: ["mobile_native"]} as $renamed | all(to_entries[]; .value.result == "success" or (.value.result == "skipped" @@ -603,7 +686,7 @@ jobs: # server tests ran must have produced it. - name: Require transfer measurements if: needs.test_server.result == 'success' && needs.changes.outputs.test_server != 'false' - uses: actions/download-artifact@v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: thread-transfer-results path: ${{ runner.temp }}/thread-transfer diff --git a/.github/workflows/deploy-relay.yml b/.github/workflows/deploy-relay.yml index 5db7a86eb98c9..da779c2783d4c 100644 --- a/.github/workflows/deploy-relay.yml +++ b/.github/workflows/deploy-relay.yml @@ -51,7 +51,7 @@ jobs: ALCHEMY_TELEMETRY_DISABLED: "1" steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: sparse-checkout: | /* @@ -59,7 +59,7 @@ jobs: sparse-checkout-cone-mode: false - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1.15.0 with: node-version-file: package.json cache: true @@ -83,7 +83,7 @@ jobs: T3CHAT_LINK_SECRET: ${{ secrets.T3CHAT_LINK_SECRET }} - name: Publish relay deploy commit status - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: script: | await github.rest.repos.createCommitStatus({ diff --git a/.github/workflows/desktop-macos-preview-publish.yml b/.github/workflows/desktop-macos-preview-publish.yml index 6e8805b7f790b..88f96079b8979 100644 --- a/.github/workflows/desktop-macos-preview-publish.yml +++ b/.github/workflows/desktop-macos-preview-publish.yml @@ -55,7 +55,7 @@ jobs: steps: - id: pr name: Resolve the pull request behind the build - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: script: | const run = context.payload.workflow_run; @@ -134,7 +134,7 @@ jobs: - id: vouch name: Check PR author trust if: steps.pr.outputs.eligible == 'true' - uses: mitchellh/vouch/action/check-user@d66fa29a64600490892131ad87597c30c91fcac4 # v1 + uses: mitchellh/vouch/action/check-user@d66fa29a64600490892131ad87597c30c91fcac4 # v1.5.0 with: user: ${{ steps.pr.outputs.author }} allow-fail: true @@ -149,7 +149,7 @@ jobs: - id: consume name: Consume the preview label if: steps.pr.outputs.eligible == 'true' - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 env: PR_NUMBER: ${{ steps.pr.outputs.pr_number }} with: @@ -271,7 +271,7 @@ jobs: # Only validated bundle files cross into the signing job's artifact. - name: Stage JS bundle for packaging if: steps.gate.outputs.eligible == 'true' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: js-bundle path: ${{ runner.temp }}/js-bundle @@ -316,6 +316,7 @@ jobs: rust_target: aarch64-apple-darwin resource_key: darwin-arm64 cli_archive: false + untrusted_bundle: true # Release assets download without a GitHub account, unlike workflow # artifacts. All preview DMGs live on one rolling prerelease tagged @@ -338,7 +339,7 @@ jobs: pull-requests: write steps: - name: Download macOS artifacts - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: desktop-mac-arm64 path: release @@ -431,7 +432,7 @@ jobs: - name: Comment download link if: steps.upload.outputs.download_url != '' - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 env: PR_NUMBER: ${{ needs.resolve.outputs.pr_number }} DOWNLOAD_URL: ${{ steps.upload.outputs.download_url }} @@ -552,7 +553,7 @@ jobs: - name: Mark the preview comment as removed if: steps.delete.outputs.removed == 'true' - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: script: | const marker = ""; diff --git a/.github/workflows/desktop-macos-preview.yml b/.github/workflows/desktop-macos-preview.yml index 6dcdd702c2da1..2c54052ea66d5 100644 --- a/.github/workflows/desktop-macos-preview.yml +++ b/.github/workflows/desktop-macos-preview.yml @@ -50,7 +50,7 @@ jobs: git checkout --detach FETCH_HEAD - name: Setup Vite+ - uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1 + uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1.15.0 with: node-version-file: package.json cache: true @@ -73,21 +73,32 @@ jobs: # Public T3 Connect identifiers (Clerk publishable key, relay URL). cp .env.example .env - - uses: ./.github/actions/setup-apt-mirrors + # PR heads from before build:desktop:bundle use the full build, which + # compiles the Linux browser secret helper and needs libsecret. + - name: Check bundle-only build support + id: bundle_build + run: | + script="$(node -p "require('./package.json').scripts['build:desktop:bundle'] ? 'build:desktop:bundle' : 'build:desktop'")" + echo "script=$script" >> "$GITHUB_OUTPUT" + + - if: steps.bundle_build.outputs.script == 'build:desktop' + uses: ./.github/actions/setup-apt-mirrors - name: Install browser secret helper build libraries + if: steps.bundle_build.outputs.script == 'build:desktop' run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config - # desktop-macos-preview-publish.yml packages this bundle as the preview channel. + # desktop-macos-preview-publish.yml packages this bundle as the preview + # channel. The Linux browser secret helper is not part of a macOS app. - name: Build JS bundle env: T3CODE_TELEMETRY_CHANNEL: ${{ github.repository == 'pingdotgg/t3code' && 'preview' || 'dev' }} - run: vp run build:desktop + run: vp run ${{ steps.bundle_build.outputs.script }} # Same layout as release.yml's js-bundle so release-desktop.yml can # package it unchanged. - name: Upload JS bundle - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: js-bundle path: | diff --git a/.github/workflows/issue-labels.yml b/.github/workflows/issue-labels.yml index d6571d65d4530..fd9ba6b60302b 100644 --- a/.github/workflows/issue-labels.yml +++ b/.github/workflows/issue-labels.yml @@ -18,7 +18,7 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Ensure managed issue labels exist - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 with: script: | const managedLabels = [ diff --git a/.github/workflows/issue-vouch.yml b/.github/workflows/issue-vouch.yml new file mode 100644 index 0000000000000..bbb6e95c9ec60 --- /dev/null +++ b/.github/workflows/issue-vouch.yml @@ -0,0 +1,77 @@ +name: Issue Vouch + +on: + issues: + types: [opened, reopened] + push: + branches: + - main + paths: + - .github/VOUCHED.td + - .github/workflows/issue-vouch.yml + workflow_dispatch: + +permissions: + contents: read + issues: write + +concurrency: + group: issue-vouch-${{ github.event.issue.number || 'all' }} + cancel-in-progress: true + +jobs: + label: + name: Label issues by author trust + runs-on: ubuntu-24.04 + steps: + - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + script: | + const { owner, repo } = context.repo; + // Only listed authors get a label; open issues number in the thousands, + // so unvouched is the default and stays unlabelled. + const labelByStatus = { vouched: "vouch:trusted", denounced: "vouch:denounced" }; + const managedLabelNames = new Set(["vouch:trusted", "vouch:unvouched", "vouch:denounced"]); + + const { data: file } = await github.rest.repos.getContent({ + owner, + repo, + path: ".github/VOUCHED.td", + ref: context.payload.repository.default_branch, + }); + const statusByUser = new Map(); + for (const raw of Buffer.from(file.content, "base64").toString("utf8").split("\n")) { + const match = /^(-?)github:(\S+)/.exec(raw.trim()); + if (match) statusByUser.set(match[2].toLowerCase(), match[1] ? "denounced" : "vouched"); + } + + const issues = + context.eventName === "issues" + ? [context.payload.issue] + : ( + await github.paginate(github.rest.issues.listForRepo, { + owner, + repo, + state: "open", + per_page: 100, + }) + ).filter((issue) => !issue.pull_request); + + for (const issue of issues) { + const status = statusByUser.get(issue.user.login.toLowerCase()); + const next = labelByStatus[status]; + const current = issue.labels.map((label) => label.name).filter((name) => managedLabelNames.has(name)); + + for (const name of current) { + if (name === next) continue; + try { + await github.rest.issues.removeLabel({ owner, repo, issue_number: issue.number, name }); + } catch (error) { + if (error.status !== 404) throw error; + } + } + if (next && !current.includes(next)) { + await github.rest.issues.addLabels({ owner, repo, issue_number: issue.number, labels: [next] }); + core.info(`Issue #${issue.number} (${issue.user.login}): ${status} -> ${next}`); + } + } diff --git a/.github/workflows/mobile-android-apk.yml b/.github/workflows/mobile-android-apk.yml index 18d977c50f4a0..8650b5a6d527d 100644 --- a/.github/workflows/mobile-android-apk.yml +++ b/.github/workflows/mobile-android-apk.yml @@ -37,7 +37,7 @@ jobs: - name: Checkout if: steps.credentials.outputs.present == 'true' - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: sparse-checkout: .github/scripts diff --git a/.github/workflows/mobile-dev-client.yml b/.github/workflows/mobile-dev-client.yml new file mode 100644 index 0000000000000..b3e11c1ed6b6b --- /dev/null +++ b/.github/workflows/mobile-dev-client.yml @@ -0,0 +1,315 @@ +name: Mobile Dev Client +run-name: ${{ github.event_name == 'workflow_dispatch' && format('Dev client {0}{1} {2}', inputs.platform, inputs.platform == 'android' && format(' {0}', inputs.abi) || '', inputs.fingerprint) || '' }} + +# Builds the simulator and emulator development client for a commit's native +# fingerprint and shares it through the native-client cache, the one +# `vp run mobile:install` reads on every machine. Android builds with Gradle, +# ccache, and pnpm state on a sticky disk; iOS builds on a macOS runner, so no +# machine needs Xcode or an EAS build for it. +# +# - push to main: main's client is cached before anyone asks for it. +# - workflow_dispatch: `mobile:install` starts this for the checkout's HEAD on a +# cache miss, when the person running it may dispatch workflows here, and +# waits for the client in the cache. The run name is how it finds its run. +# - pull_request (this file or the script): builds without sharing, as a check. +on: + push: + branches: [main] + paths: + - apps/mobile/** + - packages/client-runtime/** + - packages/contracts/** + - packages/shared/** + - assets/** + - patches/** + - scripts/mobile-native-client.ts + - package.json + - pnpm-lock.yaml + - pnpm-workspace.yaml + - .github/workflows/mobile-dev-client.yml + pull_request: + paths: + - .github/workflows/mobile-dev-client.yml + - scripts/mobile-native-client.ts + workflow_dispatch: + inputs: + ref: + description: Commit to build (default main) + required: false + type: string + platform: + description: Platform + required: true + default: android + type: choice + options: [android, ios] + abi: + description: Android ABI of the device + required: true + default: x86_64 + type: choice + options: [x86_64, arm64-v8a] + fingerprint: + description: Native fingerprint the caller expects; the run fails when the commit's differs + required: false + type: string + +permissions: + contents: read + +jobs: + android: + name: Android ${{ matrix.abi }} + if: github.repository == 'pingdotgg/t3code' && (github.event_name != 'workflow_dispatch' || inputs.platform == 'android') + runs-on: blacksmith-32vcpu-ubuntu-2404 + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + # Pool emulators are x86_64; a dispatch builds the ABI it asks for. + abi: ${{ github.event_name == 'workflow_dispatch' && fromJSON(format('["{0}"]', inputs.abi)) || fromJSON('["x86_64"]') }} + concurrency: + # One run per client: a second request for the same fingerprint waits and finds it + # in the cache. A shared group would drop every waiting run but the newest. + group: mobile-dev-client-android-${{ matrix.abi }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || inputs.fingerprint || github.sha }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + env: + NODE_OPTIONS: --max-old-space-size=8192 + ABI: ${{ matrix.abi }} + EXPECTED_FINGERPRINT: ${{ inputs.fingerprint }} + # A pull request checks that the build works: it builds even on a cache hit and shares nothing. + T3_NATIVE_CLIENT_CACHE: ${{ github.event_name == 'pull_request' && 'off' || '' }} + # Gradle and Xcode output goes to the job log, not a file on the runner. + T3_NATIVE_CLIENT_VERBOSE: "1" + steps: + - name: Checkout + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + ref: ${{ inputs.ref || github.sha }} + + # A dispatch names any commit GitHub has, including a fork's. Only a commit that + # collaborators pushed here builds with the cache login and the shared disk. + - name: Check the commit belongs to this repository + if: github.event_name == 'workflow_dispatch' + env: + GH_TOKEN: ${{ github.token }} + run: | + sha="$(git rev-parse HEAD)" + if git ls-remote --heads origin | grep -q "^$sha"; then + exit 0 + fi + status="$(gh api "repos/$GITHUB_REPOSITORY/compare/$sha...main" --jq .status || true)" + if [ "$status" = "ahead" ] || [ "$status" = "identical" ]; then + exit 0 + fi + echo "::error::$sha is not the head of a branch in $GITHUB_REPOSITORY and not on main; build it locally." + exit 1 + + # The build runs dependency scripts; it gets no token. checkout keeps the token in a + # file under RUNNER_TEMP that .git/config includes, so both go. `persist-credentials: + # false` fails here: its cleanup runs `git submodule foreach`, which stops at the + # vendored gitlinks under .repos (no .gitmodules entry). + - name: Drop checkout credentials + run: | + git config --local --get-regexp '^includeif\..*\.path$' | while read -r key file; do + rm -f "$file" + git config --local --unset-all "$key" + done + git config --local --name-only --get-regexp '^http\..*\.extraheader$' | while read -r key; do + git config --local --unset-all "$key" + done + rm -f "$RUNNER_TEMP"/git-credentials-*.config + if git config --local --get-regexp 'extraheader|includeif' || ls "$RUNNER_TEMP"/git-credentials-*.config 2>/dev/null; then + echo "::error::checkout credentials are still present" + exit 1 + fi + + - name: Prepare build directories + run: | + cache="$HOME/.cache/t3-android-dev-client" + { + echo "GRADLE_USER_HOME=$cache/gradle" + echo "CCACHE_DIR=$cache/ccache" + echo "CCACHE_MAXSIZE=5G" + # Gradle installs the NDK fresh each run, so key on the compiler's version, not its mtime. + echo "CCACHE_COMPILERCHECK=%compiler% -v" + # The checkout path is the same on every run, so ccache keys stay stable. + echo "CCACHE_BASEDIR=$GITHUB_WORKSPACE" + # CMake (3.17+) reads these, and AGP passes Gradle's environment through. + echo "CMAKE_C_COMPILER_LAUNCHER=ccache" + echo "CMAKE_CXX_COMPILER_LAUNCHER=ccache" + } >> "$GITHUB_ENV" + + # Pull requests build cold: their code must not write Gradle or ccache state that + # the runs holding the cache login restore. + - name: Mount build cache + if: github.event_name != 'pull_request' + uses: useblacksmith/stickydisk@3f6be1451e3cad893b778bc93495c80452a5da26 # v1.7.2 + with: + key: ${{ github.repository }}-android-dev-client-${{ matrix.abi }} + path: ~/.cache/t3-android-dev-client + + - name: Configure Gradle and ccache + run: | + mkdir -p "$GRADLE_USER_HOME" "$CCACHE_DIR" + # No daemon: nothing should hold the sticky disk open when it is committed. + printf 'org.gradle.caching=true\norg.gradle.daemon=false\n' > "$GRADLE_USER_HOME/gradle.properties" + sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources + sudo apt-get update -qq && sudo apt-get install -y -qq ccache + ccache --zero-stats + + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} + install-args: --filter=@t3tools/mobile... --filter=@t3tools/scripts... + + - name: Setup Java + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + with: + distribution: temurin + java-version: 17 + + # Only this step's login can write to the cache; pull requests never get it. + # Only this login can write to the cache; pull requests never get it. + - id: login + name: Log in to the native client cache + if: github.event_name != 'pull_request' + env: + WRITE_TOKEN: ${{ secrets.T3_NATIVE_CLIENT_WRITE_TOKEN }} + run: | + if [ -z "$WRITE_TOKEN" ]; then + echo "::warning::T3_NATIVE_CLIENT_WRITE_TOKEN is not set; nothing can be shared, so nothing is built." + exit 0 + fi + node --input-type=module <<'NODE' + import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; + import { homedir } from "node:os"; + import { join } from "node:path"; + const { endpoint } = JSON.parse(readFileSync(".task-cache.json", "utf8")); + const directory = join(homedir(), ".config/t3-task-cache"); + mkdirSync(directory, { recursive: true, mode: 0o700 }); + writeFileSync( + join(directory, "credentials.json"), + JSON.stringify({ [endpoint]: { name: "ci-native-clients", token: process.env.WRITE_TOKEN } }), + { mode: 0o600 }, + ); + NODE + echo "T3_NATIVE_CLIENT_SHARE=required" >> "$GITHUB_ENV" + echo "ready=true" >> "$GITHUB_OUTPUT" + + - name: Build and share + if: github.event_name == 'pull_request' || steps.login.outputs.ready == 'true' + shell: bash # pipefail: a failed build fails the step through tee + run: | + node scripts/mobile-native-client.ts ensure android --prepare --abi "$ABI" ${EXPECTED_FINGERPRINT:+--expect-fingerprint "$EXPECTED_FINGERPRINT"} | tee "$RUNNER_TEMP/dev-client.log" + result="$(tail -n 1 "$RUNNER_TEMP/dev-client.log")" + echo "### android dev client: \`$(jq -r '.source' <<<"$result")\` for fingerprint \`$(jq -r '.fingerprint' <<<"$result")\`" >> "$GITHUB_STEP_SUMMARY" + + - name: Cache statistics + if: success() || failure() + continue-on-error: true + run: | + { + echo '```' + ccache --show-stats + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + ios: + name: iOS simulator + if: github.repository == 'pingdotgg/t3code' && (github.event_name != 'workflow_dispatch' || inputs.platform == 'ios') + runs-on: blacksmith-12vcpu-macos-27 + timeout-minutes: 60 + concurrency: + group: mobile-dev-client-ios-${{ github.event_name == 'pull_request' && github.event.pull_request.number || inputs.fingerprint || github.sha }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + env: + NODE_OPTIONS: --max-old-space-size=8192 + EXPECTED_FINGERPRINT: ${{ inputs.fingerprint }} + T3_NATIVE_CLIENT_CACHE: ${{ github.event_name == 'pull_request' && 'off' || '' }} + # Gradle and Xcode output goes to the job log, not a file on the runner. + T3_NATIVE_CLIENT_VERBOSE: "1" + steps: + - name: Checkout + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + ref: ${{ inputs.ref || github.sha }} + + # A dispatch names any commit GitHub has, including a fork's. Only a commit that + # collaborators pushed here builds with the cache login and the shared disk. + - name: Check the commit belongs to this repository + if: github.event_name == 'workflow_dispatch' + env: + GH_TOKEN: ${{ github.token }} + run: | + sha="$(git rev-parse HEAD)" + if git ls-remote --heads origin | grep -q "^$sha"; then + exit 0 + fi + status="$(gh api "repos/$GITHUB_REPOSITORY/compare/$sha...main" --jq .status || true)" + if [ "$status" = "ahead" ] || [ "$status" = "identical" ]; then + exit 0 + fi + echo "::error::$sha is not the head of a branch in $GITHUB_REPOSITORY and not on main; build it locally." + exit 1 + + # The build runs dependency scripts; it gets no token. checkout keeps the token in a + # file under RUNNER_TEMP that .git/config includes, so both go. `persist-credentials: + # false` fails here: its cleanup runs `git submodule foreach`, which stops at the + # vendored gitlinks under .repos (no .gitmodules entry). + - name: Drop checkout credentials + run: | + git config --local --get-regexp '^includeif\..*\.path$' | while read -r key file; do + rm -f "$file" + git config --local --unset-all "$key" + done + git config --local --name-only --get-regexp '^http\..*\.extraheader$' | while read -r key; do + git config --local --unset-all "$key" + done + rm -f "$RUNNER_TEMP"/git-credentials-*.config + if git config --local --get-regexp 'extraheader|includeif' || ls "$RUNNER_TEMP"/git-credentials-*.config 2>/dev/null; then + echo "::error::checkout credentials are still present" + exit 1 + fi + + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} + install-args: --filter=@t3tools/mobile... --filter=@t3tools/scripts... + + # Only this login can write to the cache; pull requests never get it. + - id: login + name: Log in to the native client cache + if: github.event_name != 'pull_request' + env: + WRITE_TOKEN: ${{ secrets.T3_NATIVE_CLIENT_WRITE_TOKEN }} + run: | + if [ -z "$WRITE_TOKEN" ]; then + echo "::warning::T3_NATIVE_CLIENT_WRITE_TOKEN is not set; nothing can be shared, so nothing is built." + exit 0 + fi + node --input-type=module <<'NODE' + import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; + import { homedir } from "node:os"; + import { join } from "node:path"; + const { endpoint } = JSON.parse(readFileSync(".task-cache.json", "utf8")); + const directory = join(homedir(), ".config/t3-task-cache"); + mkdirSync(directory, { recursive: true, mode: 0o700 }); + writeFileSync( + join(directory, "credentials.json"), + JSON.stringify({ [endpoint]: { name: "ci-native-clients", token: process.env.WRITE_TOKEN } }), + { mode: 0o600 }, + ); + NODE + echo "T3_NATIVE_CLIENT_SHARE=required" >> "$GITHUB_ENV" + echo "ready=true" >> "$GITHUB_OUTPUT" + + - name: Build and share + if: github.event_name == 'pull_request' || steps.login.outputs.ready == 'true' + shell: bash # pipefail: a failed build fails the step through tee + run: | + node scripts/mobile-native-client.ts ensure ios --prepare ${EXPECTED_FINGERPRINT:+--expect-fingerprint "$EXPECTED_FINGERPRINT"} | tee "$RUNNER_TEMP/dev-client.log" + result="$(tail -n 1 "$RUNNER_TEMP/dev-client.log")" + echo "### ios dev client: \`$(jq -r '.source' <<<"$result")\` for fingerprint \`$(jq -r '.fingerprint' <<<"$result")\`" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/mobile-eas-preview-fork.yml b/.github/workflows/mobile-eas-preview-fork.yml index e7615d46011bd..13ca814defd7e 100644 --- a/.github/workflows/mobile-eas-preview-fork.yml +++ b/.github/workflows/mobile-eas-preview-fork.yml @@ -1,8 +1,8 @@ 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 +# app config and plugins run on this runner, the iOS client builds on EAS and +# the Android one on Blacksmith (android-build), 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. @@ -29,6 +29,9 @@ jobs: permissions: contents: read pull-requests: write + outputs: + build: ${{ steps.client.outputs.build }} + fingerprint: ${{ steps.client.outputs.fingerprint }} env: APP_VARIANT: preview NODE_OPTIONS: --max-old-space-size=8192 @@ -37,7 +40,7 @@ jobs: # If a maintainer removed the label first, that removal wins. - id: consume name: Consume the deployment label - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: script: | try { @@ -72,18 +75,52 @@ jobs: # the PR's scripts to read. - name: Checkout reviewed commit if: steps.expo-token.outputs.present == 'true' - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 0 - persist-credentials: false # No sparse-checkout: see mobile-eas-preview.yml. + # The comment script comes from main, not from the PR. + - name: Checkout trusted scripts + if: steps.expo-token.outputs.present == 'true' + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + path: .trusted + sparse-checkout: .github/scripts + + # The steps below run the PR's dependency and build scripts; they get no token. + # checkout keeps it in a file under RUNNER_TEMP that .git/config includes, so both + # go. Asking checkout not to persist it fails here: that cleanup runs `git submodule + # foreach`, which stops at the vendored gitlinks under .repos (no .gitmodules entry). + - name: Drop checkout credentials + if: steps.expo-token.outputs.present == 'true' + run: | + for dir in . .trusted; do + [ -e "$dir/.git" ] || continue + git -C "$dir" config --local --get-regexp '^includeif\..*\.path$' | while read -r key file; do + rm -f "$file" + git -C "$dir" config --local --unset-all "$key" + done + git -C "$dir" config --local --name-only --get-regexp '^http\..*\.extraheader$' | while read -r key; do + git -C "$dir" config --local --unset-all "$key" + done + if git -C "$dir" config --local --get-regexp 'extraheader|includeif'; then + echo "::error::checkout credentials are still configured in $dir" + exit 1 + fi + done + rm -f "$RUNNER_TEMP"/git-credentials-*.config + if ls "$RUNNER_TEMP"/git-credentials-*.config 2>/dev/null; then + echo "::error::a checkout credential file is still present" + exit 1 + fi + # 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 + uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1.15.0 with: node-version-file: package.json cache: false @@ -101,7 +138,7 @@ jobs: - name: Setup EAS if: steps.expo-token.outputs.present == 'true' - uses: expo/expo-github-action@v8 + uses: expo/expo-github-action@c7b66a9c327a43a8fa7c0158e7f30d6040d2481e # v8.2.1 with: # Pinned and installed with npm for the same reasons as mobile-eas-preview.yml. eas-version: 21.8.0 @@ -129,7 +166,7 @@ jobs: - name: Deploy with fingerprint check if: steps.expo-token.outputs.present == 'true' - uses: expo/expo-github-action/continuous-deploy-fingerprint@main + uses: expo/expo-github-action/continuous-deploy-fingerprint@5273c2136bd09c2a341fe3340d3e767ce5a13015 # v9.0.2 env: EXPO_TOKEN: ${{ secrets.EXPO_PREVIEW_TOKEN }} RUNNER_DEBUG: "1" @@ -139,7 +176,180 @@ jobs: with: profile: preview:dev branch: pr-${{ github.event.pull_request.number }} - platform: all + # Android is below: its client builds on Blacksmith (android-build). + platform: ios environment: preview working-directory: apps/mobile github-token: ${{ secrets.GITHUB_TOKEN }} + + # Android, as in mobile-eas-preview.yml: the same fingerprint command the Expo + # action runs. A client built for a same-repo PR (its tag) or by EAS is reused. + - id: client + name: Find the Android client for this fingerprint + if: steps.expo-token.outputs.present == 'true' + working-directory: apps/mobile + shell: bash + env: + EXPO_TOKEN: ${{ secrets.EXPO_PREVIEW_TOKEN }} + run: | + hash="$(eas env:exec --non-interactive preview "npx expo-updates fingerprint:generate --platform android" | sed -n '/^{/,$p' | jq -er '.hash | select(type == "string" and length > 0)')" + echo "fingerprint=$hash" >> "$GITHUB_OUTPUT" + tag="mobile-android-preview-build/$hash" + link="" + if git fetch --quiet origin "refs/tags/$tag:refs/tags/$tag" 2>/dev/null; then + link="$(git tag -l --format='%(contents:subject)' "$tag")" + else + link="$(eas build:list --platform android --build-profile preview:dev --runtime-version "$hash" --status finished --limit 5 --json --non-interactive \ + | sed -n '/^\[/,$p' \ + | jq -r --arg now "$(date -u +%Y-%m-%dT%H:%M:%SZ)" '[.[] | select((.expirationDate // "9999") > $now)][0] | select(. != null) | "https://expo.dev/accounts/pingdotgg/projects/t3-code/builds/\(.id)"')" + fi + echo "link=$link" >> "$GITHUB_OUTPUT" + if [ -z "$link" ]; then echo "build=true" >> "$GITHUB_OUTPUT"; fi + + - id: update + name: Publish the Android update + if: steps.expo-token.outputs.present == 'true' + working-directory: apps/mobile + shell: bash + env: + EXPO_TOKEN: ${{ secrets.EXPO_PREVIEW_TOKEN }} + PR: ${{ github.event.pull_request.number }} + run: | + group="$(eas update --auto --branch "pr-$PR" --platform android --environment preview --non-interactive --json | sed -n '/^\[/,$p' | jq -er '.[0].group')" + echo "group=$group" >> "$GITHUB_OUTPUT" + + - name: Comment + if: steps.expo-token.outputs.present == 'true' + env: + GH_TOKEN: ${{ github.token }} + PR: ${{ github.event.pull_request.number }} + FINGERPRINT: ${{ steps.client.outputs.fingerprint }} + CLIENT: ${{ steps.client.outputs.link }} + GROUP: ${{ steps.update.outputs.group }} + run: node .trusted/.github/scripts/mobile-preview-comment.cjs + + # The reviewed commit's Android client, built cold: no sticky disk, because PR + # code could leave Gradle or ccache state that trusted builds would restore, and + # no build record tag, so nobody else is served an APK a fork's code built. + android-build: + name: Android preview client + needs: preview + if: needs.preview.outputs.build == 'true' + runs-on: blacksmith-32vcpu-ubuntu-2404 + timeout-minutes: 60 + permissions: + contents: read + pull-requests: write + env: + APP_VARIANT: preview + NODE_OPTIONS: --max-old-space-size=8192 + MOBILE_VERSION_POLICY: fingerprint + FINGERPRINT: ${{ needs.preview.outputs.fingerprint }} + # Phones only: emulators use the development client from the native-client cache. + ORG_GRADLE_PROJECT_reactNativeArchitectures: armeabi-v7a,arm64-v8a + steps: + - name: Checkout reviewed commit + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + + # The comment script comes from main, not from the PR. + - name: Checkout trusted scripts + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + path: .trusted + sparse-checkout: .github/scripts + + # The steps below run the PR's dependency and build scripts; they get no token. + # checkout keeps it in a file under RUNNER_TEMP that .git/config includes, so both + # go. Asking checkout not to persist it fails here: that cleanup runs `git submodule + # foreach`, which stops at the vendored gitlinks under .repos (no .gitmodules entry). + - name: Drop checkout credentials + run: | + for dir in . .trusted; do + [ -e "$dir/.git" ] || continue + git -C "$dir" config --local --get-regexp '^includeif\..*\.path$' | while read -r key file; do + rm -f "$file" + git -C "$dir" config --local --unset-all "$key" + done + git -C "$dir" config --local --name-only --get-regexp '^http\..*\.extraheader$' | while read -r key; do + git -C "$dir" config --local --unset-all "$key" + done + if git -C "$dir" config --local --get-regexp 'extraheader|includeif'; then + echo "::error::checkout credentials are still configured in $dir" + exit 1 + fi + done + rm -f "$RUNNER_TEMP"/git-credentials-*.config + if ls "$RUNNER_TEMP"/git-credentials-*.config 2>/dev/null; then + echo "::error::a checkout credential file is still present" + exit 1 + fi + + - name: Setup Node + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + with: + node-version-file: package.json + + - name: Enable corepack + run: | + npm install --global corepack@latest + corepack enable + pnpm --version + + - name: Install mobile dependencies + run: pnpm install --frozen-lockfile --filter=@t3tools/mobile... + + - name: Setup Java + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + with: + distribution: temurin + java-version: 17 + + - name: Setup EAS + uses: expo/expo-github-action@c7b66a9c327a43a8fa7c0158e7f30d6040d2481e # v8.2.1 + with: + eas-version: latest + eas-cache: false + token: ${{ secrets.EXPO_PREVIEW_TOKEN }} + packager: npm + + - name: Pull preview environment variables + working-directory: apps/mobile + env: + EXPO_TOKEN: ${{ secrets.EXPO_PREVIEW_TOKEN }} + run: | + eas env:pull preview --non-interactive + 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 + + - id: build + name: Build and upload + working-directory: apps/mobile + shell: bash + env: + EXPO_TOKEN: ${{ secrets.EXPO_PREVIEW_TOKEN }} + run: | + eas build --local --platform android --profile preview:dev --non-interactive --output "$RUNNER_TEMP/t3code-preview.apk" + eas upload --platform android --build-path "$RUNNER_TEMP/t3code-preview.apk" --fingerprint "$FINGERPRINT" --non-interactive --json | tee "$RUNNER_TEMP/upload.json" + link="$(sed -n '/^{/,$p' "$RUNNER_TEMP/upload.json" | jq -r '.url // empty')" + if [ -z "$link" ]; then + echo "::error::eas upload returned no install link" + exit 1 + fi + echo "link=$link" >> "$GITHUB_OUTPUT" + + - name: Comment + env: + GH_TOKEN: ${{ github.token }} + PR: ${{ github.event.pull_request.number }} + CLIENT: ${{ steps.build.outputs.link }} + run: node .trusted/.github/scripts/mobile-preview-comment.cjs diff --git a/.github/workflows/mobile-eas-preview.yml b/.github/workflows/mobile-eas-preview.yml index 08183d0385811..b46e090434f58 100644 --- a/.github/workflows/mobile-eas-preview.yml +++ b/.github/workflows/mobile-eas-preview.yml @@ -6,7 +6,7 @@ on: jobs: preview: - name: EAS Preview + name: EAS Preview (iOS) # 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 && @@ -38,7 +38,7 @@ jobs: - name: Checkout if: steps.expo-token.outputs.present == 'true' - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: fetch-depth: 0 # No sparse-checkout here: it makes actions/checkout fetch with @@ -48,7 +48,7 @@ jobs: - name: Setup Vite+ if: steps.expo-token.outputs.present == 'true' - uses: voidzero-dev/setup-vp@v1 + uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1.15.0 with: node-version-file: package.json cache: true @@ -66,7 +66,7 @@ jobs: - name: Setup EAS if: steps.expo-token.outputs.present == 'true' - uses: expo/expo-github-action@v8 + uses: expo/expo-github-action@c7b66a9c327a43a8fa7c0158e7f30d6040d2481e # v8.2.1 with: # eas-cli 22 renamed `project` to `app` in build JSON, and # continuous-deploy-fingerprint still reads `build.project.ownerAccount`. @@ -99,7 +99,7 @@ jobs: - name: Deploy with fingerprint check if: steps.expo-token.outputs.present == 'true' - uses: expo/expo-github-action/continuous-deploy-fingerprint@main + uses: expo/expo-github-action/continuous-deploy-fingerprint@5273c2136bd09c2a341fe3340d3e767ce5a13015 # v9.0.2 env: EXPO_TOKEN: ${{ secrets.EXPO_PREVIEW_TOKEN }} # The Expo action otherwise suppresses the EAS command's failure output. @@ -107,7 +107,349 @@ jobs: with: profile: preview:dev branch: pr-${{ github.event.pull_request.number }} - platform: all + # Android is the android job below: its client builds on Blacksmith. + platform: ios environment: preview working-directory: apps/mobile github-token: ${{ secrets.GITHUB_TOKEN }} + + # Android publishes its own update and comment, because the Expo action can only + # find a client that EAS built. A fingerprint with no client is built by + # android-build with `eas build --local` on Blacksmith (about 4 minutes warm, + # against about 40 on EAS) and recorded as a mobile-android-preview-build/ + # tag whose message is the install link. Clients EAS built earlier still count. + android: + name: EAS Preview (Android) + 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: blacksmith-8vcpu-ubuntu-2404 + concurrency: + group: mobile-eas-preview-android-${{ github.event.pull_request.number }} + cancel-in-progress: true + permissions: + contents: read + pull-requests: write + outputs: + build: ${{ steps.client.outputs.build }} + fingerprint: ${{ steps.client.outputs.fingerprint }} + env: + APP_VARIANT: preview + NODE_OPTIONS: --max-old-space-size=8192 + MOBILE_VERSION_POLICY: fingerprint + EXPO_TOKEN: ${{ secrets.EXPO_PREVIEW_TOKEN }} + GH_TOKEN: ${{ github.token }} + PR: ${{ github.event.pull_request.number }} + steps: + - id: expo-token + name: Check for 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 available; skipping the Android preview." + fi + + - name: Checkout + if: steps.expo-token.outputs.present == 'true' + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + fetch-depth: 0 + + # The steps below run the PR's dependency and build scripts; they get no token. + # checkout keeps it in a file under RUNNER_TEMP that .git/config includes, so both + # go. Asking checkout not to persist it fails here: that cleanup runs `git submodule + # foreach`, which stops at the vendored gitlinks under .repos (no .gitmodules entry). + - name: Drop checkout credentials + if: steps.expo-token.outputs.present == 'true' + run: | + for dir in . .trusted; do + [ -e "$dir/.git" ] || continue + git -C "$dir" config --local --get-regexp '^includeif\..*\.path$' | while read -r key file; do + rm -f "$file" + git -C "$dir" config --local --unset-all "$key" + done + git -C "$dir" config --local --name-only --get-regexp '^http\..*\.extraheader$' | while read -r key; do + git -C "$dir" config --local --unset-all "$key" + done + if git -C "$dir" config --local --get-regexp 'extraheader|includeif'; then + echo "::error::checkout credentials are still configured in $dir" + exit 1 + fi + done + rm -f "$RUNNER_TEMP"/git-credentials-*.config + if ls "$RUNNER_TEMP"/git-credentials-*.config 2>/dev/null; then + echo "::error::a checkout credential file is still present" + exit 1 + fi + + - name: Setup Vite+ + if: steps.expo-token.outputs.present == 'true' + uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1.15.0 + with: + node-version-file: package.json + cache: true + 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@c7b66a9c327a43a8fa7c0158e7f30d6040d2481e # v8.2.1 + with: + eas-version: latest + token: ${{ secrets.EXPO_PREVIEW_TOKEN }} + packager: npm + + - name: Pull preview environment variables + if: steps.expo-token.outputs.present == 'true' + working-directory: apps/mobile + run: | + eas env:pull preview --non-interactive + 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 + + # The same command the Expo action runs, so this is the runtime version the update gets. + - id: client + name: Find the client for this fingerprint + if: steps.expo-token.outputs.present == 'true' + working-directory: apps/mobile + shell: bash + run: | + hash="$(eas env:exec --non-interactive preview "npx expo-updates fingerprint:generate --platform android" | sed -n '/^{/,$p' | jq -er '.hash | select(type == "string" and length > 0)')" + echo "fingerprint=$hash" >> "$GITHUB_OUTPUT" + tag="mobile-android-preview-build/$hash" + link="" + if git fetch --quiet origin "refs/tags/$tag:refs/tags/$tag" 2>/dev/null; then + link="$(git tag -l --format='%(contents:subject)' "$tag")" + else + # A client EAS built before this job existed; expired ones are gone. + link="$(eas build:list --platform android --build-profile preview:dev --runtime-version "$hash" --status finished --limit 5 --json --non-interactive \ + | sed -n '/^\[/,$p' \ + | jq -r --arg now "$(date -u +%Y-%m-%dT%H:%M:%SZ)" '[.[] | select((.expirationDate // "9999") > $now)][0] | select(. != null) | "https://expo.dev/accounts/pingdotgg/projects/t3-code/builds/\(.id)"')" + fi + echo "link=$link" >> "$GITHUB_OUTPUT" + if [ -z "$link" ]; then echo "build=true" >> "$GITHUB_OUTPUT"; fi + echo "android fingerprint \`$hash\`: ${link:-no client yet, building on Blacksmith}" >> "$GITHUB_STEP_SUMMARY" + + - id: update + name: Publish the update + if: steps.expo-token.outputs.present == 'true' + working-directory: apps/mobile + shell: bash + run: | + group="$(eas update --auto --branch "pr-$PR" --platform android --environment preview --non-interactive --json | sed -n '/^\[/,$p' | jq -er '.[0].group')" + echo "group=$group" >> "$GITHUB_OUTPUT" + + - name: Comment + if: steps.expo-token.outputs.present == 'true' + env: + FINGERPRINT: ${{ steps.client.outputs.fingerprint }} + CLIENT: ${{ steps.client.outputs.link }} + GROUP: ${{ steps.update.outputs.group }} + run: node .github/scripts/mobile-preview-comment.cjs + + android-build: + name: Android preview client + needs: android + if: needs.android.outputs.build == 'true' + runs-on: blacksmith-32vcpu-ubuntu-2404 + timeout-minutes: 60 + concurrency: + # One build per fingerprint; a second PR with the same one finds the tag. + group: mobile-android-preview-build-${{ needs.android.outputs.fingerprint }} + cancel-in-progress: false + permissions: + contents: write # pushes the build record tag + pull-requests: write + env: + APP_VARIANT: preview + NODE_OPTIONS: --max-old-space-size=8192 + MOBILE_VERSION_POLICY: fingerprint + EXPO_TOKEN: ${{ secrets.EXPO_PREVIEW_TOKEN }} + FINGERPRINT: ${{ needs.android.outputs.fingerprint }} + BUILD_TAG: mobile-android-preview-build/${{ needs.android.outputs.fingerprint }} + # Phones only: emulators use the development client from the native-client cache. + ORG_GRADLE_PROJECT_reactNativeArchitectures: armeabi-v7a,arm64-v8a + steps: + - name: Checkout + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + fetch-depth: 0 + + - id: record + name: Check build record + run: | + if git fetch --quiet origin "refs/tags/$BUILD_TAG:refs/tags/$BUILD_TAG" 2>/dev/null; then + echo "built=true" >> "$GITHUB_OUTPUT" + echo "link=$(git tag -l --format='%(contents:subject)' "$BUILD_TAG")" >> "$GITHUB_OUTPUT" + fi + + # The steps below run the PR's dependency and build scripts; they get no token. + # checkout keeps it in a file under RUNNER_TEMP that .git/config includes, so both + # go. Asking checkout not to persist it fails here: that cleanup runs `git submodule + # foreach`, which stops at the vendored gitlinks under .repos (no .gitmodules entry). + - name: Drop checkout credentials + run: | + for dir in . .trusted; do + [ -e "$dir/.git" ] || continue + git -C "$dir" config --local --get-regexp '^includeif\..*\.path$' | while read -r key file; do + rm -f "$file" + git -C "$dir" config --local --unset-all "$key" + done + git -C "$dir" config --local --name-only --get-regexp '^http\..*\.extraheader$' | while read -r key; do + git -C "$dir" config --local --unset-all "$key" + done + if git -C "$dir" config --local --get-regexp 'extraheader|includeif'; then + echo "::error::checkout credentials are still configured in $dir" + exit 1 + fi + done + rm -f "$RUNNER_TEMP"/git-credentials-*.config + if ls "$RUNNER_TEMP"/git-credentials-*.config 2>/dev/null; then + echo "::error::a checkout credential file is still present" + exit 1 + fi + + - name: Prepare build directories + if: steps.record.outputs.built != 'true' + run: | + cache="$HOME/.cache/t3-android-preview" + { + echo "GRADLE_USER_HOME=$cache/gradle" + echo "CCACHE_DIR=$cache/ccache" + echo "CCACHE_MAXSIZE=5G" + # Gradle installs the NDK fresh each run, so key on the compiler's version, not its mtime. + echo "CCACHE_COMPILERCHECK=%compiler% -v" + echo "pnpm_config_store_dir=$cache/pnpm-store" + # eas build --local copies the project here. A fixed path keeps + # ccache keys stable, and the same disk as the store lets pnpm hardlink. + echo "EAS_LOCAL_BUILD_WORKINGDIR=$cache/eas-build" + echo "CCACHE_BASEDIR=$cache/eas-build" + echo "CMAKE_C_COMPILER_LAUNCHER=ccache" + echo "CMAKE_CXX_COMPILER_LAUNCHER=ccache" + } >> "$GITHUB_ENV" + + - name: Mount build cache + if: steps.record.outputs.built != 'true' + uses: useblacksmith/stickydisk@3f6be1451e3cad893b778bc93495c80452a5da26 # v1.7.2 + with: + key: ${{ github.repository }}-android-preview-build + path: ~/.cache/t3-android-preview + + - name: Configure Gradle and ccache + if: steps.record.outputs.built != 'true' + run: | + mkdir -p "$GRADLE_USER_HOME" "$CCACHE_DIR" + rm -rf "$EAS_LOCAL_BUILD_WORKINGDIR" + # No daemon: nothing should hold the sticky disk open when it is committed. + printf 'org.gradle.caching=true\norg.gradle.daemon=false\n' > "$GRADLE_USER_HOME/gradle.properties" + sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources + sudo apt-get update -qq && sudo apt-get install -y -qq ccache + ccache --zero-stats + + - name: Setup Node + if: steps.record.outputs.built != 'true' + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + with: + node-version-file: package.json + + - name: Enable corepack + if: steps.record.outputs.built != 'true' + run: | + npm install --global corepack@latest + corepack enable + pnpm --version + + - name: Install mobile dependencies + if: steps.record.outputs.built != 'true' + run: pnpm install --frozen-lockfile --prefer-offline --filter=@t3tools/mobile... + + - name: Setup Java + if: steps.record.outputs.built != 'true' + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + with: + distribution: temurin + java-version: 17 + + - name: Setup EAS + if: steps.record.outputs.built != 'true' + uses: expo/expo-github-action@c7b66a9c327a43a8fa7c0158e7f30d6040d2481e # v8.2.1 + with: + eas-version: latest + token: ${{ secrets.EXPO_PREVIEW_TOKEN }} + packager: npm + + - name: Pull preview environment variables + if: steps.record.outputs.built != 'true' + working-directory: apps/mobile + run: | + eas env:pull preview --non-interactive + 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 + + # `eas upload` hosts the APK on EAS and returns the page testers install from. + - id: build + name: Build and upload + if: steps.record.outputs.built != 'true' + working-directory: apps/mobile + shell: bash + run: | + eas build --local --platform android --profile preview:dev --non-interactive --output "$RUNNER_TEMP/t3code-preview.apk" + eas upload --platform android --build-path "$RUNNER_TEMP/t3code-preview.apk" --fingerprint "$FINGERPRINT" --non-interactive --json | tee "$RUNNER_TEMP/upload.json" + link="$(sed -n '/^{/,$p' "$RUNNER_TEMP/upload.json" | jq -r '.url // empty')" + if [ -z "$link" ]; then + echo "::error::eas upload returned no install link" + exit 1 + fi + echo "link=$link" >> "$GITHUB_OUTPUT" + { + echo "### Android preview client for \`$FINGERPRINT\`: $link" + echo '```' + ccache --show-stats + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + # The tag is the record later runs look up; its message is the install link. + - name: Record build + if: steps.record.outputs.built != 'true' + env: + LINK: ${{ steps.build.outputs.link }} + GH_TOKEN: ${{ github.token }} + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag -a "$BUILD_TAG" -m "$LINK" + # Only this step has the token; the checkout's copy was removed before the build. + git push "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "refs/tags/$BUILD_TAG" + + - name: Comment + env: + GH_TOKEN: ${{ github.token }} + PR: ${{ github.event.pull_request.number }} + CLIENT: ${{ steps.build.outputs.link || steps.record.outputs.link }} + run: node .github/scripts/mobile-preview-comment.cjs diff --git a/.github/workflows/mobile-eas-production.yml b/.github/workflows/mobile-eas-production.yml index 6877f5c831a08..cdabb1e4b7ca3 100644 --- a/.github/workflows/mobile-eas-production.yml +++ b/.github/workflows/mobile-eas-production.yml @@ -7,6 +7,12 @@ name: Mobile EAS Production # runner, with corepack pinning pnpm 10.24 in eas.json, local == build. # # Every merge reconciles native fingerprints and publishes matching OTAs. +# Android store builds run here with `eas build --local` on Blacksmith (the +# android job). iOS builds run on EAS unless the MOBILE_IOS_LOCAL_BUILDS +# repository variable is `true`, which moves them to the ios job the same way. +# Local builds never appear in `eas build:list`, so each finished local build +# pushes a mobile-android-build// or +# mobile-ios-build/// tag that the reconcile reads. # iOS prereleases use the testflight profile (3.0.0); App Store binaries use # production and the committed app version, built manually. Both receive OTAs # for their own major version. Android continues using production automatically. @@ -65,20 +71,29 @@ on: - pnpm-workspace.yaml - .github/workflows/mobile-eas-production.yml -# Serialize runs so OTAs publish in merge order. GitHub keeps at most one -# queued run per group, so a burst of merges collapses into one run of the -# newest commit — intermediate commits don't need their own OTA. -concurrency: - group: mobile-eas-production - cancel-in-progress: false - jobs: production: name: EAS ${{ inputs.profile || 'production' }} ${{ github.event_name == 'push' && 'auto' || inputs.mode }} if: github.repository == 'pingdotgg/t3code' runs-on: blacksmith-8vcpu-ubuntu-2404 + # Serialize runs so OTAs publish in merge order. GitHub keeps at most one + # queued job per group, so a burst of merges collapses into one run of the + # newest commit — intermediate commits don't need their own OTA. + concurrency: + group: mobile-eas-production + cancel-in-progress: false permissions: contents: read + outputs: + android_build: ${{ steps.reconcile.outputs.android_build || steps.android_manual.outputs.android_build }} + android_fingerprint: ${{ steps.reconcile.outputs.android_fingerprint || steps.android_manual.outputs.android_fingerprint }} + android_version: ${{ steps.reconcile.outputs.android_version || steps.android_manual.outputs.android_version }} + android_sha: ${{ steps.reconcile.outputs.android_sha || steps.android_manual.outputs.android_sha }} + ios_build: ${{ steps.reconcile.outputs.ios_build || steps.ios_manual.outputs.ios_build }} + ios_profile: ${{ steps.reconcile.outputs.ios_profile || steps.ios_manual.outputs.ios_profile }} + ios_fingerprint: ${{ steps.reconcile.outputs.ios_fingerprint || steps.ios_manual.outputs.ios_fingerprint }} + ios_version: ${{ steps.reconcile.outputs.ios_version || steps.ios_manual.outputs.ios_version }} + ios_sha: ${{ steps.reconcile.outputs.ios_sha || steps.ios_manual.outputs.ios_sha }} env: APP_VARIANT: production NODE_OPTIONS: --max-old-space-size=8192 @@ -109,7 +124,7 @@ jobs: - id: version_app_token name: Mint release app token for version override if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'build' && inputs.version != '' && inputs.profile != 'testflight' - uses: actions/create-github-app-token@v2 + uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2.2.2 with: app-id: ${{ secrets.RELEASE_APP_ID }} private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} @@ -117,7 +132,7 @@ jobs: - name: Checkout if: steps.expo-token.outputs.present == 'true' - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: fetch-depth: 0 token: ${{ steps.version_app_token.outputs.token || github.token }} @@ -128,7 +143,7 @@ jobs: - name: Setup Vite+ if: steps.expo-token.outputs.present == 'true' - uses: voidzero-dev/setup-vp@v1 + uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1.15.0 with: node-version-file: package.json cache: true @@ -146,7 +161,7 @@ jobs: - name: Setup EAS if: steps.expo-token.outputs.present == 'true' - uses: expo/expo-github-action@v8 + uses: expo/expo-github-action@c7b66a9c327a43a8fa7c0158e7f30d6040d2481e # v8.2.1 with: eas-version: latest token: ${{ secrets.EXPO_PRODUCTION_TOKEN }} @@ -215,9 +230,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 --no-verify \ - -m "chore(mobile): bump app version to $RELEASE_VERSION" \ - -m "Co-authored-by: codex " + git commit --no-verify -m "chore(mobile): bump app version to $RELEASE_VERSION" git push origin "HEAD:refs/heads/${GITHUB_REF_NAME}" - name: Summarize manual build version @@ -240,13 +253,51 @@ jobs: echo "> Apple closes an iOS release train after App Store approval. Before building iOS, confirm \`$version\` is newer than the approved App Store version." } >> "$GITHUB_STEP_SUMMARY" - - name: Build and submit (manual) - if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'build' + - name: Build and submit iOS (manual) + if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'build' && inputs.platform != 'android' && vars.MOBILE_IOS_LOCAL_BUILDS != 'true' + working-directory: apps/mobile + env: + EXPO_TOKEN: ${{ secrets.EXPO_PRODUCTION_TOKEN }} + MOBILE_BUILD_PROFILE: ${{ inputs.profile || 'production' }} + run: eas build --platform ios --profile "$MOBILE_BUILD_PROFILE" --auto-submit --non-interactive --no-wait + + - id: ios_manual + name: Queue iOS build (manual) + if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'build' && inputs.platform != 'android' && vars.MOBILE_IOS_LOCAL_BUILDS == 'true' working-directory: apps/mobile env: 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 + run: | + if [ "$MOBILE_BUILD_PROFILE" = "testflight" ]; then + T3CODE_MOBILE_VERSION="$(node -p 'require("./eas.json").build.testflight.env.T3CODE_MOBILE_VERSION')" + export T3CODE_MOBILE_VERSION + fi + version="$(npx expo config --json --type public | jq -r '.version')" + hash="$(eas fingerprint:generate --platform ios --environment production --json --non-interactive | sed -n '/^{/,$p' | jq -er '.hash | select(type == "string" and length > 0)')" + { + echo "ios_build=true" + echo "ios_profile=$MOBILE_BUILD_PROFILE" + echo "ios_fingerprint=$hash" + echo "ios_version=$version" + echo "ios_sha=$(git rev-parse HEAD)" + } >> "$GITHUB_OUTPUT" + + - id: android_manual + name: Queue Android build (manual) + if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'build' && inputs.platform != 'ios' + working-directory: apps/mobile + env: + EXPO_TOKEN: ${{ secrets.EXPO_PRODUCTION_TOKEN }} + run: | + version="$(npx expo config --json --type public | jq -r '.version')" + hash="$(eas fingerprint:generate --platform android --environment production --json --non-interactive | sed -n '/^{/,$p' | jq -er '.hash | select(type == "string" and length > 0)')" + { + echo "android_build=true" + echo "android_fingerprint=$hash" + echo "android_version=$version" + echo "android_sha=$(git rev-parse HEAD)" + } >> "$GITHUB_OUTPUT" # @expo/fingerprint swallows autolinking errors and returns no Expo module # sources, so native changes stop moving the hash and OTAs reach binaries @@ -300,6 +351,7 @@ jobs: working-directory: apps/mobile env: EXPO_TOKEN: ${{ secrets.EXPO_PRODUCTION_TOKEN }} + IOS_LOCAL_BUILDS: ${{ vars.MOBILE_IOS_LOCAL_BUILDS == 'true' }} run: | failed=0 message="$(git log -1 --pretty=%s | head -c 120) ($(git rev-parse --short=9 HEAD))" @@ -320,6 +372,22 @@ jobs: active="$(jq '[.[] | select(.status == "NEW" or .status == "IN_QUEUE" or .status == "IN_PROGRESS")] | length' <<<"$matches")" errored="$(jq '[.[] | select(.status == "ERRORED")] | length' <<<"$matches")" latest_version="$(eas build:list --platform "$platform" --build-profile "$profile" --limit 1 --json --non-interactive | jq -r '.[0].appVersion // "none"')" + # Local builds (the android and ios jobs) record each build as a tag; + # EAS records still count for builds made before that. + tag_prefix="" + if [ "$platform" = "android" ]; then + tag_prefix="mobile-android-build" + elif [ "$IOS_LOCAL_BUILDS" = "true" ]; then + tag_prefix="mobile-ios-build/$profile" + fi + if [ -n "$tag_prefix" ]; then + if [ -n "$(git tag -l "$tag_prefix/*/$hash")" ]; then + finished=$((finished + 1)) + fi + if [ -n "$(git tag -l "$tag_prefix/$version/*")" ]; then + latest_version="$version" + fi + fi if [ "$finished" -gt 0 ]; then if eas update \ @@ -363,6 +431,27 @@ jobs: fi echo "$platform: $reason — building" + if [ "$platform" = "android" ]; then + { + echo "android_build=true" + echo "android_fingerprint=$hash" + echo "android_version=$version" + echo "android_sha=$(git rev-parse HEAD)" + } >> "$GITHUB_OUTPUT" + echo ":building_construction: android: building and submitting $version in the Android store build job ($reason)" >> "$GITHUB_STEP_SUMMARY" + continue + fi + if [ "$platform" = "ios" ] && [ "$IOS_LOCAL_BUILDS" = "true" ]; then + { + echo "ios_build=true" + echo "ios_profile=$profile" + echo "ios_fingerprint=$hash" + echo "ios_version=$version" + echo "ios_sha=$(git rev-parse HEAD)" + } >> "$GITHUB_OUTPUT" + echo ":building_construction: ios/$profile: building and submitting $version in the iOS store build job ($reason)" >> "$GITHUB_STEP_SUMMARY" + continue + fi if eas build --platform "$platform" --profile "$profile" --auto-submit --non-interactive --no-wait; then echo ":building_construction: $platform: scheduled production build and submission for $version ($reason)" >> "$GITHUB_STEP_SUMMARY" else @@ -386,3 +475,364 @@ jobs: node scripts/upload-sourcemaps.mts ota "$dir" fi done + + # `eas build --local` on Blacksmith, ARM-only, without release lint (see the + # production profile in eas.json), with Gradle, ccache, and pnpm state on a + # sticky disk. Its own concurrency group keeps later OTAs from waiting on it. + android: + name: Android store build + needs: production + # Skipped when the production job failed: no store build after a failed OTA. + if: needs.production.outputs.android_build == 'true' + runs-on: blacksmith-32vcpu-ubuntu-2404 + timeout-minutes: 60 + concurrency: + group: mobile-android-store-build + cancel-in-progress: false + permissions: + contents: write # pushes the build record tag + env: + APP_VARIANT: production + NODE_OPTIONS: --max-old-space-size=8192 + EXPO_TOKEN: ${{ secrets.EXPO_PRODUCTION_TOKEN }} + BUILD_TAG: mobile-android-build/${{ needs.production.outputs.android_version }}/${{ needs.production.outputs.android_fingerprint }} + steps: + - name: Checkout + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + ref: ${{ needs.production.outputs.android_sha }} + fetch-depth: 0 + + # Dependency scripts and the build run in this job; only Record build gets a token. + # persist-credentials: false would clean up via `git submodule foreach`, which fails on .repos gitlinks. + - name: Drop checkout credentials + run: | + git config --local --unset-all http.https://github.com/.extraheader || true + { git config --local --name-only --get-regexp '^includeif\.' || true; } | while read -r key; do + path=$(git config --local --get "$key") + git config --local --unset-all "$key" + case "$path" in "$RUNNER_TEMP"/*) rm -f "$path" ;; esac + done + if git config --local --get-regexp '^(http\..*\.extraheader|includeif\.)'; then + echo "::error::Checkout credentials are still configured" + exit 1 + fi + + # A queued run can follow a build of the same fingerprint; manual runs always build. + - id: record + name: Check build record + env: + FORCE: ${{ github.event_name == 'workflow_dispatch' }} + run: | + if [ "$FORCE" != "true" ] && [ -n "$(git tag -l "$BUILD_TAG")" ]; then + echo "built=true" >> "$GITHUB_OUTPUT" + echo ":white_check_mark: android: \`$BUILD_TAG\` was built by an earlier run" >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Prepare build directories + if: steps.record.outputs.built != 'true' + run: | + cache="$HOME/.cache/t3-android-store" + { + echo "GRADLE_USER_HOME=$cache/gradle" + echo "CCACHE_DIR=$cache/ccache" + echo "CCACHE_MAXSIZE=5G" + # Gradle installs the NDK fresh each run, so key on the compiler's version, not its mtime. + echo "CCACHE_COMPILERCHECK=%compiler% -v" + echo "pnpm_config_store_dir=$cache/pnpm-store" + # eas build --local copies the project here. A fixed path keeps + # ccache keys stable, and the same disk as the store lets pnpm hardlink. + echo "EAS_LOCAL_BUILD_WORKINGDIR=$cache/eas-build" + echo "CCACHE_BASEDIR=$cache/eas-build" + # CMake (3.17+) reads these, and AGP passes Gradle's environment through. + echo "CMAKE_C_COMPILER_LAUNCHER=ccache" + echo "CMAKE_CXX_COMPILER_LAUNCHER=ccache" + } >> "$GITHUB_ENV" + + - name: Mount build cache + if: steps.record.outputs.built != 'true' + uses: useblacksmith/stickydisk@3f6be1451e3cad893b778bc93495c80452a5da26 # v1.7.2 + with: + key: ${{ github.repository }}-android-store-build + path: ~/.cache/t3-android-store + + - name: Configure Gradle and ccache + if: steps.record.outputs.built != 'true' + run: | + mkdir -p "$GRADLE_USER_HOME" "$CCACHE_DIR" + rm -rf "$EAS_LOCAL_BUILD_WORKINGDIR" + # No daemon: nothing should hold the sticky disk open when it is committed. + printf 'org.gradle.caching=true\norg.gradle.daemon=false\n' > "$GRADLE_USER_HOME/gradle.properties" + sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources + sudo apt-get update -qq && sudo apt-get install -y -qq ccache + ccache --zero-stats + + - name: Setup Node + if: steps.record.outputs.built != 'true' + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + with: + node-version-file: package.json + + - name: Enable corepack + if: steps.record.outputs.built != 'true' + run: | + npm install --global corepack@latest + corepack enable + pnpm --version + + # EAS commands load app.config.ts, whose plugins must resolve. Keep install + # scripts: some download native assets that the build's fingerprint check hashes. + - name: Install mobile dependencies + if: steps.record.outputs.built != 'true' + run: pnpm install --frozen-lockfile --prefer-offline --filter=@t3tools/mobile... + + - name: Setup Java + if: steps.record.outputs.built != 'true' + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + with: + distribution: temurin + java-version: 17 + + - name: Setup EAS + if: steps.record.outputs.built != 'true' + uses: expo/expo-github-action@c7b66a9c327a43a8fa7c0158e7f30d6040d2481e # v8.2.1 + with: + eas-version: latest + token: ${{ secrets.EXPO_PRODUCTION_TOKEN }} + packager: npm + + - name: Pull production environment variables + if: steps.record.outputs.built != 'true' + working-directory: apps/mobile + run: | + eas env:pull production --non-interactive + 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: Build + if: steps.record.outputs.built != 'true' + working-directory: apps/mobile + env: + # EAS secret variables don't reach local builds; the eas-build-on-success + # hook needs these to upload the bundle's source map. + MOBILE_SOURCEMAPS_R2: ${{ secrets.MOBILE_SOURCEMAPS_R2 }} + run: | + eas build --local --platform android --profile production --non-interactive --output "$RUNNER_TEMP/t3code.aab" + abis="$(unzip -Z1 "$RUNNER_TEMP/t3code.aab" | sed -n 's|^base/lib/\([^/]*\)/.*|\1|p' | sort -u | paste -sd, -)" + { + echo "### Android $BUILD_TAG" + echo "- ABIs: \`$abis\`" + echo '```' + ccache --show-stats + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + if [ "$abis" != "arm64-v8a,armeabi-v7a" ]; then + echo "::error::Expected only ARM native libraries in the AAB, found $abis" + exit 1 + fi + + # A --path submission doesn't keep its archive on EAS; this is the only downloadable copy. + - name: Keep the AAB + if: steps.record.outputs.built != 'true' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: android-store-aab-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/t3code.aab + retention-days: 14 + if-no-files-found: error + + - name: Submit to Google Play + if: steps.record.outputs.built != 'true' + working-directory: apps/mobile + run: eas submit --platform android --profile production --path "$RUNNER_TEMP/t3code.aab" --non-interactive + + # The tag means "delivered", so it follows the submit. If it can't be + # pushed, the next merge rebuilds with a new build number; nothing is lost. + - name: Record build + if: steps.record.outputs.built != 'true' + env: + GH_TOKEN: ${{ github.token }} + run: | + sha="$(git rev-parse HEAD)" + # GitHub occasionally fails ref writes server-side ("fatal error in commit_refs"). + for attempt in 1 2 3; do + if gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$BUILD_TAG" >/dev/null 2>&1; then + echo "$BUILD_TAG exists." + break + fi + if gh api "repos/$GITHUB_REPOSITORY/git/refs" -f ref="refs/tags/$BUILD_TAG" -f sha="$sha" >/dev/null; then + break + fi + if [ "$attempt" = 3 ]; then + echo "::error::Submitted, but could not create $BUILD_TAG; the next merge will rebuild." + exit 1 + fi + sleep $((attempt * 15)) + done + echo ":rocket: android: submitted and recorded \`$BUILD_TAG\`" >> "$GITHUB_STEP_SUMMARY" + + # Off unless the MOBILE_IOS_LOCAL_BUILDS repository variable is `true`; the + # production job only sets ios_build then. Xcode 27.1 matches the EAS build + # image in eas.json. + # No sticky disk: it mounts ext4 and only works on Linux runners. + ios: + name: iOS store build + needs: production + # Skipped when the production job failed: no store build after a failed OTA. + if: needs.production.outputs.ios_build == 'true' + runs-on: blacksmith-12vcpu-macos-27 + timeout-minutes: 60 + concurrency: + group: mobile-ios-store-build + cancel-in-progress: false + permissions: + contents: write # pushes the build record tag + env: + APP_VARIANT: production + NODE_OPTIONS: --max-old-space-size=8192 + EXPO_TOKEN: ${{ secrets.EXPO_PRODUCTION_TOKEN }} + PROFILE: ${{ needs.production.outputs.ios_profile }} + BUILD_TAG: mobile-ios-build/${{ needs.production.outputs.ios_profile }}/${{ needs.production.outputs.ios_version }}/${{ needs.production.outputs.ios_fingerprint }} + steps: + - name: Checkout + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + ref: ${{ needs.production.outputs.ios_sha }} + fetch-depth: 0 + + # Dependency scripts and the build run in this job; only Record build gets a token. + # persist-credentials: false would clean up via `git submodule foreach`, which fails on .repos gitlinks. + - name: Drop checkout credentials + run: | + git config --local --unset-all http.https://github.com/.extraheader || true + { git config --local --name-only --get-regexp '^includeif\.' || true; } | while read -r key; do + path=$(git config --local --get "$key") + git config --local --unset-all "$key" + case "$path" in "$RUNNER_TEMP"/*) rm -f "$path" ;; esac + done + if git config --local --get-regexp '^(http\..*\.extraheader|includeif\.)'; then + echo "::error::Checkout credentials are still configured" + exit 1 + fi + + # A queued run can follow a build of the same fingerprint; manual runs always build. + - id: record + name: Check build record + env: + FORCE: ${{ github.event_name == 'workflow_dispatch' }} + run: | + if [ "$FORCE" != "true" ] && [ -n "$(git tag -l "$BUILD_TAG")" ]; then + echo "built=true" >> "$GITHUB_OUTPUT" + echo ":white_check_mark: ios: \`$BUILD_TAG\` was built by an earlier run" >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Select Xcode 27.1 + if: steps.record.outputs.built != 'true' + run: | + xcode=/Applications/Xcode_27.1.app + if [ ! -d "$xcode" ]; then + ls -d /Applications/Xcode*.app + echo "::error::Xcode 27.1 is not on this runner." + exit 1 + fi + sudo xcode-select -s "$xcode/Contents/Developer" + xcodebuild -version + echo "- Xcode: \`$(basename "$xcode")\`" >> "$GITHUB_STEP_SUMMARY" + + # EAS's credentials step fails to import the distribution certificate without it. + - name: Install Apple WWDR G3 intermediate + if: steps.record.outputs.built != 'true' + run: | + curl -fsSL -o "$RUNNER_TEMP/AppleWWDRCAG3.cer" https://www.apple.com/certificateauthority/AppleWWDRCAG3.cer + sudo security add-certificates -k /Library/Keychains/System.keychain "$RUNNER_TEMP/AppleWWDRCAG3.cer" || true + security find-certificate -c "Apple Worldwide Developer Relations" /Library/Keychains/System.keychain | grep -m1 labl + + - name: Setup Node + if: steps.record.outputs.built != 'true' + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + with: + node-version-file: package.json + + - name: Enable corepack + if: steps.record.outputs.built != 'true' + run: | + npm install --global corepack@latest + corepack enable + pnpm --version + + # EAS commands load app.config.ts, whose plugins must resolve. Keep install + # scripts: some download native assets that the build's fingerprint check hashes. + - name: Install mobile dependencies + if: steps.record.outputs.built != 'true' + run: pnpm install --frozen-lockfile --prefer-offline --filter=@t3tools/mobile... + + - name: Setup EAS + if: steps.record.outputs.built != 'true' + uses: expo/expo-github-action@c7b66a9c327a43a8fa7c0158e7f30d6040d2481e # v8.2.1 + with: + eas-version: latest + token: ${{ secrets.EXPO_PRODUCTION_TOKEN }} + packager: npm + + - name: Build tools + if: steps.record.outputs.built != 'true' + run: | + command -v pod || sudo gem install cocoapods --no-document + command -v fastlane || brew install fastlane + pod --version + fastlane --version | tail -1 + + - name: Pull production environment variables + if: steps.record.outputs.built != 'true' + working-directory: apps/mobile + run: eas env:pull production --non-interactive + + - name: Build + if: steps.record.outputs.built != 'true' + working-directory: apps/mobile + env: + # EAS secret variables don't reach local builds; the eas-build-on-success + # hook needs these to upload the bundle's source map. + MOBILE_SOURCEMAPS_R2: ${{ secrets.MOBILE_SOURCEMAPS_R2 }} + run: | + # A real path: /var -> /private/var symlinks break the expo-widgets bundle step. + export EAS_LOCAL_BUILD_WORKINGDIR="$RUNNER_TEMP/eas-build" + eas build --local --platform ios --profile "$PROFILE" --non-interactive --output "$RUNNER_TEMP/t3code.ipa" + + - name: Submit to App Store Connect + if: steps.record.outputs.built != 'true' + working-directory: apps/mobile + run: eas submit --platform ios --profile "$PROFILE" --path "$RUNNER_TEMP/t3code.ipa" --non-interactive + + # The tag means "delivered", so it follows the submit. If it can't be + # pushed, the next merge rebuilds with a new build number; nothing is lost. + - name: Record build + if: steps.record.outputs.built != 'true' + env: + GH_TOKEN: ${{ github.token }} + run: | + sha="$(git rev-parse HEAD)" + # GitHub occasionally fails ref writes server-side ("fatal error in commit_refs"). + for attempt in 1 2 3; do + if gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$BUILD_TAG" >/dev/null 2>&1; then + echo "$BUILD_TAG exists." + break + fi + if gh api "repos/$GITHUB_REPOSITORY/git/refs" -f ref="refs/tags/$BUILD_TAG" -f sha="$sha" >/dev/null; then + break + fi + if [ "$attempt" = 3 ]; then + echo "::error::Submitted, but could not create $BUILD_TAG; the next merge will rebuild." + exit 1 + fi + sleep $((attempt * 15)) + done + echo ":rocket: ios: submitted and recorded \`$BUILD_TAG\`" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/mobile-fingerprint-check.yml b/.github/workflows/mobile-fingerprint-check.yml index 9fabb3a5f2ad2..437f16c4684be 100644 --- a/.github/workflows/mobile-fingerprint-check.yml +++ b/.github/workflows/mobile-fingerprint-check.yml @@ -33,7 +33,7 @@ jobs: NODE_OPTIONS: --max-old-space-size=8192 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: # The merge ref can advance after the event is queued. Pin its commit # and compare against its first parent, rather than the event's base. @@ -58,7 +58,7 @@ jobs: - name: Setup Vite+ if: steps.changes.outputs.relevant == 'true' - uses: voidzero-dev/setup-vp@v1 + uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1.15.0 with: node-version-file: package.json cache: true @@ -136,7 +136,7 @@ jobs: printf '%s\n' "$CHANGED_PLATFORMS" > "$RUNNER_TEMP/fingerprint-result/changed-platforms" - name: Upload changed platforms - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: fingerprint-result path: ${{ runner.temp }}/fingerprint-result diff --git a/.github/workflows/mobile-fingerprint-label.yml b/.github/workflows/mobile-fingerprint-label.yml index 45e4c9f2ad42d..a48462eec8c53 100644 --- a/.github/workflows/mobile-fingerprint-label.yml +++ b/.github/workflows/mobile-fingerprint-label.yml @@ -33,7 +33,7 @@ jobs: steps: - id: pr name: Resolve the pull request behind the check - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: script: | const run = context.payload.workflow_run; @@ -100,7 +100,7 @@ jobs: - name: Sync native change label if: steps.pr.outputs.pr_number != '' - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 env: PR_NUMBER: ${{ steps.pr.outputs.pr_number }} CHANGED_PLATFORMS: ${{ steps.result.outputs.changed_platforms }} diff --git a/.github/workflows/mobile-showcase-screenshots.yml b/.github/workflows/mobile-showcase-screenshots.yml index c02be425c1ab7..402ff86445378 100644 --- a/.github/workflows/mobile-showcase-screenshots.yml +++ b/.github/workflows/mobile-showcase-screenshots.yml @@ -45,13 +45,13 @@ jobs: ios: name: iPhone, iPhone Duo, and iPad 13 if: inputs.platform == 'all' || inputs.platform == 'ios' - runs-on: blacksmith-12vcpu-macos-26 + runs-on: blacksmith-12vcpu-macos-27 # Capturing every palette multiplies the device matrix by six, and only the # one native build is shared between them. timeout-minutes: ${{ inputs.theme == 'all' && 300 || 60 }} steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: sparse-checkout: | /* @@ -59,7 +59,7 @@ jobs: sparse-checkout-cone-mode: false - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1.15.0 with: node-version-file: package.json cache: true @@ -88,7 +88,7 @@ jobs: axe --version - name: Install foldable simulator controls - run: npm install --global agent-device@0.21.25 + run: npm install --global agent-device@0.21.36 - name: Capture iOS showcase run: pnpm screenshots:mobile --platform ios --appearance "${{ inputs.appearance }}" --theme "${{ inputs.theme }}" @@ -98,7 +98,7 @@ jobs: - name: Upload iOS screenshots if: always() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: app-store-connect-screenshots path: artifacts/app-store/screenshots/apple/ @@ -116,7 +116,7 @@ jobs: T3_SHOWCASE_ANDROID_ABI: x86_64 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: sparse-checkout: | /* @@ -124,7 +124,7 @@ jobs: sparse-checkout-cone-mode: false - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1.15.0 with: node-version-file: package.json cache: true @@ -142,13 +142,13 @@ jobs: "$vp_pnpm_bin/pnpm" --version - name: Setup Java - uses: actions/setup-java@v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: distribution: temurin java-version: 17 - name: Setup Gradle cache - uses: gradle/actions/setup-gradle@v5 + uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2 - name: Enable KVM run: | @@ -158,7 +158,7 @@ jobs: sudo udevadm trigger --name-match=kvm - name: Capture Android showcase - uses: reactivecircus/android-emulator-runner@v2 + uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0 with: api-level: 36 target: google_apis @@ -175,7 +175,7 @@ jobs: - name: Upload Android screenshots if: always() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: google-play-screenshots path: artifacts/app-store/screenshots/google-play/ diff --git a/.github/workflows/pr-limits.yml b/.github/workflows/pr-limits.yml new file mode 100644 index 0000000000000..d6a2864f94a9b --- /dev/null +++ b/.github/workflows/pr-limits.yml @@ -0,0 +1,72 @@ +name: PR Limits + +# Deterministic PR rules from CONTRIBUTING.md: the open PR limit, the conflict +# window, and the 72 hour close window that follows a triage notice. +# +# Nothing is labeled, commented on, or closed until the repository variable +# PR_LIMITS_ENFORCE is "true". Until then every run logs what it would do. A +# sweep reads the variable once when it starts; to stop one, cancel the run. + +on: + pull_request_target: + types: [opened, reopened] + schedule: + - cron: "23 5 * * *" + workflow_dispatch: + inputs: + dry_run: + description: Log what the sweep would do without labeling, commenting, or closing. + type: boolean + default: true + +permissions: + contents: read + issues: write + pull-requests: write + +jobs: + open-limit: + name: Open PR limit + if: github.event_name == 'pull_request_target' + runs-on: ubuntu-24.04 + steps: + # pull_request_target has a write token. Load the script from the default + # branch and never run PR code. Cone mode also checks out the files beside + # .github/scripts, which is where TRIAGE_EXEMPTIONS.td and VOUCHED.td live. + - name: Checkout trusted scripts + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + ref: ${{ github.event.repository.default_branch }} + sparse-checkout: .github/scripts + + - name: Close PRs over the limit + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + env: + DRY_RUN: ${{ vars.PR_LIMITS_ENFORCE != 'true' }} + with: + script: | + const limits = require("./.github/scripts/pr-limits.cjs"); + await limits.enforceOpenLimit({ github, context, core, dryRun: process.env.DRY_RUN === "true" }); + + sweep: + name: Conflict and close windows + if: github.event_name != 'pull_request_target' + runs-on: ubuntu-24.04 + concurrency: + group: pr-limits-sweep + cancel-in-progress: false + steps: + - name: Checkout trusted scripts + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + ref: ${{ github.event.repository.default_branch }} + sparse-checkout: .github/scripts + + - name: Request rebases and close what went unanswered + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + env: + DRY_RUN: ${{ vars.PR_LIMITS_ENFORCE != 'true' || (github.event_name == 'workflow_dispatch' && inputs.dry_run) }} + with: + script: | + const limits = require("./.github/scripts/pr-limits.cjs"); + await limits.sweep({ github, context, core, dryRun: process.env.DRY_RUN === "true" }); diff --git a/.github/workflows/pr-size.yml b/.github/workflows/pr-size.yml index af557dff62df8..45f5c9b9f7760 100644 --- a/.github/workflows/pr-size.yml +++ b/.github/workflows/pr-size.yml @@ -16,7 +16,7 @@ jobs: steps: - id: config name: Build PR size label config - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: result-encoding: string script: | @@ -64,7 +64,7 @@ jobs: issues: write steps: - name: Ensure PR size labels exist - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 env: PR_SIZE_LABELS_JSON: ${{ needs.prepare-config.outputs.labels_json }} with: @@ -128,11 +128,11 @@ jobs: # git data. Do not add dependency installs, build/test scripts, or cache # actions here; use pull_request plus workflow_run for that pattern instead. - name: Checkout base repository - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: fetch-depth: 0 - name: Sync PR size label - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 env: PR_SIZE_LABELS_JSON: ${{ needs.prepare-config.outputs.labels_json }} with: @@ -293,3 +293,55 @@ jobs: core.info( `PR #${issueNumber}: ${nonTestChangedLines} non-test lines, ${testChangedLines} test lines, ${changedLines} effective lines -> ${nextLabelName} (${classification})`, ); + # Reads the script from the base checkout above; the PR's own commits are only diffed. + - name: Report test suite growth + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + script: | + const { execFileSync } = require("node:child_process"); + const { MARKER, countTestDelta, render } = require( + `${process.env.GITHUB_WORKSPACE}/.github/scripts/test-delta.cjs`, + ); + + const issueNumber = context.payload.pull_request.number; + const diff = execFileSync( + "git", + ["diff", "-U0", `${context.payload.pull_request.base.sha}...refs/remotes/pr-size/${issueNumber}`], + { encoding: "utf8", maxBuffer: 256 * 1024 * 1024 }, + ); + const delta = countTestDelta(diff); + const body = render(delta); + core.info(`PR #${issueNumber}: test cases ${JSON.stringify(delta.kinds)}, net ${delta.net}`); + + const comments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + per_page: 100, + }); + const existing = comments.find( + (comment) => comment.user?.type === "Bot" && comment.body?.startsWith(MARKER), + ); + if (body && existing) { + if (existing.body !== body) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } + } else if (body) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + body, + }); + } else if (existing) { + await github.rest.issues.deleteComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + }); + } diff --git a/.github/workflows/pr-vouch.yml b/.github/workflows/pr-vouch.yml index 75e2ee8a56548..eb53c07894025 100644 --- a/.github/workflows/pr-vouch.yml +++ b/.github/workflows/pr-vouch.yml @@ -29,7 +29,7 @@ jobs: targets: ${{ steps.collect.outputs.targets }} steps: - id: collect - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: script: | if (context.eventName === "pull_request_target") { @@ -81,7 +81,7 @@ jobs: steps: - id: vouch name: Check PR author trust - uses: mitchellh/vouch/action/check-user@v1 + uses: mitchellh/vouch/action/check-user@d66fa29a64600490892131ad87597c30c91fcac4 # v1.5.0 with: user: ${{ matrix.target.user }} allow-fail: true @@ -89,7 +89,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Sync PR labels - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 env: PR_NUMBER: ${{ matrix.target.number }} VOUCH_STATUS: ${{ steps.vouch.outputs.status }} @@ -101,17 +101,17 @@ jobs: { name: "vouch:trusted", color: "1f883d", - description: "PR author is trusted by repo permissions or the VOUCHED list.", + description: "Author is trusted by repo permissions or the VOUCHED list.", }, { name: "vouch:unvouched", color: "fbca04", - description: "PR author is not yet trusted in the VOUCHED list.", + description: "Author is not yet trusted in the VOUCHED list.", }, { name: "vouch:denounced", color: "d1242f", - description: "PR author is explicitly blocked by the VOUCHED list.", + description: "Author is explicitly blocked by the VOUCHED list.", }, ]; diff --git a/.github/workflows/publish-aur.yml b/.github/workflows/publish-aur.yml index 4ab6db89ab703..dc88f0150945b 100644 --- a/.github/workflows/publish-aur.yml +++ b/.github/workflows/publish-aur.yml @@ -48,7 +48,7 @@ jobs: run: pacman -Syu --noconfirm --needed git github-cli jq namcap openssh sudo - name: Checkout packaging sources - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 - name: Create unprivileged build user run: | diff --git a/.github/workflows/relay-preview-cleanup.yml b/.github/workflows/relay-preview-cleanup.yml index eed067adfd773..483e3e0b01b7e 100644 --- a/.github/workflows/relay-preview-cleanup.yml +++ b/.github/workflows/relay-preview-cleanup.yml @@ -58,7 +58,7 @@ jobs: ALCHEMY_TELEMETRY_DISABLED: "1" steps: - name: Checkout default branch - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ github.event.repository.default_branch }} sparse-checkout: | @@ -67,7 +67,7 @@ jobs: sparse-checkout-cone-mode: false - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1.15.0 with: node-version-file: package.json cache: true @@ -170,7 +170,7 @@ jobs: - name: Checkout default branch if: steps.eligible.outputs.eligible == 'yes' - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ github.event.repository.default_branch }} sparse-checkout: | @@ -180,7 +180,7 @@ jobs: - name: Setup Vite+ if: steps.eligible.outputs.eligible == 'yes' - uses: voidzero-dev/setup-vp@v1 + uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1.15.0 with: node-version-file: package.json cache: true diff --git a/.github/workflows/relay-preview.yml b/.github/workflows/relay-preview.yml index fdce22c68a59e..daec319087759 100644 --- a/.github/workflows/relay-preview.yml +++ b/.github/workflows/relay-preview.yml @@ -53,7 +53,7 @@ jobs: fi - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ github.event.pull_request.head.sha }} sparse-checkout: | @@ -62,7 +62,7 @@ jobs: sparse-checkout-cone-mode: false - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1.15.0 with: node-version-file: package.json cache: true diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 3af4e6abeafe9..4252cb46e7a05 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -70,6 +70,12 @@ on: required: false default: false type: boolean + # Whether the JS bundle came from a pull request. This job holds the + # signing secrets, so packaging then skips every step that runs the bundle. + untrusted_bundle: + 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. @@ -77,6 +83,12 @@ on: required: false default: "" type: string + # Whether this job saves the workspace pnpm store. Release's two Macs + # install the same store on the same runner type, so only one saves it. + save_pnpm_store: + required: false + default: true + type: boolean version: required: true type: string @@ -128,6 +140,14 @@ jobs: T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: ${{ inputs.clerk_cli_oauth_client_id }} T3CODE_RELAY_URL: ${{ inputs.relay_url }} steps: + # The launch check and the CLI archive smoke test both run the bundle. + - name: Reject running an untrusted bundle + if: inputs.untrusted_bundle && (inputs.launch_check || inputs.cli_archive) + shell: bash + run: | + echo "::error::untrusted_bundle cannot be combined with launch_check or cli_archive, which run the bundle." + exit 1 + # This repository is public, so Git needs no credentials. checkout's # credential cleanup runs submodule foreach even with submodules disabled, # which fails on the orphaned gitlinks in our vendored .repos tree. @@ -151,7 +171,7 @@ jobs: # artifact leaves the cache empty, so installation runs the checks again. - name: Download dependency verification continue-on-error: true - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: release-dependency-verification path: ${{ runner.temp }}/pnpm-metadata @@ -160,6 +180,7 @@ jobs: uses: ./.github/actions/setup-ci-deps with: install-args: --filter=@t3tools/desktop... --filter=t3... --filter=@t3tools/scripts... + save-pnpm-store: ${{ inputs.save_pnpm_store }} env: pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata @@ -176,7 +197,7 @@ jobs: - name: Restore staged install cache id: stage_pnpm_cache - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ steps.stage_pnpm.outputs.dir }} key: ${{ steps.stage_pnpm.outputs.prefix }}${{ github.run_id }}-${{ github.run_attempt }} @@ -188,7 +209,7 @@ jobs: - name: Cache resource monitor id: resource_monitor_cache - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: native/resource-monitor/target/${{ inputs.rust_target }}/release/t3-resource-monitor${{ inputs.platform == 'win' && '.exe' || '' }} key: resource-monitor-${{ inputs.rust_target }}-${{ hashFiles('native/resource-monitor/Cargo.lock', 'native/resource-monitor/Cargo.toml', 'native/resource-monitor/src/**') }} @@ -196,13 +217,27 @@ jobs: - name: Cache Linux capture helpers if: inputs.platform == 'linux' id: capture_helper_cache - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | native/kde-snap-shot/target/${{ inputs.rust_target }}/release/t3-kde-snap-shot 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/**') }} + # Every Linux build makes electron-builder fetch the AppImage runtime, FPM + # for the deb, and the 7-Zip that unpacks FPM from GitHub releases. It + # checks an archive found here against the SHA-256 pinned in those toolset + # files and downloads again on a mismatch, so only raw archives are cached. + - name: Cache Linux packaging tool archives + if: inputs.platform == 'linux' + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.cache/electron-builder/appimage@*/appimage-tools-runtime-*.tar.gz + ~/.cache/electron-builder/fpm@*/fpm-*.7z + ~/.cache/electron-builder/7zip@*/7zip-linux-*.tar.gz + key: electron-builder-linux-tools-${{ inputs.arch }}-${{ hashFiles('node_modules/.pnpm/app-builder-lib@*/node_modules/app-builder-lib/out/toolsets/linux.js', 'node_modules/.pnpm/app-builder-lib@*/node_modules/app-builder-lib/out/toolsets/7zip.js') }} + - name: Ensure Rosetta for macOS x64 bytecode generation if: inputs.platform == 'mac' && inputs.arch == 'x64' shell: bash @@ -213,14 +248,14 @@ jobs: - 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 + uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master with: toolchain: stable targets: ${{ inputs.rust_target }} - name: Download relay client tracing config if: inputs.relay_client_tracing - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: relay-client-tracing-config path: ${{ runner.temp }}/relay-client-tracing @@ -241,14 +276,14 @@ jobs: # ancestor of its paths), so extracting into `apps` restores # apps/server/dist and apps/desktop/dist-electron at their build paths. - name: Download JS bundle - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: js-bundle path: apps - name: Install cargo-xwin if: inputs.platform == 'win' && steps.resource_monitor_cache.outputs.cache-hit != 'true' - uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2 + uses: taiki-e/install-action@cfdb7ed4ccc7c7dce37759eae89dc5674b469b39 # v2.90.3 with: tool: cargo-xwin@0.23.1 @@ -296,7 +331,7 @@ jobs: - 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 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ runner.temp }}/node-download key: node-download-v${{ steps.target_node.outputs.version }}-win-${{ inputs.arch }} @@ -323,7 +358,7 @@ jobs: # 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 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ runner.temp }}/node-download key: ${{ steps.target_node_cache.outputs.cache-primary-key }} @@ -408,6 +443,7 @@ jobs: run: command -v patchelf || { sudo apt-get update && sudo apt-get install -y patchelf; } - name: Build CLI archive + id: build_cli if: inputs.cli_archive shell: bash env: @@ -425,37 +461,52 @@ jobs: AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} run: | set -euo pipefail - if [[ "${{ inputs.platform }}" == "mac" && -n "${APPLE_API_KEY:-}" ]]; then - key_path="$RUNNER_TEMP/AuthKey_cli_${APPLE_API_KEY_ID}.p8" - printf '%s' "$APPLE_API_KEY" > "$key_path" - export APPLE_API_KEY="$key_path" + extra_args=() + if [[ "${{ inputs.platform }}" == "mac" ]]; then + if [[ -n "${APPLE_API_KEY:-}" ]]; then + key_dir="$(mktemp -d "$RUNNER_TEMP/t3-cli-notary.XXXXXXXX")" + trap 'rm -rf "$key_dir"' EXIT + key_path="$key_dir/AuthKey.p8" + (umask 077 && printf '%s' "$APPLE_API_KEY" > "$key_path") + export APPLE_API_KEY="$key_path" + fi + # Older release refs lack deferred notarization and still wait here. + cli_help="$(node scripts/build-cli-archive.ts --help)" + if [[ "$cli_help" == *--notary-submission-file* ]]; then + extra_args=(--notary-submission-file "$RUNNER_TEMP/t3-cli-notary-submission") + echo "deferred_notarization=true" >> "$GITHUB_OUTPUT" + fi fi node scripts/build-cli-archive.ts \ --platform "${{ inputs.platform }}" \ --arch "${{ inputs.arch }}" \ --version "${{ inputs.version }}" \ --resource-monitor-dir "$RUNNER_TEMP/cli-resource-monitor" \ - --output-dir release-cli + --output-dir release-cli \ + ${extra_args[@]+"${extra_args[@]}"} - name: Smoke-test CLI archive if: inputs.cli_archive && inputs.verify_runner == '' shell: bash run: node scripts/smoke-cli-archive.ts --archive release-cli/* --expect-version "${{ inputs.version }}" + # macOS uploads its archive after "Await CLI notarization" instead. - name: Upload CLI archive - if: inputs.cli_archive - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + if: inputs.cli_archive && inputs.platform != 'mac' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: cli-${{ inputs.platform }}-${{ inputs.arch }} path: release-cli/* if-no-files-found: error + # The archive is already gzip or zip; deflating it again saves under 2%. + compression-level: 0 - name: Install Linux desktop build libraries if: inputs.platform == 'linux' shell: bash run: | sudo apt-get update - sudo apt-get install -y libsecret-1-dev pkg-config + sudo apt-get install -y libsecret-1-dev pkg-config libgtk-3-0t64 libnss3 libasound2t64 libgbm1 if ! command -v magick >/dev/null 2>&1 && ! command -v convert >/dev/null 2>&1; then sudo apt-get install -y imagemagick fi @@ -522,7 +573,7 @@ jobs: - name: Download Linux CLI archive for WSL if: inputs.platform == 'win' - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: cli-linux-${{ inputs.arch }} path: wsl-runtime @@ -536,6 +587,7 @@ jobs: 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' }} + T3CODE_DESKTOP_UNTRUSTED_BUNDLE: ${{ inputs.untrusted_bundle }} CSC_LINK: ${{ secrets.CSC_LINK }} CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} @@ -618,6 +670,39 @@ jobs: vp run dist:desktop:artifact "${args[@]}" + # The CLI was submitted without waiting so Apple reviews it while the + # desktop builds. Notarization does not change the archive bytes, but + # nothing uploads them, so nothing publishes them, until Apple accepts. + # With signing configured, a missing submission id fails this step. + - name: Await CLI notarization + if: inputs.cli_archive && inputs.platform == 'mac' && steps.build_cli.outputs.deferred_notarization == 'true' + shell: bash + env: + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + run: | + set -euo pipefail + if [[ -n "${APPLE_API_KEY:-}" ]]; then + key_dir="$(mktemp -d "$RUNNER_TEMP/t3-cli-notary.XXXXXXXX")" + trap 'rm -rf "$key_dir"' EXIT + key_path="$key_dir/AuthKey.p8" + (umask 077 && printf '%s' "$APPLE_API_KEY" > "$key_path") + export APPLE_API_KEY="$key_path" + fi + node scripts/build-cli-archive.ts await-notarization \ + --submission-file "$RUNNER_TEMP/t3-cli-notary-submission" + + - name: Upload notarized CLI archive + if: inputs.cli_archive && inputs.platform == 'mac' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: cli-${{ inputs.platform }}-${{ inputs.arch }} + path: release-cli/* + if-no-files-found: error + # The archive is already gzip or zip; deflating it again saves under 2%. + compression-level: 0 + # 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 @@ -630,7 +715,7 @@ jobs: - name: Save staged install cache if: steps.stage_pnpm_changed.outputs.changed == 'true' - uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ steps.stage_pnpm.outputs.dir }} key: ${{ steps.stage_pnpm_cache.outputs.cache-primary-key }} @@ -661,7 +746,7 @@ jobs: - name: Upload launch check output if: failure() && inputs.launch_check - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: desktop-launch-check-${{ inputs.platform }}-${{ inputs.arch }} path: ${{ runner.temp }}/desktop-launch-check @@ -689,7 +774,7 @@ jobs: - name: Upload verification inputs if: inputs.verify_runner != '' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: verify-${{ inputs.platform }}-${{ inputs.arch }} path: verify/* @@ -756,14 +841,16 @@ jobs: cp "$source_path" "$target_dir/$binary_name" - name: Upload build artifacts - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: desktop-${{ inputs.platform }}-${{ inputs.arch }} path: release-publish/* if-no-files-found: error + # Installers, zips and blockmaps are already compressed; deflating them again saves under 0.3%. + compression-level: 0 - name: Upload resource monitor - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: resource-monitor-${{ inputs.resource_key }} path: resource-monitor-publish/${{ inputs.resource_key }}/* @@ -777,14 +864,14 @@ jobs: timeout-minutes: 15 steps: - name: Download verification inputs - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: verify-${{ inputs.platform }}-${{ inputs.arch }} path: verify - name: Download CLI archive if: inputs.cli_archive - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: cli-${{ inputs.platform }}-${{ inputs.arch }} path: release-cli diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 82f39f8034cc4..c1bd673dab60f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -48,9 +48,12 @@ jobs: # triggering commit. Manual stable releases build the commit of the latest # published nightly, so stable only ever ships a build that nightly users # have already run. Scheduled runs also decide here whether a nightly is due. + # The PAUSE_NIGHTLY repository variable skips scheduled runs only; every later + # job needs this one, so a skip here skips the whole run. `update_feed` runs + # without it and checks the variable itself. resolve_commit: name: Resolve release commit - if: github.repository == 'pingdotgg/t3code' + if: github.repository == 'pingdotgg/t3code' && (github.event_name != 'schedule' || vars.PAUSE_NIGHTLY != 'true') runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 5 outputs: @@ -59,16 +62,18 @@ jobs: has_changes: ${{ steps.resolve.outputs.has_changes }} steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: sparse-checkout: .github/scripts - id: resolve name: Resolve release commit - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 env: DISPATCH_CHANNEL: ${{ inputs.channel }} with: + # Every call here is a read; retry GitHub's transient 5xx and dropped connections. + retries: 3 script: | const { assertCommitOnDefaultBranch, @@ -120,7 +125,7 @@ jobs: ref: ${{ needs.resolve_commit.outputs.ref }} steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ needs.resolve_commit.outputs.ref }} fetch-depth: 0 @@ -161,14 +166,12 @@ jobs: echo "is_prerelease=true" >> "$GITHUB_OUTPUT" echo "make_latest=false" >> "$GITHUB_OUTPUT" elif [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${DISPATCH_CHANNEL:-preview}" == "preview" ]]; then - # Manual-only test train: exercises the whole release flow for a - # commit end users must never receive. Never scheduled. + # Manual-only test train: exercises the release flow up to a draft + # release for a commit end users must never receive. Never scheduled. # Same versioning as nightly under its own prerelease identifier. - # A preview release is reachable only by asking for it: npm gets it - # under the `preview` dist-tag, which nothing resolves by default, - # its desktop builds carry no update feed, and no updater manifest - # is attached to the release, so neither stable nor nightly - # installs can ever be offered one. + # A preview release stays a draft (see "Publish prerelease"), its + # desktop builds carry no update feed, no updater manifest is + # attached, and npm gets nothing, so no install can be offered one. nightly_date="$(date -u -d "$NIGHTLY_DATE" +%Y%m%d)" node scripts/resolve-nightly-release.ts \ @@ -179,7 +182,6 @@ jobs: --github-output echo "release_channel=preview" >> "$GITHUB_OUTPUT" - echo "cli_dist_tag=preview" >> "$GITHUB_OUTPUT" echo "is_prerelease=true" >> "$GITHUB_OUTPUT" echo "make_latest=false" >> "$GITHUB_OUTPUT" else @@ -224,7 +226,7 @@ jobs: # Share only the verification results, not the large registry metadata cache. - name: Upload dependency verification continue-on-error: true - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-dependency-verification path: ${{ runner.temp }}/pnpm-metadata/lockfile-verified.jsonl @@ -237,7 +239,7 @@ jobs: timeout-minutes: 10 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ needs.preflight.outputs.ref }} sparse-checkout: | @@ -269,7 +271,7 @@ jobs: timeout-minutes: 10 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ needs.preflight.outputs.ref }} sparse-checkout: | @@ -298,14 +300,18 @@ jobs: # Web browser tests time out when they share a runner with the other package # suites: Chromium gets too little CPU and diff workers miss their 10s start budget. test_web: - name: Release web tests + name: Release web tests ${{ matrix.shard }} needs: [preflight] if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }} runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + shard: [1, 2] steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ needs.preflight.outputs.ref }} sparse-checkout: | @@ -324,7 +330,7 @@ jobs: - name: Cache Chromium id: chromium_cache - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/ms-playwright key: playwright-chromium-shell-${{ runner.os }}-${{ runner.arch }}-${{ steps.playwright_version.outputs.version }} @@ -342,10 +348,11 @@ jobs: fi - name: Test - run: vp run --filter @t3tools/web test + run: vp run --filter @t3tools/web test --shard ${{ matrix.shard }}/${{ strategy.job-total }} # Browser tests run source modules; this boots the production chunk graph. - name: Boot the built web client + if: matrix.shard == 1 run: | vp run --filter @t3tools/web build node apps/web/scripts/verify-built-boot.ts @@ -363,7 +370,7 @@ jobs: shard: [1, 2, 3, 4, 5, 6, 7, 8] steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ needs.preflight.outputs.ref }} sparse-checkout: | @@ -416,7 +423,7 @@ jobs: CLERK_CLI_OAUTH_CLIENT_ID: ${{ vars.CLERK_CLI_OAUTH_CLIENT_ID }} steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ needs.resolve_commit.outputs.ref }} sparse-checkout: | @@ -456,7 +463,7 @@ jobs: } > "$RUNNER_TEMP/relay-client-tracing.env" - name: Upload relay client tracing config - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: relay-client-tracing-config path: ${{ runner.temp }}/relay-client-tracing.env @@ -517,6 +524,8 @@ jobs: if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' }} runs-on: blacksmith-32vcpu-ubuntu-2404 timeout-minutes: 30 + outputs: + npm_launcher_artifact_id: ${{ steps.npm_launcher.outputs.artifact-id }} env: T3CODE_CLERK_PUBLISHABLE_KEY: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} T3CODE_CLERK_JWT_TEMPLATE: ${{ needs.relay_public_config.outputs.clerk_jwt_template }} @@ -524,7 +533,7 @@ jobs: T3CODE_RELAY_URL: ${{ needs.relay_public_config.outputs.relay_url }} steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ needs.preflight.outputs.ref }} sparse-checkout: | @@ -536,7 +545,7 @@ jobs: # artifact leaves the cache empty, so installation runs the checks again. - name: Download dependency verification continue-on-error: true - uses: actions/download-artifact@v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: release-dependency-verification path: ${{ runner.temp }}/pnpm-metadata @@ -549,7 +558,7 @@ jobs: pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata - name: Download relay client tracing config - uses: actions/download-artifact@v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: relay-client-tracing-config path: ${{ runner.temp }}/relay-client-tracing @@ -565,27 +574,37 @@ jobs: - name: Align package versions to release version run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" - - uses: ./.github/actions/setup-apt-mirrors + # Older release refs lack build:desktop:bundle and use the full build, + # which compiles the Linux browser secret helper and needs libsecret. + - name: Check bundle-only build support + id: bundle_build + run: | + script="$(node -p "require('./package.json').scripts['build:desktop:bundle'] ? 'build:desktop:bundle' : 'build:desktop'")" + echo "script=$script" >> "$GITHUB_OUTPUT" + + - if: steps.bundle_build.outputs.script == 'build:desktop' + uses: ./.github/actions/setup-apt-mirrors - # @t3tools/desktop#build compiles the Linux browser secret helper on a - # Linux host before packing, and that needs libsecret headers. - name: Install browser secret helper build libraries + if: steps.bundle_build.outputs.script == 'build:desktop' run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config - # Runs t3#build (which depends on @t3tools/web#build) and - # @t3tools/desktop#build, so apps/server/dist holds the server bundle - # plus the web client and apps/desktop/dist-electron the Electron main. + # Runs t3#build (which depends on @t3tools/web#build) and the desktop + # bundle, so apps/server/dist holds the server bundle plus the web client + # and apps/desktop/dist-electron the Electron main. build:desktop:bundle + # skips the Linux browser secret helper, which this artifact does not + # carry; each Linux package job compiles its own per arch. # The release channel is already stable, nightly, or preview. Builds in # other repositories report "dev" telemetry. - name: Build JS bundle env: T3CODE_TELEMETRY_CHANNEL: ${{ github.repository == 'pingdotgg/t3code' && needs.preflight.outputs.release_channel || 'dev' }} - run: vp run build:desktop + run: vp run ${{ steps.bundle_build.outputs.script }} # Two paths under apps/ so the artifact root is apps/; consumers download # into `apps` to restore both at their original locations. - name: Upload JS bundle - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: js-bundle path: | @@ -594,6 +613,22 @@ jobs: if-no-files-found: error retention-days: 1 + # publish_cli publishes this exact tarball, so the trusted publisher + # needs no workspace checkout or install. It is version-matched here. + - name: Build the npm launcher + run: node scripts/build-npm-launcher.ts --version "${{ needs.preflight.outputs.version }}" --output-dir npm-packages + + # GitHub allows a failed-jobs rerun for 30 days. publish_cli downloads + # this upload by its ID, which a publish_cli-only rerun keeps. + - name: Upload the npm launcher + id: npm_launcher + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: npm-launcher + path: npm-packages/t3.tgz + if-no-files-found: error + retention-days: 30 + # One job per platform and architecture (see release-desktop.yml), each # gated only on the JS bundle. Windows targets build on Linux and run their # native checks on a Windows runner of their own architecture. @@ -625,6 +660,8 @@ jobs: resource_key: darwin-arm64 cli_archive: true launch_check: true + # desktop_mac_x64 saves the shared pnpm store; this job finishes last. + save_pnpm_store: false desktop_mac_x64: name: Desktop macOS x64 @@ -780,7 +817,7 @@ jobs: pull-requests: read steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ needs.preflight.outputs.ref }} # Release notes need history back to the previous tag, not file contents. @@ -796,32 +833,38 @@ jobs: with: install-args: --filter=@t3tools/scripts... + # Overlaps the artifact downloads. The wait below ends it before any + # workspace script runs, so the Play key lives only in this process. + # `exec` lets the runner's stop signals reach node directly. + - id: android_apk + name: Download nightly Android APK + if: needs.preflight.outputs.release_channel == 'nightly' + background: true + continue-on-error: true + timeout-minutes: 5 + env: + GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }} + PACKAGE_NAME: com.t3tools.t3code + APK_OUTPUT_DIR: ${{ runner.temp }}/android-apk + run: | + if [ -n "$GOOGLE_PLAY_SERVICE_ACCOUNT_JSON" ]; then + exec node .github/scripts/mirror-play-apk.cjs + fi + - name: Download all desktop artifacts - uses: actions/download-artifact@v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: pattern: desktop-* merge-multiple: true path: release-assets - name: Download all CLI archives - uses: actions/download-artifact@v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: pattern: cli-* merge-multiple: true path: release-assets - - name: Download internal Android APK for Nightly - if: needs.preflight.outputs.release_channel == 'nightly' - continue-on-error: true - env: - GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }} - PACKAGE_NAME: com.t3tools.t3code - APK_OUTPUT_DIR: release-assets - run: | - if [ -n "$GOOGLE_PLAY_SERVICE_ACCOUNT_JSON" ]; then - node .github/scripts/mirror-play-apk.cjs - fi - # Installers verify archives against this file, so it is written from the # signed bytes that get uploaded, never from an earlier stage. - name: Write CLI archive checksums @@ -838,6 +881,10 @@ jobs: sha256sum "${archives[@]}" > SHA256SUMS cat SHA256SUMS + # Always runs, even after a failed step, and keeps that failure. + - name: Wait for nightly Android APK + wait: android_apk + # The desktop build omits the publish config for preview versions, so # electron-builder emits no updater manifests for them. Refuse to publish # if one shows up anyway: a `latest*.yml` or `nightly*.yml` on a preview @@ -856,34 +903,12 @@ jobs: fi # electron-updater reads one manifest per platform and channel and picks - # the file entry whose name carries the running arch, so the per-arch - # manifests the build jobs wrote are merged back into that one file. - - name: Merge macOS updater manifests - if: needs.preflight.outputs.release_channel != 'preview' - run: | - shopt -s nullglob - for x64_manifest in release-assets/*-mac-x64.yml; do - arm64_manifest="${x64_manifest%-x64.yml}.yml" - if [[ -f "$arm64_manifest" ]]; then - node scripts/merge-update-manifests.ts --platform mac "$arm64_manifest" "$x64_manifest" - rm -f "$x64_manifest" - fi - done - - - name: Merge Windows updater manifests + # the file entry whose name carries the running arch. Require every + # platform's two build jobs to have produced matching metadata before + # merging it, so a successful release can never strand one architecture. + - name: Merge desktop updater manifests if: needs.preflight.outputs.release_channel != 'preview' - run: | - shopt -s nullglob - for x64_manifest in release-assets/*-win-x64.yml; do - arm64_manifest="${x64_manifest%-x64.yml}-arm64.yml" - merged_manifest="${x64_manifest%-win-x64.yml}.yml" - if [[ -f "$arm64_manifest" ]]; then - node scripts/merge-update-manifests.ts --platform win "$x64_manifest" "$arm64_manifest" "$merged_manifest" - rm -f "$x64_manifest" "$arm64_manifest" - else - mv "$x64_manifest" "$merged_manifest" - fi - done + run: node scripts/merge-release-update-manifests.ts release-assets # Updater manifests and blockmaps are what electron-updater consumes. # They are only listed for channels an updater is meant to follow. @@ -903,10 +928,13 @@ jobs: echo 'release-assets/*.yml' fi echo 'release-assets/*.tar.gz' - # Only list APKs when Play has a processed internal build available. - for apk in release-assets/*.apk; do - [[ -f "$apk" ]] && echo "$apk" - done + # A failed or timed-out fetch can leave partial bytes, so list the APK + # only when this attempt's fetch succeeded and Play had one ready. + if [[ "${{ steps.android_apk.outcome }}" == "success" ]]; then + for apk in "$RUNNER_TEMP"/android-apk/*.apk; do + [[ -f "$apk" ]] && echo "$apk" + done + fi echo 'release-assets/SHA256SUMS' echo 'EOF' } >> "$GITHUB_OUTPUT" @@ -952,7 +980,7 @@ jobs: - name: Publish release if: needs.preflight.outputs.previous_tag != '' - uses: softprops/action-gh-release@v3 + uses: softprops/action-gh-release@8a0cd651e5b2365bd640854a3fa5dfc70e4875f3 # v3.0.4 with: tag_name: ${{ needs.preflight.outputs.tag }} target_commitish: ${{ needs.preflight.outputs.ref }} @@ -969,7 +997,7 @@ jobs: - name: Publish first release if: needs.preflight.outputs.previous_tag == '' - uses: softprops/action-gh-release@v3 + uses: softprops/action-gh-release@8a0cd651e5b2365bd640854a3fa5dfc70e4875f3 # v3.0.4 with: tag_name: ${{ needs.preflight.outputs.tag }} target_commitish: ${{ needs.preflight.outputs.ref }} @@ -986,48 +1014,150 @@ jobs: # 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. + # Previews stay drafts, because a draft has no git tag. releases.atom lists the 10 newest tags, + # a day's `preview` tags can sort above its `nightly` ones, and older updaters find nightlies only there. - name: Publish prerelease - if: needs.preflight.outputs.is_prerelease == 'true' + if: needs.preflight.outputs.is_prerelease == 'true' && needs.preflight.outputs.release_channel != 'preview' env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} RELEASE_TAG: ${{ needs.preflight.outputs.tag }} run: gh release edit "$RELEASE_TAG" --draft=false + # A nightly install finds updates in releases.atom, which holds the 10 newest git tags of any + # kind, so a burst of other tags hides every nightly. The `update-feed` release is the fixed URL + # newer installs also read. Every unpaused scheduled tick and every nightly repairs it, then fails when + # the feed's first nightly is not the newest one. See docs/operations/release.md#update-feed. + update_feed: + name: Check the update feed + needs: [preflight, release] + if: ${{ !cancelled() && github.repository == 'pingdotgg/t3code' && ((github.event_name == 'schedule' && vars.PAUSE_NIGHTLY != 'true') || needs.preflight.outputs.release_channel == 'nightly') }} + runs-on: blacksmith-2vcpu-ubuntu-2404 + timeout-minutes: 5 + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + sparse-checkout: .github/scripts + + - id: feed + name: Read the update feed + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + retries: 3 + script: | + const { readNightlyUpdateFeed } = require('./.github/scripts/check-nightly-release.cjs'); + const { tag, pointerTag, feedNightly, feedTags } = await readNightlyUpdateFeed({ github, context }); + if (!tag) return; + core.info(`Newest nightly: ${tag}. Pointer: ${pointerTag}. Feed: ${feedTags?.join(', ')}.`); + core.setOutput('tag', tag); + core.setOutput('pointer_stale', pointerTag !== tag); + core.setOutput('feed', feedTags === undefined ? 'unreadable' : feedNightly === tag ? 'ok' : 'hidden'); + + - name: Point the update feed at the newest nightly + if: steps.feed.outputs.pointer_stale == 'true' + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + RELEASE_TAG: ${{ steps.feed.outputs.tag }} + run: | + gh api "repos/$GH_REPO/releases/tags/$RELEASE_TAG" --jq '{tag_name, name, body}' > nightly.json + gh release view update-feed > /dev/null 2>&1 || + gh release create update-feed --prerelease --latest=false --title "Update feed" \ + --notes "The desktop updater reads this release to find the newest nightly. Do not delete it. See docs/operations/release.md." + gh release upload update-feed nightly.json --clobber + + # Every run, because the pointer can be current while the release's flags are wrong. A draft's + # files are not public, and a release marked "latest" is the one stable installs update to. + - name: Keep the update feed release public and never latest + if: steps.feed.outputs.tag != '' + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: gh release edit update-feed --draft=false --prerelease --latest=false + + # An unreadable feed says nothing about its tags and the next tick reads it again, so only a + # feed that was read and names another nightly first fails the run. + - name: Fail when the feed hides the newest nightly + if: steps.feed.outputs.tag != '' && steps.feed.outputs.feed != 'ok' + env: + RELEASE_TAG: ${{ steps.feed.outputs.tag }} + FEED: ${{ steps.feed.outputs.feed }} + run: | + if [[ "$FEED" == "unreadable" ]]; then + echo "::warning::releases.atom could not be read, so this run did not check it." + exit 0 + fi + echo "::error::The first nightly in releases.atom is not $RELEASE_TAG. Nightly installs older than 0.1.10-nightly.20261112.4603 cannot reach it, and neither can later builds that do not read the pointer when the feed names an older nightly. A preview from a branch not rebased onto main is the usual cause. See docs/operations/release.md#update-feed." + exit 1 + # 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 - # nothing resolves unless asked for by name. + # exists. A preview release stays a draft, whose files are not public, so + # a preview never publishes the launcher. publish_cli: name: Publish CLI to npm - needs: [preflight, release] - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' }} + needs: [preflight, build_bundle, release] + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' && needs.preflight.outputs.release_channel != 'preview' }} runs-on: ubuntu-24.04 # blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 15 permissions: contents: read id-token: write steps: + # Only what setup-ci-deps reads to install the project's Node and npm. - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ needs.preflight.outputs.ref }} sparse-checkout: | - /* - !/.repos/ + /.github/actions/setup-ci-deps/ + /package.json + /pnpm-workspace.yaml sparse-checkout-cone-mode: false - - name: Setup dependencies + - name: Setup Node.js uses: ./.github/actions/setup-ci-deps with: - install-args: --filter=t3... --filter=@t3tools/scripts... + install: "false" - - name: Build the npm launcher - run: node scripts/build-npm-launcher.ts --version "${{ needs.preflight.outputs.version }}" --output-dir npm-packages + # Bind to build_bundle's exact upload: a name lookup takes the newest + # same-name artifact, and an empty ID downloads every artifact in the + # run. A later same-name upload hides this ID, so the download fails. + - name: Check the npm launcher artifact ID + env: + ARTIFACT_ID: ${{ needs.build_bundle.outputs.npm_launcher_artifact_id }} + run: | + if [[ ! "$ARTIFACT_ID" =~ ^[1-9][0-9]*$ ]]; then + echo "::error::build_bundle gave no valid npm-launcher artifact ID: '$ARTIFACT_ID'." >&2 + exit 1 + fi + + - name: Download the npm launcher + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + artifact-ids: ${{ needs.build_bundle.outputs.npm_launcher_artifact_id }} + path: npm-packages + digest-mismatch: error + + - name: Check the npm launcher version + env: + RELEASE_VERSION: ${{ needs.preflight.outputs.version }} + run: | + built="$(tar -xzOf npm-packages/t3.tgz package/package.json | jq -r .version)" + if [[ "$built" != "$RELEASE_VERSION" ]]; then + echo "::error::npm-launcher holds t3@$built, expected t3@$RELEASE_VERSION." >&2 + exit 1 + fi - name: Publish the npm launcher + env: + DIST_TAG: ${{ needs.preflight.outputs.cli_dist_tag }} run: | - if ! node apps/server/scripts/cli.ts publish --packages-dir npm-packages --tag "${{ needs.preflight.outputs.cli_dist_tag }}" --provenance --verbose; then + # The ./ keeps npm from reading the path as a GitHub owner/repo spec. + if ! npm publish --access public --tag "$DIST_TAG" --provenance ./npm-packages/t3.tgz; then echo "::error::npm publish failed. Make sure package t3 has a trusted publisher registered for .github/workflows/release.yml (see docs/operations/release.md)." >&2 exit 1 fi @@ -1068,7 +1198,7 @@ jobs: VERCEL_TEAM_SLUG: ${{ vars.VERCEL_TEAM_SLUG }} steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ needs.preflight.outputs.ref }} sparse-checkout: | @@ -1082,7 +1212,7 @@ jobs: install-args: --filter=@t3tools/scripts... --filter=@t3tools/web... - name: Download relay client tracing config - uses: actions/download-artifact@v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: relay-client-tracing-config path: ${{ runner.temp }}/relay-client-tracing @@ -1165,13 +1295,16 @@ jobs: T3CODE_WEB_NIGHTLY_DOMAIN: ${{ vars.T3CODE_WEB_NIGHTLY_DOMAIN }} VERCEL_TEAM_SLUG: ${{ vars.VERCEL_TEAM_SLUG }} steps: + # Only what setup-ci-deps reads to install vp and Node. The alias call + # names its deployment and scope, so it reads nothing else from the repo. - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ needs.preflight.outputs.ref }} sparse-checkout: | - /* - !/.repos/ + /.github/actions/setup-ci-deps/ + /package.json + /pnpm-workspace.yaml sparse-checkout-cone-mode: false - name: Setup Vite+ @@ -1236,7 +1369,7 @@ jobs: VERCEL_TEAM_SLUG: ${{ vars.VERCEL_TEAM_SLUG }} steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ needs.preflight.outputs.ref }} sparse-checkout: | @@ -1302,7 +1435,7 @@ jobs: VERCEL_TEAM_SLUG: ${{ vars.VERCEL_TEAM_SLUG }} steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ needs.preflight.outputs.ref }} sparse-checkout: | @@ -1340,14 +1473,14 @@ jobs: steps: - id: app_token name: Mint release app token - uses: actions/create-github-app-token@v2 + uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2.2.2 with: app-id: ${{ secrets.RELEASE_APP_ID }} private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} owner: ${{ github.repository_owner }} - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: main fetch-depth: 0 @@ -1403,7 +1536,18 @@ jobs: git add apps/server/package.json apps/desktop/package.json apps/web/package.json packages/contracts/package.json pnpm-lock.yaml git commit --no-verify -m "chore(release): prepare $RELEASE_TAG" - git push origin HEAD:main + + # main moves while this job runs, so a rejected push rebases and retries. + for attempt in 1 2 3 4 5; do + if git push origin HEAD:main; then + exit 0 + fi + echo "Push rejected (attempt $attempt). Rebasing onto latest main." + git pull --rebase origin main + done + + echo "Could not push the version bump after 5 attempts." >&2 + exit 1 announce_discord: name: Announce release on Discord @@ -1420,7 +1564,7 @@ jobs: timeout-minutes: 10 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ needs.preflight.outputs.ref }} sparse-checkout: | diff --git a/.github/workflows/stale-needs-more-info.yml b/.github/workflows/stale-needs-more-info.yml index b7b475a6117dc..9d994115705f4 100644 --- a/.github/workflows/stale-needs-more-info.yml +++ b/.github/workflows/stale-needs-more-info.yml @@ -13,7 +13,7 @@ jobs: stale: runs-on: ubuntu-latest steps: - - uses: actions/stale@v9 + - uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0 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. diff --git a/.github/workflows/thread-transfer-report.yml b/.github/workflows/thread-transfer-report.yml index 23eec72923bd8..94987b447745b 100644 --- a/.github/workflows/thread-transfer-report.yml +++ b/.github/workflows/thread-transfer-report.yml @@ -22,7 +22,7 @@ jobs: # workflow_run has a write-capable token even for fork PRs. Only load the # publisher from the trusted default branch and never execute PR code. - name: Checkout trusted publisher - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ github.event.repository.default_branch }} sparse-checkout: .github/scripts @@ -32,7 +32,7 @@ jobs: - id: resolve name: Resolve PR and baseline artifacts - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: script: | const reporter = require("./.github/scripts/thread-transfer-report.cjs"); @@ -40,7 +40,7 @@ jobs: - name: Download PR result if: steps.resolve.outputs.publish == 'true' && steps.resolve.outputs.pr_artifact == 'true' - uses: actions/download-artifact@v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: thread-transfer-results path: ${{ runner.temp }}/thread-transfer/pr @@ -49,7 +49,7 @@ jobs: - name: Download main baseline if: steps.resolve.outputs.publish == 'true' && steps.resolve.outputs.baseline_artifact == 'true' - uses: actions/download-artifact@v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: thread-transfer-results path: ${{ runner.temp }}/thread-transfer/main @@ -58,7 +58,7 @@ jobs: - name: Update thread transfer comment if: steps.resolve.outputs.publish == 'true' - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 env: PR_NUMBER: ${{ steps.resolve.outputs.pull_number }} PR_SHA: ${{ steps.resolve.outputs.pr_sha }} diff --git a/.github/workflows/web-preview.yml b/.github/workflows/web-preview.yml index 4b3f8952d503d..69db6b6025e8f 100644 --- a/.github/workflows/web-preview.yml +++ b/.github/workflows/web-preview.yml @@ -46,7 +46,7 @@ jobs: VERCEL_TEAM_SLUG: ${{ vars.VERCEL_TEAM_SLUG }} steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ github.event.pull_request.head.sha }} sparse-checkout: | @@ -55,7 +55,7 @@ jobs: sparse-checkout-cone-mode: false - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1.15.0 with: node-version-file: package.json cache: true @@ -97,7 +97,7 @@ jobs: echo "deployment_url=$deployment_url" >> "$GITHUB_OUTPUT" - name: Comment deployment URL - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 env: DEPLOYMENT_URL: ${{ steps.deploy.outputs.deployment_url }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} diff --git a/.github/workflows/windows-tests.yml b/.github/workflows/windows-tests.yml index 3a70ad5a26a05..9a8e69d69cc29 100644 --- a/.github/workflows/windows-tests.yml +++ b/.github/workflows/windows-tests.yml @@ -30,7 +30,7 @@ jobs: timeout-minutes: 45 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: sparse-checkout: | /* @@ -40,7 +40,7 @@ jobs: # setup-vp's own cache restores a Linux-shaped store on Windows, which is # slower than no cache (see #7975). Cache pnpm's Windows store directly. - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1.15.0 with: node-version-file: package.json cache: false @@ -52,7 +52,7 @@ jobs: run: '"path=$(vp pm cache dir)" >> $env:GITHUB_OUTPUT' - name: Cache packages - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ steps.package_cache_path.outputs.path }} key: windows-tests-packages-v1-${{ hashFiles('pnpm-lock.yaml') }} diff --git a/.gitignore b/.gitignore index b6b793e020ef1..1637f475fbbd0 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,7 @@ apps/mobile/.android-cache/ artifacts/app-store/screenshots/ artifacts/mobile-e2e/ artifacts/mobile-behavior/ +scripts/.e2e/ .github/pr-assets/ native/**/target/ node_modules/ @@ -48,3 +49,5 @@ node_modules/ # pnpm content-addressable store; never belongs in the repo. .pnpm-store/ + +/packages/ssh/.ssh-launcher-test-*/ diff --git a/.macroscope/check-run-agents/ui-consistency.md b/.macroscope/check-run-agents/ui-consistency.md index 905823d1d1a9e..4326d4e8391b7 100644 --- a/.macroscope/check-run-agents/ui-consistency.md +++ b/.macroscope/check-run-agents/ui-consistency.md @@ -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 `