Skip to content

Commit 890bbbd

Browse files
committed
fix(composer): merge main and preserve thread drop behavior
2 parents 606780c + 573cec0 commit 890bbbd

1,811 files changed

Lines changed: 99284 additions & 25913 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.

‎.coderabbit.config.ts‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
11
import { defineConfig, type CodeRabbitContext } from "@coderabbitai/config";
22

3-
// Org members and collaborators merge their own pull requests. On anyone else's, CodeRabbit
4-
// requests changes until its comments are resolved, then approves.
3+
// Org members and collaborators can approve their own pull requests.
54
const UNGATED_AUTHORS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
65
const isGated = ({ pr }: CodeRabbitContext) => !UNGATED_AUTHORS.has(pr?.authorAssociation ?? "");
76

87
export default defineConfig((ctx) => ({
98
reviews: {
109
high_level_summary: false,
1110
review_status: false,
12-
request_changes_workflow: isGated(ctx),
11+
request_changes_workflow: false,
1312
allow_author_approval: !isGated(ctx),
1413
auto_review: {
1514
enabled: true,
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
// Mirrors the newest build on a Google Play track to a rolling GitHub
2+
// prerelease as a universal APK. Play generates and signs that APK from the
3+
// uploaded bundle with the app signing key, so it carries the same signature
4+
// as the Play Store install. Run by .github/workflows/mobile-android-apk.yml.
5+
const { execFileSync } = require("node:child_process");
6+
const crypto = require("node:crypto");
7+
const fs = require("node:fs");
8+
const os = require("node:os");
9+
const path = require("node:path");
10+
const { Readable } = require("node:stream");
11+
const { pipeline } = require("node:stream/promises");
12+
13+
const PLAY_API = "https://androidpublisher.googleapis.com/androidpublisher/v3/applications";
14+
15+
function requireEnv(name) {
16+
const value = process.env[name];
17+
if (!value) throw new Error(`${name} is required.`);
18+
return value;
19+
}
20+
21+
// Service account key -> OAuth access token via a self-signed JWT grant.
22+
async function playAccessToken(key) {
23+
const now = Math.floor(Date.now() / 1000);
24+
const encode = (value) => Buffer.from(JSON.stringify(value)).toString("base64url");
25+
const unsigned = `${encode({ alg: "RS256", typ: "JWT" })}.${encode({
26+
iss: key.client_email,
27+
scope: "https://www.googleapis.com/auth/androidpublisher",
28+
aud: key.token_uri,
29+
iat: now,
30+
exp: now + 600,
31+
})}`;
32+
const signature = crypto.sign("RSA-SHA256", Buffer.from(unsigned), key.private_key);
33+
const response = await fetch(key.token_uri, {
34+
method: "POST",
35+
body: new URLSearchParams({
36+
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
37+
assertion: `${unsigned}.${signature.toString("base64url")}`,
38+
}),
39+
});
40+
if (!response.ok) {
41+
throw new Error(`Google token exchange failed: ${response.status} ${await response.text()}`);
42+
}
43+
return (await response.json()).access_token;
44+
}
45+
46+
async function play(token, method, url) {
47+
const response = await fetch(url, { method, headers: { authorization: `Bearer ${token}` } });
48+
if (!response.ok) {
49+
throw new Error(`${method} ${url} failed: ${response.status} ${await response.text()}`);
50+
}
51+
return response;
52+
}
53+
54+
// Highest version code the track serves, with its release name. Play names a
55+
// release after the bundle's versionName unless the uploader sets one. This
56+
// endpoint reads without opening an edit; an edit would invalidate any open
57+
// EAS Submit edit made with the same service account.
58+
async function newestTrackBuild(token, appUrl, trackName) {
59+
const { releases = [] } = await (
60+
await play(token, "GET", `${appUrl}/tracks/${trackName}/releases`)
61+
).json();
62+
let newest;
63+
for (const release of releases) {
64+
if (release.releaseLifecycleState !== "RELEASE_LIFECYCLE_STATE_PUBLISHED") continue;
65+
for (const { versionCode } of release.activeArtifacts ?? []) {
66+
if (!newest || versionCode > newest.versionCode) {
67+
newest = { versionCode, versionName: release.releaseName || String(versionCode) };
68+
}
69+
}
70+
}
71+
return newest;
72+
}
73+
74+
function gh(...args) {
75+
return execFileSync("gh", args, { encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] });
76+
}
77+
78+
function readRelease(tag) {
79+
try {
80+
return JSON.parse(gh("release", "view", tag, "--json", "assets,body"));
81+
} catch {
82+
return undefined;
83+
}
84+
}
85+
86+
// Downloads the build's universal APK and uploads it, creating the release on
87+
// first use. Returns false when Play lists no universal APK, which happens
88+
// while it is still processing a fresh upload.
89+
async function uploadApk({ token, appUrl, build, tag, apkName, notes, release }) {
90+
const generated = await (
91+
await play(token, "GET", `${appUrl}/generatedApks/${build.versionCode}`)
92+
).json();
93+
// One entry per app signing key. More than one means a key rotation, so
94+
// fail rather than guess which signature testers should get.
95+
const universal = (generated.generatedApks ?? []).filter((entry) => entry.generatedUniversalApk);
96+
if (universal.length === 0) return false;
97+
if (universal.length > 1) {
98+
throw new Error(`Play returned ${universal.length} universal APKs, one per signing key.`);
99+
}
100+
const { downloadId } = universal[0].generatedUniversalApk;
101+
102+
const apkPath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "apk-")), apkName);
103+
const download = await play(
104+
token,
105+
"GET",
106+
`${appUrl}/generatedApks/${build.versionCode}/downloads/${encodeURIComponent(downloadId)}:download?alt=media`,
107+
);
108+
await pipeline(Readable.fromWeb(download.body), fs.createWriteStream(apkPath));
109+
110+
if (!release) {
111+
// Prerelease with a non-v tag: release.yml, the desktop updater, the CLI,
112+
// and the marketing site's latest-release lookup all ignore it.
113+
gh(
114+
"release",
115+
"create",
116+
tag,
117+
"--prerelease",
118+
"--latest=false",
119+
"--title",
120+
"T3 Code for Android (nightly)",
121+
"--notes",
122+
notes,
123+
);
124+
}
125+
// --clobber replaces an asset a failed upload left behind.
126+
gh("release", "upload", tag, apkPath, "--clobber");
127+
return true;
128+
}
129+
130+
async function main() {
131+
const packageName = requireEnv("PACKAGE_NAME");
132+
const trackName = requireEnv("PLAY_TRACK");
133+
const tag = requireEnv("RELEASE_TAG");
134+
const key = JSON.parse(requireEnv("GOOGLE_PLAY_SERVICE_ACCOUNT_JSON"));
135+
const summary = (line) =>
136+
process.env.GITHUB_STEP_SUMMARY &&
137+
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${line}\n`);
138+
139+
const token = await playAccessToken(key);
140+
const appUrl = `${PLAY_API}/${packageName}`;
141+
const build = await newestTrackBuild(token, appUrl, trackName);
142+
if (!build) {
143+
console.log(`The ${trackName} track has no live release.`);
144+
return;
145+
}
146+
147+
const safeName = build.versionName.replace(/[^0-9A-Za-z.]+/g, "-").replace(/^-|-$/g, "");
148+
const apkName = `t3code-android-${safeName}-${build.versionCode}.apk`;
149+
const notes = [
150+
`T3 Code for Android that works with nightly servers, mirrored from the Google Play ${trackName} track. Current build: ${build.versionName} (${build.versionCode}).`,
151+
"",
152+
"The Play Store release can't connect to a nightly server yet. Install this APK if you can't join the Play testing group. Only the newest build is kept here.",
153+
].join("\n");
154+
155+
const release = readRelease(tag);
156+
const assets = release?.assets ?? [];
157+
// A failed upload can leave an empty asset under the final name.
158+
const published = assets.some(
159+
(asset) => asset.name === apkName && asset.state === "uploaded" && asset.size > 0,
160+
);
161+
if (published) {
162+
console.log(`${apkName} is already published.`);
163+
} else if (await uploadApk({ token, appUrl, build, tag, apkName, notes, release })) {
164+
console.log(`Published ${apkName}.`);
165+
summary(`:white_check_mark: Published \`${apkName}\` to the \`${tag}\` release.`);
166+
} else {
167+
// The next run retries.
168+
console.log(`Play lists no universal APK for ${build.versionCode}.`);
169+
summary(
170+
`:hourglass: Play lists no universal APK for ${build.versionName} (${build.versionCode}).`,
171+
);
172+
return;
173+
}
174+
175+
// Reconciled on every run so an interrupted cleanup finishes on the next one.
176+
for (const stale of assets) {
177+
if (stale.name.endsWith(".apk") && stale.name !== apkName) {
178+
gh("release", "delete-asset", tag, stale.name, "--yes");
179+
}
180+
}
181+
if (release && release.body.replace(/\r\n/g, "\n").trim() !== notes) {
182+
gh("release", "edit", tag, "--notes", notes);
183+
}
184+
}
185+
186+
main().catch((error) => {
187+
console.error(error);
188+
process.exit(1);
189+
});

‎.github/workflows/cursor-hygiene-webhook.yml‎

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,23 @@ jobs:
2828
echo "Missing CURSOR_T3CODE_WEBHOOK_URL or CURSOR_T3CODE_WEBHOOK_AUTH — skipping."
2929
exit 0
3030
fi
31-
curl -fsS --max-time 60 -X POST "$URL" \
32-
-H "Authorization: $AUTH" \
33-
-H "Content-Type: application/json" \
34-
-H "X-GitHub-Event: ${{ github.event_name }}" \
35-
-H "X-GitHub-Delivery: ${{ github.run_id }}-${{ github.run_attempt }}" \
36-
--data-binary @"${{ github.event_path }}"
31+
for attempt in 1 2 3; do
32+
curl_status=0
33+
http_status=$(curl -fsS --max-time 60 -X POST "$URL" \
34+
--output /dev/null --write-out '%{http_code}' \
35+
-H "Authorization: $AUTH" \
36+
-H "Content-Type: application/json" \
37+
-H "X-GitHub-Event: ${{ github.event_name }}" \
38+
-H "X-GitHub-Delivery: ${{ github.run_id }}-${{ github.run_attempt }}" \
39+
--data-binary @"${{ github.event_path }}") || curl_status=$?
40+
if [ "$curl_status" -eq 0 ]; then
41+
exit 0
42+
fi
43+
if [ "$GITHUB_REF" != "refs/heads/main" ] || \
44+
[ "$curl_status" -ne 22 ] || [ "$http_status" != "400" ] || \
45+
[ "$attempt" -eq 3 ]; then
46+
exit "$curl_status"
47+
fi
48+
echo "Cursor webhook returned HTTP 400; retrying in 5 seconds ($attempt/2)."
49+
sleep 5
50+
done
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
name: Mobile Android APK
2+
3+
# Publishes the newest nightly-compatible Android build as a sideloadable APK
4+
# on the rolling "android-nightly" prerelease, for nightly users who can't join
5+
# the Play testing group. Play re-signs uploaded bundles with the app signing
6+
# key, so its universal APK matches the Play Store install and needs no signing
7+
# secrets here. EAS submits asynchronously (--no-wait), so this polls Play
8+
# instead of chaining off mobile-eas-production.yml; runs with nothing new exit
9+
# early.
10+
on:
11+
schedule:
12+
# Avoid minute zero, when GitHub scheduled jobs are busiest.
13+
- cron: "23 * * * *"
14+
workflow_dispatch:
15+
16+
concurrency:
17+
group: mobile-android-apk
18+
cancel-in-progress: false
19+
20+
jobs:
21+
mirror:
22+
name: Mirror Play APK
23+
runs-on: blacksmith-2vcpu-ubuntu-2404
24+
timeout-minutes: 10
25+
permissions:
26+
contents: write
27+
env:
28+
PACKAGE_NAME: com.t3tools.t3code
29+
# eas.json submits v2-preview builds to this closed testing track; they
30+
# speak the orchestration protocol nightly servers use, which the Play
31+
# Store release can't.
32+
PLAY_TRACK: v2
33+
RELEASE_TAG: android-nightly
34+
steps:
35+
- id: credentials
36+
name: Check for Play credentials
37+
env:
38+
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
39+
run: |
40+
if [ -n "$GOOGLE_PLAY_SERVICE_ACCOUNT_JSON" ]; then
41+
echo "present=true" >> "$GITHUB_OUTPUT"
42+
else
43+
echo "present=false" >> "$GITHUB_OUTPUT"
44+
echo "GOOGLE_PLAY_SERVICE_ACCOUNT_JSON is not available; skipping APK mirror."
45+
fi
46+
47+
- name: Checkout
48+
if: steps.credentials.outputs.present == 'true'
49+
uses: actions/checkout@v6
50+
with:
51+
sparse-checkout: .github/scripts
52+
53+
- name: Mirror the newest APK
54+
if: steps.credentials.outputs.present == 'true'
55+
env:
56+
GH_TOKEN: ${{ github.token }}
57+
GH_REPO: ${{ github.repository }}
58+
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
59+
run: node .github/scripts/mirror-play-apk.cjs

‎.github/workflows/mobile-eas-preview.yml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,8 @@ jobs:
100100
uses: expo/expo-github-action/continuous-deploy-fingerprint@main
101101
env:
102102
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
103+
# The Expo action otherwise suppresses the EAS command's failure output.
104+
RUNNER_DEBUG: "1"
103105
with:
104106
profile: preview:dev
105107
branch: pr-${{ github.event.pull_request.number }}

0 commit comments

Comments
 (0)