Skip to content

Commit ad7bbbc

Browse files
authored
Merge pull request #36 from tebiggs/sync-conflict/v0.1.8-nightly.20261109.4399
chore(fork): merge upstream v0.1.8-nightly.20261109.4399
2 parents 5255dd4 + 3d6a26c commit ad7bbbc

415 files changed

Lines changed: 14377 additions & 3818 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/actions/ci-changes/action.yml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ outputs:
55
value: ${{ steps.detect.outputs.typecheck }}
66
build:
77
value: ${{ steps.detect.outputs.build }}
8+
desktop_launch:
9+
value: ${{ steps.detect.outputs.desktop_launch }}
810
test:
911
value: ${{ steps.detect.outputs.test }}
1012
test_packages:

‎.github/fork/upstream-tag‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
v0.1.8-nightly.20261108.4353
1+
v0.1.8-nightly.20261109.4399

‎.github/scripts/check-nightly-release.cjs‎

Lines changed: 42 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -40,42 +40,67 @@ async function assertReleaseSource({ github, context, releaseChannel }) {
4040

4141
const isNightlyTag = (tag) => /^v.*-nightly\./.test(tag) || tag.startsWith("nightly-v");
4242

43-
async function findLatestNightly({ github, context }) {
44-
const releases = await github.paginate(github.rest.repos.listReleases, {
45-
...context.repo,
46-
per_page: 100,
47-
});
48-
return releases
49-
.filter((release) => !release.draft && release.published_at && isNightlyTag(release.tag_name))
50-
.sort((a, b) => Date.parse(b.published_at) - Date.parse(a.published_at))[0];
43+
// Only the three fields the gate reads. The REST list also returns every asset,
44+
// which made a full scan tens of megabytes.
45+
const RELEASES_QUERY = `
46+
query ($owner: String!, $repo: String!, $cursor: String) {
47+
repository(owner: $owner, name: $repo) {
48+
releases(first: 100, after: $cursor, orderBy: { field: CREATED_AT, direction: DESC }) {
49+
pageInfo { hasNextPage endCursor }
50+
nodes { tagName isDraft publishedAt }
51+
}
52+
}
53+
}
54+
`;
55+
56+
// Returns the nightly with the newest publishedAt. Release order follows the
57+
// tagged commit date, not publication, so every page is read unless `isSettled`
58+
// says the newest nightly seen so far already decides the caller's answer.
59+
async function findLatestNightly({ github, context, isSettled = () => false }) {
60+
let latest;
61+
let cursor = null;
62+
do {
63+
const { repository } = await github.graphql(RELEASES_QUERY, { ...context.repo, cursor });
64+
const { nodes, pageInfo } = repository.releases;
65+
for (const release of nodes) {
66+
if (release.isDraft || !release.publishedAt || !isNightlyTag(release.tagName)) continue;
67+
if (!latest || Date.parse(release.publishedAt) > Date.parse(latest.publishedAt)) {
68+
latest = release;
69+
}
70+
}
71+
if (latest && isSettled(latest)) return latest;
72+
cursor = pageInfo.hasNextPage ? pageInfo.endCursor : null;
73+
} while (cursor);
74+
return latest;
5175
}
5276

5377
async function shouldReleaseNightly({ github, context, core, now = Date.now() }) {
54-
const lastNightly = await findLatestNightly({ github, context });
78+
const isInGap = (release) => now - Date.parse(release.publishedAt) < MINIMUM_RELEASE_GAP_MS;
79+
// Any nightly inside the gap means the newest one is too, so stop reading.
80+
const lastNightly = await findLatestNightly({ github, context, isSettled: isInGap });
5581

5682
if (!lastNightly) {
5783
core.info("No published nightly found. Proceeding with release.");
5884
return true;
5985
}
6086

61-
if (now - Date.parse(lastNightly.published_at) < MINIMUM_RELEASE_GAP_MS) {
62-
core.info(`Nightly ${lastNightly.tag_name} was published less than six hours ago. Skipping.`);
87+
const tag = lastNightly.tagName;
88+
if (isInGap(lastNightly)) {
89+
core.info(`Nightly ${tag} was published less than six hours ago. Skipping.`);
6390
return false;
6491
}
6592

6693
const { data: comparison } = await github.rest.repos.compareCommitsWithBasehead({
6794
...context.repo,
68-
basehead: `${lastNightly.tag_name}...${context.sha}`,
95+
basehead: `${tag}...${context.sha}`,
6996
per_page: 1,
7097
});
7198
if (comparison.status !== "ahead") {
72-
core.info(
73-
`Candidate commit is ${comparison.status} relative to ${lastNightly.tag_name}. Skipping.`,
74-
);
99+
core.info(`Candidate commit is ${comparison.status} relative to ${tag}. Skipping.`);
75100
return false;
76101
}
77102

78-
core.info(`New commits since ${lastNightly.tag_name}, and the six-hour gap has passed.`);
103+
core.info(`New commits since ${tag}, and the six-hour gap has passed.`);
79104
return true;
80105
}
81106

@@ -85,7 +110,7 @@ async function resolveLatestNightlyCommit({ github, context, core }) {
85110
throw new Error("No published nightly found. Stable releases build the latest nightly commit.");
86111
}
87112

88-
const tag = lastNightly.tag_name;
113+
const tag = lastNightly.tagName;
89114
const { data: commit } = await github.rest.repos.getCommit({ ...context.repo, ref: tag });
90115
const version = /^(?:nightly-)?v(\d+\.\d+\.\d+)-nightly\./.exec(tag)?.[1];
91116
if (!version) {

‎.github/scripts/check-nightly-release.test.cjs‎

Lines changed: 84 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@ const {
99
const now = Date.parse("2026-09-05T12:00:00Z");
1010
const hour = 60 * 60 * 1000;
1111
const nightly = (hoursAgo, overrides = {}) => ({
12-
tag_name: "v1.0.1-nightly.20260905.123",
13-
draft: false,
14-
published_at: new Date(now - hoursAgo * hour).toISOString(),
12+
tagName: "v1.0.1-nightly.20260905.123",
13+
isDraft: false,
14+
publishedAt: new Date(now - hoursAgo * hour).toISOString(),
1515
...overrides,
1616
});
1717

@@ -73,26 +73,39 @@ for (const comparisonStatus of ["behind", "diverged"]) {
7373
});
7474
}
7575

76-
function fixture({ releases = [nightly(7)], comparisonStatus = "ahead" } = {}) {
76+
// `pages` holds the GraphQL release pages; `releases` is shorthand for one page.
77+
function fixture({ releases = [nightly(7)], pages = [releases], comparisonStatus = "ahead" } = {}) {
7778
const calls = [];
79+
const cursors = [];
7880
return {
7981
calls,
82+
cursors,
8083
options: {
8184
now,
8285
context: { repo: { owner: "example", repo: "app" }, sha: "new" },
8386
core: { info() {} },
8487
github: {
8588
rest: {
8689
repos: {
87-
listReleases() {},
8890
async compareCommitsWithBasehead(params) {
8991
calls.push(params);
9092
return { data: { status: comparisonStatus } };
9193
},
9294
},
9395
},
94-
async paginate() {
95-
return releases;
96+
async graphql(_query, { owner, repo, cursor }) {
97+
assert.deepEqual({ owner, repo }, { owner: "example", repo: "app" });
98+
cursors.push(cursor);
99+
const index = cursor === null ? 0 : Number(cursor);
100+
const hasNextPage = index + 1 < pages.length;
101+
return {
102+
repository: {
103+
releases: {
104+
nodes: pages[index],
105+
pageInfo: { hasNextPage, endCursor: hasNextPage ? String(index + 1) : null },
106+
},
107+
},
108+
};
96109
},
97110
},
98111
},
@@ -101,28 +114,65 @@ function fixture({ releases = [nightly(7)], comparisonStatus = "ahead" } = {}) {
101114

102115
test("releases the first nightly when no nightly is published", async () => {
103116
const { options } = fixture({
104-
releases: [nightly(0, { tag_name: "v1.0.0" }), nightly(0, { draft: true })],
117+
releases: [nightly(0, { tagName: "v1.0.0" }), nightly(0, { isDraft: true })],
105118
});
106119
assert.equal(await shouldReleaseNightly(options), true);
107120
});
108121

109122
test("uses publication time, not release order or the tagged commit date", async () => {
110123
const { options } = fixture({
111-
releases: [nightly(10), nightly(1), nightly(20, { tag_name: "nightly-v0.9.0" })],
124+
releases: [nightly(10), nightly(1), nightly(20, { tagName: "nightly-v0.9.0" })],
125+
});
126+
assert.equal(await shouldReleaseNightly(options), false);
127+
});
128+
129+
test("the six-hour gap ends exactly six hours after publication", async () => {
130+
const atBoundary = fixture({ releases: [nightly(6)] });
131+
assert.equal(await shouldReleaseNightly(atBoundary.options), true);
132+
const justInside = fixture({ releases: [nightly(6 - 1 / 3_600_000)] });
133+
assert.equal(await shouldReleaseNightly(justInside.options), false);
134+
assert.equal(justInside.calls.length, 0);
135+
});
136+
137+
test("stops reading releases once a nightly inside the gap is seen", async () => {
138+
const { options, cursors } = fixture({ pages: [[nightly(10), nightly(2)], [nightly(30)]] });
139+
assert.equal(await shouldReleaseNightly(options), false);
140+
assert.deepEqual(cursors, [null]);
141+
});
142+
143+
test("reads every page when no nightly seen so far settles the gap", async () => {
144+
const { options, cursors, calls } = fixture({
145+
pages: [
146+
[nightly(10), nightly(0, { tagName: "v1.0.0" })],
147+
[nightly(1, { tagName: "nightly-v0.9.0" })],
148+
],
112149
});
113150
assert.equal(await shouldReleaseNightly(options), false);
151+
assert.deepEqual(cursors, [null, "1"]);
152+
assert.equal(calls.length, 0);
153+
});
154+
155+
test("compares against the newest nightly across pages once the gap has passed", async () => {
156+
const { options, calls } = fixture({
157+
pages: [[nightly(10, { tagName: "v1.0.1-nightly.20260905.100" })], [nightly(8)]],
158+
});
159+
assert.equal(await shouldReleaseNightly(options), true);
160+
assert.deepEqual(
161+
calls.map((call) => call.basehead),
162+
["v1.0.1-nightly.20260905.123...new"],
163+
);
114164
});
115165

116166
test("ignores stable releases and drafts when checking the gap", async () => {
117167
const { options } = fixture({
118-
releases: [nightly(0, { tag_name: "v1.0.0" }), nightly(0, { draft: true }), nightly(7)],
168+
releases: [nightly(0, { tagName: "v1.0.0" }), nightly(0, { isDraft: true }), nightly(7)],
119169
});
120170
assert.equal(await shouldReleaseNightly(options), true);
121171
});
122172

123173
test("fails instead of releasing when GitHub cannot supply release state", async () => {
124174
const { options } = fixture();
125-
options.github.paginate = async () => {
175+
options.github.graphql = async () => {
126176
throw new Error("GitHub unavailable");
127177
};
128178
await assert.rejects(shouldReleaseNightly(options), /GitHub unavailable/);
@@ -137,9 +187,9 @@ for (const status of ["behind", "diverged"]) {
137187

138188
const { resolveLatestNightlyCommit } = require("./check-nightly-release.cjs");
139189

140-
function nightlyCommitFixture({ releases, commitSha = "abc123" }) {
190+
function nightlyCommitFixture({ releases, pages, commitSha = "abc123" }) {
141191
const refs = [];
142-
const { options } = fixture({ releases });
192+
const { options } = fixture({ releases, pages });
143193
options.github.rest.repos.getCommit = async ({ ref }) => {
144194
refs.push(ref);
145195
return { data: { sha: commitSha } };
@@ -150,10 +200,10 @@ function nightlyCommitFixture({ releases, commitSha = "abc123" }) {
150200
test("stable releases resolve the commit of the newest published nightly", async () => {
151201
const { options, refs } = nightlyCommitFixture({
152202
releases: [
153-
nightly(10, { tag_name: "v1.0.1-nightly.20260905.100" }),
154-
nightly(1, { tag_name: "v1.0.1-nightly.20260905.123" }),
155-
nightly(0, { tag_name: "v1.0.0" }),
156-
nightly(0, { draft: true, tag_name: "v1.0.1-nightly.20260905.999" }),
203+
nightly(10, { tagName: "v1.0.1-nightly.20260905.100" }),
204+
nightly(1, { tagName: "v1.0.1-nightly.20260905.123" }),
205+
nightly(0, { tagName: "v1.0.0" }),
206+
nightly(0, { isDraft: true, tagName: "v1.0.1-nightly.20260905.999" }),
157207
],
158208
commitSha: "deadbeef",
159209
});
@@ -167,7 +217,23 @@ test("stable releases resolve the commit of the newest published nightly", async
167217

168218
test("stable releases derive the version from legacy nightly tags", async () => {
169219
const { options } = nightlyCommitFixture({
170-
releases: [nightly(1, { tag_name: "nightly-v0.9.0-nightly.20260905.5" })],
220+
releases: [nightly(1, { tagName: "nightly-v0.9.0-nightly.20260905.5" })],
171221
});
172222
assert.equal((await resolveLatestNightlyCommit(options)).version, "0.9.0");
173223
});
224+
225+
test("stable releases read every page, even after a recent nightly", async () => {
226+
const { options, refs } = nightlyCommitFixture({
227+
pages: [
228+
[nightly(2, { tagName: "v1.0.1-nightly.20260905.100" })],
229+
[nightly(1, { tagName: "v1.0.1-nightly.20260905.123" })],
230+
],
231+
});
232+
assert.equal((await resolveLatestNightlyCommit(options)).tag, "v1.0.1-nightly.20260905.123");
233+
assert.deepEqual(refs, ["v1.0.1-nightly.20260905.123"]);
234+
});
235+
236+
test("stable releases fail when no nightly is published", async () => {
237+
const { options } = nightlyCommitFixture({ releases: [nightly(1, { tagName: "v1.0.0" })] });
238+
await assert.rejects(resolveLatestNightlyCommit(options), /No published nightly found/);
239+
});

‎.github/scripts/ci-changes.cjs‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,21 @@ const BUILD_ONLY_DEPS = { t3: ["@t3tools/web"] };
5151
const MOBILE_NATIVE =
5252
/^apps\/mobile\/.*\.(swift|kt|kts)$|^apps\/mobile\/(\.swiftlint\.yml|detekt\.yml|\.editorconfig|Brewfile)$|^scripts\/mobile-native-static-check\.ts$|^package\.json$|^\.github\/workflows\/ci\.yml$|^\.github\/scripts\/ci-changes\.cjs$|^\.github\/actions\/ci-changes\//;
5353

54+
// Startup, migration, settlement, and sidebar paths that decide whether an upgraded user's
55+
// threads show after launch. Build runs scripts/smoke-desktop-app.ts for them, so they do not
56+
// first fail in the release launch check.
57+
const DESKTOP_LAUNCH = [
58+
/^apps\/desktop\/src\/((main|boot|compileCache|serverCompileCache)\.ts$|(app|backend|window)\/)/,
59+
/^apps\/desktop\/src\/electron\/(ElectronApp|ElectronProtocol|ElectronWindow)\.ts$/,
60+
/^apps\/web\/src\/((main|AppRoot|appInit)\.tsx|(bootstrap|router)\.ts)$/,
61+
/^apps\/web\/src\/.*sidebar/i,
62+
/^apps\/server\/src\/(bin|binMain|server|serverRuntimeStartup)\.ts$/,
63+
/^apps\/server\/src\/persistence\/Migrations/,
64+
/^apps\/server\/src\/orchestration-v2\/((ThreadSettlementService|ProjectionStore)\.ts$|legacy\/)/,
65+
/^packages\/contracts\/src\/settings\.ts$/,
66+
/^scripts\/(smoke-desktop-app\.ts|fixtures\/desktop-launch\/)/,
67+
];
68+
5469
const OWN_TEST_JOB = new Set(["t3", "@t3tools/web", "@t3tools/monorepo"]);
5570

5671
function readWorkspace() {
@@ -151,12 +166,18 @@ function decide(files, packages, full) {
151166
(pkg) => pkg.hasTest && !OWN_TEST_JOB.has(pkg.name) && affected.has(pkg.name),
152167
);
153168
const has = (name) => affected.has(name);
169+
const desktopLaunch =
170+
everything ||
171+
files.some(
172+
(file) => !/\.test\.tsx?$/.test(file) && DESKTOP_LAUNCH.some((pattern) => pattern.test(file)),
173+
);
154174

155175
return {
156176
affected: [...affected].sort(),
157177
outputs: {
158178
typecheck: affected.size > 0,
159-
build: has("@t3tools/desktop") || has("t3") || has("@t3tools/web"),
179+
build: has("@t3tools/desktop") || has("t3") || has("@t3tools/web") || desktopLaunch,
180+
desktop_launch: desktopLaunch,
160181
test: tested.length > 0,
161182
// Empty means every package the Test job covers.
162183
test_packages: everything ? "" : tested.map((pkg) => pkg.name).join(" "),

‎.github/scripts/ci-changes.d.cts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export function decide(
1616
outputs: {
1717
typecheck: boolean;
1818
build: boolean;
19+
desktop_launch: boolean;
1920
test: boolean;
2021
test_packages: string;
2122
test_web: boolean;

‎.github/workflows/ci.yml‎

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ jobs:
2727
outputs:
2828
typecheck: ${{ steps.detect.outputs.typecheck }}
2929
build: ${{ steps.detect.outputs.build }}
30+
desktop_launch: ${{ steps.detect.outputs.desktop_launch }}
3031
test: ${{ steps.detect.outputs.test }}
3132
test_packages: ${{ steps.detect.outputs.test_packages }}
3233
test_web: ${{ steps.detect.outputs.test_web }}
@@ -135,13 +136,14 @@ jobs:
135136
if: needs.changes.outputs.build != 'false'
136137

137138
# Runs in the background while Vite+ installs; the step before the build waits for it.
139+
# Xvfb is for the desktop launch check below.
138140
- name: Start installing browser secret helper build libraries
139141
if: needs.changes.outputs.build != 'false'
140142
run: |
141143
sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources
142144
(
143145
set +e
144-
sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config
146+
sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config xvfb
145147
echo "$?" > "$RUNNER_TEMP/apt-status"
146148
) > "$RUNNER_TEMP/apt.log" 2>&1 < /dev/null &
147149
@@ -192,6 +194,25 @@ jobs:
192194
if: ${{ !cancelled() && steps.build.outcome == 'success' }}
193195
run: node apps/desktop/scripts/verify-preload-bundle.mjs
194196

197+
# The release launch check, run unpackaged against the same old-state fixture, for
198+
# startup and sidebar changes. Packaging and signing stay release-only.
199+
- name: Open the seeded thread in the built desktop app
200+
id: desktop_launch
201+
if: ${{ !cancelled() && steps.build.outcome == 'success' && needs.changes.outputs.desktop_launch != 'false' }}
202+
timeout-minutes: 4
203+
run: |
204+
electron=$(node -p "require('./apps/desktop/node_modules/electron')")
205+
xvfb-run -a node scripts/smoke-desktop-app.ts --executable "$electron" --app apps/desktop \
206+
--artifacts-dir "$RUNNER_TEMP/desktop-launch-check"
207+
208+
- name: Upload desktop launch check
209+
if: ${{ failure() && steps.desktop_launch.outcome == 'failure' }}
210+
uses: actions/upload-artifact@v7
211+
with:
212+
name: desktop-launch-check
213+
path: ${{ runner.temp }}/desktop-launch-check
214+
if-no-files-found: ignore
215+
195216
# Browser tests run source modules; this boots the production chunk graph. The
196217
# desktop build above already produced apps/web/dist, and the client waits about
197218
# 15s for a session that never comes, so this runs here instead of in Test Web,
@@ -622,7 +643,7 @@ jobs:
622643
# not needed. Gates are keyed by job name, except merged Build and the
623644
# macOS lint.
624645
echo "$RESULTS" | jq -e '.changes.outputs as $gates
625-
| {build: ["build", "typecheck"], mobile_native_static_analysis: ["mobile_native"]} as $renamed
646+
| {build: ["build", "typecheck", "desktop_launch"], mobile_native_static_analysis: ["mobile_native"]} as $renamed
626647
| all(to_entries[];
627648
.value.result == "success"
628649
or (.value.result == "skipped"

0 commit comments

Comments
 (0)