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 306038007e9a0..a11eee4e3f4eb 100644 --- a/.agents/skills/test-t3-mobile/SKILL.md +++ b/.agents/skills/test-t3-mobile/SKILL.md @@ -43,10 +43,15 @@ 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/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/setup-ci-deps/action.yml b/.github/actions/setup-ci-deps/action.yml index e27a8c90fdff8..cc6dc80511aca 100644 --- a/.github/actions/setup-ci-deps/action.yml +++ b/.github/actions/setup-ci-deps/action.yml @@ -35,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 }} @@ -56,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: @@ -92,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') }} @@ -106,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') }} @@ -121,7 +121,7 @@ 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 }} @@ -138,7 +138,7 @@ runs: 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 95581e2b22781..a9502b26c8c81 100644 --- a/.github/scripts/check-nightly-release.cjs +++ b/.github/scripts/check-nightly-release.cjs @@ -121,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 3030080d946ce..2f3d385755ca6 100644 --- a/.github/scripts/check-nightly-release.test.cjs +++ b/.github/scripts/check-nightly-release.test.cjs @@ -237,3 +237,81 @@ 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/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 c06466b32cf98..f5294d740c4cb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,7 +47,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: | @@ -76,7 +76,7 @@ jobs: timeout-minutes: ${{ github.repository == 'jayleaton/ada' && 20 || 10 }} steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: sparse-checkout: | /* @@ -100,7 +100,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') }} @@ -154,7 +154,7 @@ jobs: timeout-minutes: ${{ github.repository == 'jayleaton/ada' && 20 || 10 }} steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: sparse-checkout: | /* @@ -191,7 +191,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') }} @@ -243,7 +243,7 @@ jobs: - name: Upload desktop launch check if: ${{ failure() && steps.desktop_launch.outcome == 'failure' }} - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: desktop-launch-check path: ${{ runner.temp }}/desktop-launch-check @@ -261,7 +261,7 @@ jobs: - name: Cache Chromium id: chromium_cache if: ${{ !cancelled() && steps.build.outcome == 'success' && needs.changes.outputs.test_web != '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 }} @@ -309,7 +309,7 @@ jobs: timeout-minutes: ${{ github.repository == 'jayleaton/ada' && 20 || 10 }} steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: sparse-checkout: | /* @@ -355,7 +355,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') }} @@ -367,7 +367,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 .github/scripts/fork-release-health.test.cjs .github/scripts/mobile-preview-comment.test.cjs + run: node --test .github/scripts/check-nightly-release.test.cjs .github/scripts/vercel-deployment-output.test.cjs .github/scripts/fork-release-health.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 @@ -396,19 +396,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: ${{ github.repository == 'jayleaton/ada' && 'ubuntu-24.04' || 'blacksmith-8vcpu-ubuntu-2404' }} timeout-minutes: ${{ github.repository == 'jayleaton/ada' && 20 || 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: | /* @@ -429,7 +435,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 }} @@ -447,7 +453,7 @@ jobs: fi - name: Test - run: vp run --filter @t3tools/web test + 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 @@ -471,7 +477,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: | @@ -502,7 +508,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 }} @@ -520,23 +526,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 @@ -563,10 +581,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 @@ -580,7 +600,7 @@ jobs: timeout-minutes: 10 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: sparse-checkout: | /* @@ -589,8 +609,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 @@ -609,11 +630,11 @@ jobs: name: Mobile Native Static Analysis needs: changes if: ${{ !cancelled() && needs.changes.outputs.mobile_native != 'false' }} - runs-on: ${{ github.repository == 'jayleaton/ada' && 'macos-26' || 'blacksmith-6vcpu-macos-26' }} + runs-on: ${{ github.repository == 'jayleaton/ada' && 'macos-26' || 'blacksmith-6vcpu-macos-27' }} timeout-minutes: ${{ github.repository == 'jayleaton/ada' && 20 || 10 }} steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: sparse-checkout: | /* @@ -621,7 +642,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 @@ -646,7 +667,7 @@ jobs: timeout-minutes: ${{ github.repository == 'jayleaton/ada' && 20 || 15 }} steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: sparse-checkout: | /* @@ -704,7 +725,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/desktop-macos-preview-publish.yml b/.github/workflows/desktop-macos-preview-publish.yml index 84fd62c19ba2c..9c692f1a2d82c 100644 --- a/.github/workflows/desktop-macos-preview-publish.yml +++ b/.github/workflows/desktop-macos-preview-publish.yml @@ -51,7 +51,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; @@ -130,7 +130,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 @@ -145,7 +145,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: @@ -253,7 +253,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 @@ -293,6 +293,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 @@ -315,7 +316,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 @@ -408,7 +409,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 }} @@ -529,7 +530,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 e43765dfe9826..df1e029f0e8d0 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 @@ -96,7 +96,7 @@ jobs: # 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 index 5f8cb86befb59..b3e11c1ed6b6b 100644 --- a/.github/workflows/mobile-dev-client.yml +++ b/.github/workflows/mobile-dev-client.yml @@ -83,7 +83,7 @@ jobs: T3_NATIVE_CLIENT_VERBOSE: "1" steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ inputs.ref || github.sha }} @@ -144,7 +144,7 @@ jobs: # the runs holding the cache login restore. - name: Mount build cache if: github.event_name != 'pull_request' - uses: useblacksmith/stickydisk@v1 + uses: useblacksmith/stickydisk@3f6be1451e3cad893b778bc93495c80452a5da26 # v1.7.2 with: key: ${{ github.repository }}-android-dev-client-${{ matrix.abi }} path: ~/.cache/t3-android-dev-client @@ -165,7 +165,7 @@ jobs: install-args: --filter=@t3tools/mobile... --filter=@t3tools/scripts... - name: Setup Java - uses: actions/setup-java@v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: distribution: temurin java-version: 17 @@ -219,7 +219,7 @@ jobs: ios: name: iOS simulator if: github.repository == 'pingdotgg/t3code' && (github.event_name != 'workflow_dispatch' || inputs.platform == 'ios') - runs-on: blacksmith-12vcpu-macos-26 + 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 }} @@ -232,7 +232,7 @@ jobs: T3_NATIVE_CLIENT_VERBOSE: "1" steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ inputs.ref || github.sha }} diff --git a/.github/workflows/mobile-eas-preview-fork.yml b/.github/workflows/mobile-eas-preview-fork.yml index 8c69b07d56faa..a87f67cd494b7 100644 --- a/.github/workflows/mobile-eas-preview-fork.yml +++ b/.github/workflows/mobile-eas-preview-fork.yml @@ -40,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 { @@ -75,7 +75,7 @@ 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 @@ -84,7 +84,7 @@ jobs: # The comment script comes from main, not from the PR. - name: Checkout trusted scripts if: steps.expo-token.outputs.present == 'true' - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: path: .trusted sparse-checkout: .github/scripts @@ -120,7 +120,7 @@ jobs: # 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 @@ -138,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 @@ -166,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" @@ -249,14 +249,14 @@ jobs: ORG_GRADLE_PROJECT_reactNativeArchitectures: armeabi-v7a,arm64-v8a steps: - name: Checkout reviewed commit - uses: actions/checkout@v6 + 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@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: path: .trusted sparse-checkout: .github/scripts @@ -288,7 +288,7 @@ jobs: fi - name: Setup Node - uses: actions/setup-node@v6 + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 with: node-version-file: package.json @@ -302,13 +302,13 @@ jobs: run: pnpm install --frozen-lockfile --filter=@t3tools/mobile... - name: Setup Java - uses: actions/setup-java@v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: distribution: temurin java-version: 17 - name: Setup EAS - uses: expo/expo-github-action@v8 + uses: expo/expo-github-action@c7b66a9c327a43a8fa7c0158e7f30d6040d2481e # v8.2.1 with: eas-version: latest eas-cache: false diff --git a/.github/workflows/mobile-eas-preview.yml b/.github/workflows/mobile-eas-preview.yml index 8b7a1991b2ad1..e3164f0668658 100644 --- a/.github/workflows/mobile-eas-preview.yml +++ b/.github/workflows/mobile-eas-preview.yml @@ -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. @@ -154,7 +154,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 @@ -187,7 +187,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 @@ -205,7 +205,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_PREVIEW_TOKEN }} @@ -290,7 +290,7 @@ jobs: ORG_GRADLE_PROJECT_reactNativeArchitectures: armeabi-v7a,arm64-v8a steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: fetch-depth: 0 @@ -349,7 +349,7 @@ jobs: - name: Mount build cache if: steps.record.outputs.built != 'true' - uses: useblacksmith/stickydisk@v1 + uses: useblacksmith/stickydisk@3f6be1451e3cad893b778bc93495c80452a5da26 # v1.7.2 with: key: ${{ github.repository }}-android-preview-build path: ~/.cache/t3-android-preview @@ -367,7 +367,7 @@ jobs: - name: Setup Node if: steps.record.outputs.built != 'true' - uses: actions/setup-node@v6 + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 with: node-version-file: package.json @@ -384,14 +384,14 @@ jobs: - name: Setup Java if: steps.record.outputs.built != 'true' - uses: actions/setup-java@v5 + 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@v8 + uses: expo/expo-github-action@c7b66a9c327a43a8fa7c0158e7f30d6040d2481e # v8.2.1 with: eas-version: latest token: ${{ secrets.EXPO_PREVIEW_TOKEN }} diff --git a/.github/workflows/mobile-eas-production.yml b/.github/workflows/mobile-eas-production.yml index 9b3ed2bcdfe58..8798d7aa30c70 100644 --- a/.github/workflows/mobile-eas-production.yml +++ b/.github/workflows/mobile-eas-production.yml @@ -124,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 }} @@ -132,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 }} @@ -143,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 @@ -161,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 }} @@ -483,7 +483,7 @@ jobs: BUILD_TAG: mobile-android-build/${{ needs.production.outputs.android_version }}/${{ needs.production.outputs.android_fingerprint }} steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ needs.production.outputs.android_sha }} fetch-depth: 0 @@ -536,7 +536,7 @@ jobs: - name: Mount build cache if: steps.record.outputs.built != 'true' - uses: useblacksmith/stickydisk@v1 + uses: useblacksmith/stickydisk@3f6be1451e3cad893b778bc93495c80452a5da26 # v1.7.2 with: key: ${{ github.repository }}-android-store-build path: ~/.cache/t3-android-store @@ -554,7 +554,7 @@ jobs: - name: Setup Node if: steps.record.outputs.built != 'true' - uses: actions/setup-node@v6 + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 with: node-version-file: package.json @@ -573,14 +573,14 @@ jobs: - name: Setup Java if: steps.record.outputs.built != 'true' - uses: actions/setup-java@v5 + 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@v8 + uses: expo/expo-github-action@c7b66a9c327a43a8fa7c0158e7f30d6040d2481e # v8.2.1 with: eas-version: latest token: ${{ secrets.EXPO_PRODUCTION_TOKEN }} @@ -612,7 +612,7 @@ jobs: # 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@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: android-store-aab-${{ github.run_id }}-${{ github.run_attempt }} path: ${{ runner.temp }}/t3code.aab @@ -650,8 +650,8 @@ jobs: 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. Store binaries need the final - # Xcode 27.1; MOBILE_IOS_ALLOW_XCODE_RC=true accepts the release candidate. + # 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 @@ -671,10 +671,9 @@ jobs: 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 }} - ALLOW_XCODE_RC: ${{ vars.MOBILE_IOS_ALLOW_XCODE_RC == 'true' }} steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ needs.production.outputs.ios_sha }} fetch-depth: 0 @@ -709,12 +708,9 @@ jobs: if: steps.record.outputs.built != 'true' run: | xcode=/Applications/Xcode_27.1.app - if [ ! -d "$xcode" ] && [ "$ALLOW_XCODE_RC" = "true" ]; then - xcode=/Applications/Xcode_27.1_Release_Candidate.app - fi if [ ! -d "$xcode" ]; then ls -d /Applications/Xcode*.app - echo "::error::Xcode 27.1 (final) is not on this runner. Set the MOBILE_IOS_ALLOW_XCODE_RC repository variable to true to build store binaries with the release candidate." + echo "::error::Xcode 27.1 is not on this runner." exit 1 fi sudo xcode-select -s "$xcode/Contents/Developer" @@ -731,7 +727,7 @@ jobs: - name: Setup Node if: steps.record.outputs.built != 'true' - uses: actions/setup-node@v6 + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 with: node-version-file: package.json @@ -750,7 +746,7 @@ jobs: - name: Setup EAS if: steps.record.outputs.built != '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 }} diff --git a/.github/workflows/mobile-fingerprint-check.yml b/.github/workflows/mobile-fingerprint-check.yml index c46d1690aa97d..8667d95457bbf 100644 --- a/.github/workflows/mobile-fingerprint-check.yml +++ b/.github/workflows/mobile-fingerprint-check.yml @@ -34,7 +34,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. @@ -59,7 +59,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 @@ -137,7 +137,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 53b0a74a1f5aa..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 @@ -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/release-desktop.yml b/.github/workflows/release-desktop.yml index 8821a14be50be..0299c7ce51c4c 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -86,6 +86,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. @@ -127,6 +133,14 @@ jobs: # "Wait for Linux CLI archive"), so it gets that wait on top of the usual 30. timeout-minutes: ${{ inputs.platform == 'win' && 70 || 30 }} 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. @@ -167,7 +181,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 @@ -203,7 +217,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 }} @@ -215,7 +229,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/**') }} @@ -223,7 +237,7 @@ 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 @@ -236,7 +250,7 @@ jobs: # 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 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.cache/electron-builder/appimage@*/appimage-tools-runtime-*.tar.gz @@ -254,7 +268,7 @@ 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 }} @@ -272,14 +286,14 @@ jobs: path: packages/mcp-gateway/dist - 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 @@ -327,7 +341,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 }} @@ -354,7 +368,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 }} @@ -489,7 +503,7 @@ jobs: # macOS uploads its archive after "Await CLI notarization" instead. - name: Upload CLI archive if: inputs.cli_archive && inputs.platform != 'mac' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: cli-${{ inputs.platform }}-${{ inputs.arch }} path: release-cli/* @@ -588,7 +602,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 @@ -602,6 +616,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 }} @@ -708,7 +723,7 @@ jobs: - name: Upload notarized CLI archive if: inputs.cli_archive && inputs.platform == 'mac' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: cli-${{ inputs.platform }}-${{ inputs.arch }} path: release-cli/* @@ -728,7 +743,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 }} @@ -759,7 +774,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 @@ -787,7 +802,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/* @@ -854,7 +869,7 @@ 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/* @@ -863,7 +878,7 @@ jobs: 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 }}/* @@ -917,14 +932,14 @@ jobs: cd apps/desktop && vp test run --testTimeout=120000 src/app/WindowsSharedProfile.test.ts src/app/WindowsSharedProfile.windows.test.ts src/app/SharedSafeStorage.native.test.ts - 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 149a0ea3aa544..1d425038443ec 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,13 +62,13 @@ 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: @@ -122,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 @@ -163,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 \ @@ -181,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 @@ -226,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 @@ -239,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: | @@ -271,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: | @@ -300,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: | @@ -326,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 }} @@ -344,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 @@ -365,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: | @@ -399,7 +404,7 @@ jobs: npm_launcher_artifact_id: ${{ steps.npm_launcher.outputs.artifact-id }} steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ needs.preflight.outputs.ref }} sparse-checkout: | @@ -411,7 +416,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 @@ -456,7 +461,7 @@ jobs: # 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: | @@ -482,7 +487,7 @@ jobs: # this upload by its ID, which a publish_cli-only rerun keeps. - name: Upload the npm launcher id: npm_launcher - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: npm-launcher path: npm-packages/t3.tgz @@ -652,7 +657,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. @@ -687,14 +692,14 @@ jobs: 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 @@ -837,7 +842,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 }} @@ -854,7 +859,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 }} @@ -871,22 +876,93 @@ 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, build_bundle, release] - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' }} + 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: @@ -895,7 +971,7 @@ jobs: 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: | @@ -922,7 +998,7 @@ jobs: fi - name: Download the npm launcher - uses: actions/download-artifact@v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: artifact-ids: ${{ needs.build_bundle.outputs.npm_launcher_artifact_id }} path: npm-packages @@ -980,7 +1056,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: | @@ -1059,7 +1135,7 @@ jobs: # 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: | @@ -1130,7 +1206,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: | @@ -1196,7 +1272,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: | @@ -1234,14 +1310,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 @@ -1324,7 +1400,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 e3f0b94cd7a8f..a6c856aac8240 100644 --- a/.github/workflows/web-preview.yml +++ b/.github/workflows/web-preview.yml @@ -45,7 +45,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: | @@ -54,7 +54,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 @@ -96,7 +96,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 00f317aaa9bb5..d6232d67ac889 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,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 `