diff --git a/cloudflare/proxy-latha-org/test_worker.mjs b/cloudflare/proxy-latha-org/test_worker.mjs index 12f1ca7..ccbb1ca 100644 --- a/cloudflare/proxy-latha-org/test_worker.mjs +++ b/cloudflare/proxy-latha-org/test_worker.mjs @@ -378,6 +378,40 @@ async function testTagPushTriggersBuildAndPublishStep() { console.log("PASS: tag push -> resolves the tag's real commit, builds with commit_sha (not branch), and appends a release-publish step"); } +async function testTagPushBuildScriptIncludesHostBinary() { + // A tag push must also build //:sleek-host (the desktop egui binary + // codegod100/tap's Formula/sleek.rb downloads) and publish it as a + // second, separately-named artifact under the same tag — not just the + // apk. See buildScript()'s tagName branch. + const payload = JSON.stringify({ + after: "tagcommitsha2", + ref: "refs/tags/v4.5.6", + repository: { clone_url: "https://tangled.org/nandi.uk/sleek" }, + }); + const req = new Request("https://proxy.latha.org/webhook", { + method: "POST", + headers: { "X-Tangled-Event": "push", "X-Tangled-Signature-256": sign("test-secret", payload) }, + body: payload, + }); + const { ctx, pending } = ctxWithWaitUntil(); + const res = await worker.fetch(req, env, ctx); + assert.equal(res.status, 200); + await Promise.all(pending); + const bbBody = JSON.parse(calls.fetch[calls.fetch.length - 1].opts.body); + const script = bbBody.steps[0].run; + assert.match(script, /buck2 build --show-output \/\/:sleek-host/, "must also build the desktop host binary"); + assert.match(script, new RegExp(`upload/${MOCK_TAG_COMMIT_SHA}/sleek-x86_64-linux`), "must upload it under its own filename, not overwrite sleek.apk"); + // Two release-publish calls: one per artifact, each naming which file it's + // publishing — filename defaults to sleek.apk server-side, so the apk + // call must still say so explicitly for the second one to be + // distinguishable at all. + const publishCalls = script.match(/\/publish-release\/v4\.5\.6/g) || []; + assert.equal(publishCalls.length, 2, "must publish both the apk and the host binary as separate release records"); + assert.match(script, /\\"filename\\":\\"sleek\.apk\\"/, "apk publish call must name itself explicitly"); + assert.match(script, /\\"filename\\":\\"sleek-x86_64-linux\\"/, "host-binary publish call must name itself"); + console.log("PASS: tag push build script also builds + publishes the desktop host binary as a distinct artifact"); +} + async function testPublishReleaseRejectsBadToken() { const req = new Request("https://proxy.latha.org/publish-release/v1.2.3", { method: "POST", @@ -421,6 +455,35 @@ async function testPublishReleaseWithoutOauthSession401() { console.log("PASS: publish-release without a prior /oauth/login -> 401, no atproto calls attempted"); } +async function testPublishReleaseWithExplicitFilenameDoesNotClobber() { + // Two artifacts published under the same tag (apk defaults to + // "sleek.apk"; the host binary passes filename explicitly) must land as + // two separate releases//.json records, not one + // overwriting the other. + await env.ARTIFACTS.put("multisha/sleek.apk", "fake apk bytes"); + await env.ARTIFACTS.put("multisha/sleek-x86_64-linux", "fake host binary bytes"); + // No oauth session configured in this test env, so both calls 401 before + // ever reaching atproto — enough to prove each call resolves and records + // against its *own* filename-keyed R2 object without a crash or a wrong + // "no artifact stored" 404 for either one. + const reqApk = new Request("https://proxy.latha.org/publish-release/v7.7.7", { + method: "POST", + headers: { Authorization: "Bearer test-upload-token", "content-type": "application/json" }, + body: JSON.stringify({ sha: "multisha", tagHash: "deadbeef", filename: "sleek.apk" }), + }); + const reqHost = new Request("https://proxy.latha.org/publish-release/v7.7.7", { + method: "POST", + headers: { Authorization: "Bearer test-upload-token", "content-type": "application/json" }, + body: JSON.stringify({ sha: "multisha", tagHash: "deadbeef", filename: "sleek-x86_64-linux" }), + }); + const { ctx } = ctxWithWaitUntil(); + const resApk = await worker.fetch(reqApk, env, ctx); + const resHost = await worker.fetch(reqHost, env, ctx); + assert.equal(resApk.status, 401, "apk artifact was found (not 404) — just no oauth session"); + assert.equal(resHost.status, 401, "host-binary artifact was found (not 404) — just no oauth session"); + console.log("PASS: publish-release resolves each artifact by its own filename, not just sha"); +} + async function testOauthCallbackRejectsUnknownState() { const req = new Request("https://proxy.latha.org/oauth/callback?code=abc&state=never-issued"); const { ctx } = ctxWithWaitUntil(); @@ -443,9 +506,11 @@ const tests = [ testClientMetadataDocument, testOauthCallbackRejectsUnknownState, testTagPushTriggersBuildAndPublishStep, + testTagPushBuildScriptIncludesHostBinary, testPublishReleaseRejectsBadToken, testPublishReleaseMissingArtifact404, testPublishReleaseWithoutOauthSession401, + testPublishReleaseWithExplicitFilenameDoesNotClobber, ]; let failed = 0; diff --git a/cloudflare/proxy-latha-org/worker.js b/cloudflare/proxy-latha-org/worker.js index 40a6e03..66864b1 100644 --- a/cloudflare/proxy-latha-org/worker.js +++ b/cloudflare/proxy-latha-org/worker.js @@ -13,6 +13,12 @@ // https://proxy.latha.org/artifacts//sleek.apk // https://proxy.latha.org/artifacts/latest/sleek.apk (always newest) // +// Tag pushes additionally build //:sleek-host (the desktop egui binary) and +// publish both it and the apk to tangled.org as sh.tangled.repo.artifact +// release records (see "tangled release publishing" below) — that's what +// codegod100/tap's Formula/sleek.rb downloads instead of building from +// source. +// // No npm deps — plain ES module Worker, deployable via the raw Cloudflare // API with curl (see deploy.sh). Bindings/secrets expected: // env.ARTIFACTS R2 bucket binding @@ -99,9 +105,9 @@ async function handleWebhook(request, env, ctx) { return new Response("bad json", { status: 400 }); } - // main pushes build the apk; tag pushes build it *and* publish it to - // tangled.org as a sh.tangled.repo.artifact release record (see - // handlePublishRelease near the bottom of this file) — everything else + // main pushes build just the apk; tag pushes additionally build + // //:sleek-host and publish both as sh.tangled.repo.artifact release + // records (see handlePublishRelease near the bottom of this file) — everything else // (feature branches, etc.) is ignored. const isMain = payload.ref === "refs/heads/main"; const tagMatch = typeof payload.ref === "string" ? payload.ref.match(/^refs\/tags\/(.+)$/) : null; @@ -220,10 +226,14 @@ function buildScript(env, sha, tagName, tagHash) { // hit, ~2s, zero local/remote compute — "standard" RE caching working // as designed, unlike Nix's substituter-trust footguns. // - // Only //:sleek-android-apk for now — there's no buck2 target for the - // flatpak bundle yet (that was flake.nix's sleek-flatpak derivation; - // porting it is future work), so this pipeline currently only publishes - // the APK. + // //:sleek-android-apk builds (and, on main, publishes to `latest/`) + // unconditionally. //:sleek-host — the desktop egui binary, used by the + // codegod100/tap Homebrew formula (see Formula/sleek.rb's history and + // https://github.com/codegod100/homebrew-tap) — only needs building on a + // tag push: nobody installs an unpinned/unreleased build via brew, and a + // stable download URL requires a real tag's hash anyway (see + // publishStep()'s comment below). Skipping it on main pushes also keeps + // ordinary main-push builds as fast as they were before this existed. const steps = [ "set -euo pipefail", "if ! command -v buck2 >/dev/null 2>&1; then", @@ -263,25 +273,38 @@ function buildScript(env, sha, tagName, tagHash) { '[ -n "$apk_path" ] && [ -f "$apk_path" ] || { echo "buck2 build did not produce //:sleek-android-apk output"; exit 1; }', `curl -fsS -X PUT "${uploadBase}/sleek.apk" -H "Authorization: Bearer ${env.UPLOAD_TOKEN}" --data-binary @"$apk_path"`, ]; + // Ask the Worker to publish `filename` (already uploaded to + // `${uploadBase}/${filename}` by this point) as a sh.tangled.repo.artifact + // release record (see handlePublishRelease). Best-effort — the file is + // already safely in R2 by the time this runs, so a publish failure here + // (e.g. OAuth was never completed via /oauth/login) shouldn't fail the + // whole build; check /artifacts/releases//.json after for + // the actual outcome. tagHash (the tag object's own hash — what + // sh.tangled.repo.artifact's `tag` field wants) is passed in as a + // literal, computed by the caller from the webhook payload directly — NOT + // via `git rev-parse refs/tags/` here, which fails on this + // executor: BuildBuddy's checkout only fetches the single commit_sha + // object, never the tag ref itself (confirmed live, invocation c24d0ebd: + // "fatal: ambiguous argument 'refs/tags/v0.1.3': unknown revision or path + // not in the working tree"). + const publishStep = (filename) => + `curl -fsS -X POST "https://proxy.latha.org/publish-release/${encodeURIComponent(tagName)}" ` + + `-H "Authorization: Bearer ${env.UPLOAD_TOKEN}" -H "content-type: application/json" ` + + `-d "{\\"sha\\":\\"${sha}\\",\\"tagHash\\":\\"${tagHash}\\",\\"filename\\":\\"${filename}\\"}" ` + + `|| echo "release publish failed (${filename} is still uploaded at ${uploadBase}/${filename})"`; if (tagName) { - // Ask the Worker to publish this apk as a sh.tangled.repo.artifact - // release record (see handlePublishRelease). Best-effort — the apk is - // already safely uploaded above by this point, so a publish failure - // here (e.g. OAuth was never completed via /oauth/login) shouldn't - // fail the whole build. Check /artifacts/releases/.json after for - // the actual outcome. tagHash (the tag object's own hash — what - // sh.tangled.repo.artifact's `tag` field wants) is passed in as a - // literal, computed by the caller from the webhook payload directly — - // NOT via `git rev-parse refs/tags/` here, which fails on this - // executor: BuildBuddy's checkout only fetches the single commit_sha - // object, never the tag ref itself (confirmed live, invocation - // c24d0ebd: "fatal: ambiguous argument 'refs/tags/v0.1.3': unknown - // revision or path not in the working tree"). + steps.push(publishStep("sleek.apk")); + // The desktop host binary — same repo checkout, same buck2/BuildBuddy + // setup already exported above, just a second target. Named + // sleek-x86_64-linux (not bare "sleek") so it's self-describing once + // it's sitting in a directory listing / download link on its own, + // divorced from the repo/formula context that names it "sleek". steps.push( - `curl -fsS -X POST "https://proxy.latha.org/publish-release/${encodeURIComponent(tagName)}" ` + - `-H "Authorization: Bearer ${env.UPLOAD_TOKEN}" -H "content-type: application/json" ` + - `-d "{\\"sha\\":\\"${sha}\\",\\"tagHash\\":\\"${tagHash}\\"}" ` + - `|| echo "release publish failed (apk is still uploaded at ${uploadBase}/sleek.apk)"`, + "buck2 build --show-output //:sleek-host 2>&1 | tee /tmp/buck2-build-host.log", + "host_path=$(grep '^root//:sleek-host ' /tmp/buck2-build-host.log | awk '{print $2}')", + '[ -n "$host_path" ] && [ -f "$host_path" ] || { echo "buck2 build did not produce //:sleek-host output"; exit 1; }', + `curl -fsS -X PUT "${uploadBase}/sleek-x86_64-linux" -H "Authorization: Bearer ${env.UPLOAD_TOKEN}" --data-binary @"$host_path"`, + publishStep("sleek-x86_64-linux"), ); } return steps.join("\n"); @@ -464,11 +487,20 @@ function atprotoBytes(rawBytes) { // uploadBlob + createRecord against nandi's own PDS, authenticated with the // stored OAuth session. Throws on any failure — caller decides what to do // with that (the apk itself is already safely in R2 by the time this runs). -async function publishTangledArtifact(session, { apkBytes, filename, tagHashHex }) { +function contentTypeForArtifact(filename) { + // Only the two filenames buildScript() ever actually produces need real + // entries — application/octet-stream (a generic "just bytes, + // browser/client should offer Save As" type) is a fine fallback for + // anything else published this way in the future. + if (filename.endsWith(".apk")) return "application/vnd.android.package-archive"; + return "application/octet-stream"; +} + +async function publishTangledArtifact(session, { bytes, filename, tagHashHex }) { const uploadResp = await dpopFetch(`${session.pds}/xrpc/com.atproto.repo.uploadBlob`, { method: "POST", - headers: { "content-type": "application/vnd.android.package-archive" }, - body: apkBytes, + headers: { "content-type": contentTypeForArtifact(filename) }, + body: bytes, dpopKeys: session.dpopKeys, accessToken: session.accessToken, }); @@ -507,12 +539,16 @@ async function handlePublishRelease(request, env, url) { } catch { return new Response("bad json", { status: 400 }); } - const { sha, tagHash } = body; + // filename defaults to sleek.apk for backward compatibility with the + // original single-artifact shape of this endpoint — buildScript() now + // always sends it explicitly (both for the apk and for sleek-x86_64-linux, + // the desktop host binary). + const { sha, tagHash, filename = "sleek.apk" } = body; if (!sha || !tagHash) return new Response("missing sha/tagHash", { status: 400 }); - const apkObj = await env.ARTIFACTS.get(`${sha}/sleek.apk`); - if (!apkObj) return new Response(`no artifact stored for ${sha}/sleek.apk`, { status: 404 }); - const apkBytes = await new Response(apkObj.body).arrayBuffer(); + const obj = await env.ARTIFACTS.get(`${sha}/${filename}`); + if (!obj) return new Response(`no artifact stored for ${sha}/${filename}`, { status: 404 }); + const bytes = await new Response(obj.body).arrayBuffer(); const session = await getAtprotoSession(env); if (!session) { @@ -522,15 +558,20 @@ async function handlePublishRelease(request, env, url) { ); } + // Keyed by filename, not just tagName — a tag push now publishes two + // artifacts (sleek.apk and sleek-x86_64-linux), and a single + // `releases/.json` would have the second call's result silently + // clobber the first's. + const recordKey = `releases/${tagName}/${filename}.json`; try { - const result = await publishTangledArtifact(session, { apkBytes, filename: "sleek.apk", tagHashHex: tagHash }); - await env.ARTIFACTS.put(`releases/${tagName}.json`, JSON.stringify({ - tagName, sha, tagHash, publishedAt: new Date().toISOString(), record: result, + const result = await publishTangledArtifact(session, { bytes, filename, tagHashHex: tagHash }); + await env.ARTIFACTS.put(recordKey, JSON.stringify({ + tagName, sha, tagHash, filename, publishedAt: new Date().toISOString(), record: result, })); return new Response(JSON.stringify(result), { headers: { "content-type": "application/json" } }); } catch (e) { - await env.ARTIFACTS.put(`releases/${tagName}.json`, JSON.stringify({ - tagName, sha, tagHash, failedAt: new Date().toISOString(), error: e.message, + await env.ARTIFACTS.put(recordKey, JSON.stringify({ + tagName, sha, tagHash, filename, failedAt: new Date().toISOString(), error: e.message, })); return new Response(`publish failed: ${e.message}`, { status: 502 }); }