From cc913d169c11d1f5d37a1ba7d3a04d631610afb1 Mon Sep 17 00:00:00 2001 From: Janghoon Lee <44862514+its-janghoon@users.noreply.github.com> Date: Tue, 13 Oct 2026 06:55:12 +0000 Subject: [PATCH] Ship the Redrob Launcher release manifest with every release Every GitHub Release now carries `redrob-launcher.json`, exactly `{ "schema": 1, "sharedAccount": true }`, so Redrob Launcher learns from the release itself that this build reads the launcher's shared account file (#88) and can show "Uses this sign-in" without a launcher release. - `launcher-assets.ts` writes the manifest beside the launcher installers and, at the end of its run, verifies the release directory: every launcher asset present, manifest present, valid JSON, schema 1, sharedAccount true. A directory without the file fails the step. - The publish job's Checksums step lists the manifest in SHA256SUMS (the launcher's quota-free path reads the sums file for asset names) and fails if it is absent from the list. - `launcher-assets.test.ts` pins the bytes, parses them, and drives the verifier through missing / wrong-schema / false-flag / non-JSON cases. No release, tag or version change. --- .github/workflows/release.yml | 10 ++- packages/redrob/script/launcher-assets.ts | 67 ++++++++++++++++++- .../test/installation/launcher-assets.test.ts | 55 ++++++++++++++- 3 files changed, 128 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a00aa5d9bf..4ee4c6685b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -644,6 +644,11 @@ jobs: # them verbatim -- and the launcher's are added beside them. See the script's header for the # layout inside; `test/installation/launcher-assets.test.ts` guards both. # + # The same step writes `redrob-launcher.json`, the release manifest the launcher reads to + # learn this release uses its shared account file (`{ "schema": 1, "sharedAccount": true }`), + # and refuses to finish if the release directory lacks it. It is checksummed below like + # every other asset so the launcher's quota-free path, which lists SHA256SUMS, sees it. + # # Bun alone, without the repository's dependencies: the script imports nothing but Bun and # Node built-ins, and `bun install` here would cost minutes for nothing. - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 @@ -668,11 +673,12 @@ jobs: # and a release without it must still produce checksums for everything else. set -- redrob-code-x64-setup.exe if [ -e "$1" ]; then - sha256sum redrob-*.zip redrob-*.tar.gz redrob-code-x64-setup.exe > SHA256SUMS + sha256sum redrob-*.zip redrob-*.tar.gz redrob-launcher.json redrob-code-x64-setup.exe > SHA256SUMS else echo "::warning::no installer present; SHA256SUMS will not cover one" - sha256sum redrob-*.zip redrob-*.tar.gz > SHA256SUMS + sha256sum redrob-*.zip redrob-*.tar.gz redrob-launcher.json > SHA256SUMS fi + grep -q ' redrob-launcher.json$' SHA256SUMS || { echo "::error::SHA256SUMS does not list redrob-launcher.json"; exit 1; } cat SHA256SUMS # The marker install.sh and `redrob upgrade` read to answer "what is the newest diff --git a/packages/redrob/script/launcher-assets.ts b/packages/redrob/script/launcher-assets.ts index 7848e7ed6c..1bba4eec5d 100644 --- a/packages/redrob/script/launcher-assets.ts +++ b/packages/redrob/script/launcher-assets.ts @@ -32,6 +32,10 @@ * `--unsigned` is the build artifact (`redrob--/bin/redrob` directories); `--signed` * holds the signed macOS and Windows zips; `--out` receives the launcher assets. Exits non-zero * if any expected input is missing, so a release cannot quietly ship without a platform. + * + * Beside the installers it writes `redrob-launcher.json`, the release manifest the launcher reads + * to learn that this release uses the launcher's shared account (`packages/core/src/shared-account.ts`), + * and `verifyReleaseDirectory` refuses a release directory that lacks it or carries a wrong one. */ import fs from "node:fs" import path from "node:path" @@ -40,6 +44,59 @@ import { $ } from "bun" /** The stem Redrob Launcher expects for this app: the repository name. */ export const STEM = "redrob-code" +/** + * The release manifest the launcher reads beside the installers (the launcher's + * `docs/release-manifest.md`). `schema` is 1; `sharedAccount` is true because every release from + * here on reads the launcher's shared account file as its last console-key fallback. + */ +export const MANIFEST_NAME = "redrob-launcher.json" +export const MANIFEST = { schema: 1, sharedAccount: true } as const + +/** The manifest's bytes, exactly as the launcher contract spells them, newline-terminated. */ +export function manifestText(): string { + return `{ "schema": ${MANIFEST.schema}, "sharedAccount": ${MANIFEST.sharedAccount} }\n` +} + +/** Writes the manifest into `out` and returns its path. */ +export function writeManifest(out: string): string { + fs.mkdirSync(out, { recursive: true }) + const dest = path.join(out, MANIFEST_NAME) + fs.writeFileSync(dest, manifestText()) + return dest +} + +/** + * Checks a release directory is one the launcher can use: every launcher installer for `version` + * is present and `redrob-launcher.json` exists, is JSON, is schema 1 and says `sharedAccount: true`. + * Returns the problems found, empty when the directory passes. + */ +export function verifyReleaseDirectory(dir: string, version: string): string[] { + const problems: string[] = [] + for (const name of expectedLauncherAssets(version)) { + if (!fs.existsSync(path.join(dir, name))) problems.push(`missing launcher asset ${name}`) + } + const manifest = path.join(dir, MANIFEST_NAME) + if (!fs.existsSync(manifest)) { + problems.push(`missing ${MANIFEST_NAME}`) + return problems + } + let parsed: unknown + try { + parsed = JSON.parse(fs.readFileSync(manifest, "utf8")) + } catch (error) { + problems.push(`${MANIFEST_NAME} is not JSON: ${error instanceof Error ? error.message : String(error)}`) + return problems + } + const record = (typeof parsed === "object" && parsed !== null ? parsed : {}) as Record + if (record.schema !== MANIFEST.schema) + problems.push(`${MANIFEST_NAME} schema is ${JSON.stringify(record.schema)}, expected ${MANIFEST.schema}`) + if (record.sharedAccount !== MANIFEST.sharedAccount) + problems.push( + `${MANIFEST_NAME} sharedAccount is ${JSON.stringify(record.sharedAccount)}, expected ${MANIFEST.sharedAccount}`, + ) + return problems +} + /** * Build output directory -> launcher os-arch token. Only the variants the launcher has a token * for; see `Platform::tokens` in the launcher's `crates/core/src/platform.rs`. @@ -107,7 +164,10 @@ export interface Inputs { out: string } -/** Produces every launcher asset into `out`. Returns the names written, in TARGETS order. */ +/** + * Produces every launcher asset into `out`, then the manifest. Returns the installer names + * written, in TARGETS order; the manifest is not an installer and is not among them. + */ export async function pack(inputs: Inputs): Promise { const written: string[] = [] fs.mkdirSync(inputs.out, { recursive: true }) @@ -137,6 +197,7 @@ export async function pack(inputs: Inputs): Promise { } written.push(name) } + writeManifest(inputs.out) return written } @@ -154,4 +215,8 @@ if (import.meta.main) { if (!isLauncherAssetName(name)) throw new Error(`produced a name the launcher cannot parse: ${name}`) console.log(name) } + console.log(MANIFEST_NAME) + const problems = verifyReleaseDirectory(inputs.out, inputs.version) + if (problems.length) + throw new Error(`release directory ${inputs.out} is not launcher-ready:\n ${problems.join("\n ")}`) } diff --git a/packages/redrob/test/installation/launcher-assets.test.ts b/packages/redrob/test/installation/launcher-assets.test.ts index 75cdf23915..d345be5881 100644 --- a/packages/redrob/test/installation/launcher-assets.test.ts +++ b/packages/redrob/test/installation/launcher-assets.test.ts @@ -4,12 +4,17 @@ import os from "node:os" import path from "node:path" import { $ } from "bun" import { + MANIFEST, + MANIFEST_NAME, STEM, TARGETS, expectedLauncherAssets, isLauncherAssetName, launcherAssetName, + manifestText, pack, + verifyReleaseDirectory, + writeManifest, } from "../../script/launcher-assets" /** @@ -78,7 +83,9 @@ describe("launcher asset names", () => { const names = await pack({ version: "0.5.1", unsigned, signed, out }) expect(names.sort()).toEqual(expectedLauncherAssets("0.5.1").sort()) - expect(fs.readdirSync(out).sort()).toEqual(expectedLauncherAssets("0.5.1").sort()) + expect(fs.readdirSync(out).sort()).toEqual([...expectedLauncherAssets("0.5.1"), MANIFEST_NAME].sort()) + expect(fs.readFileSync(path.join(out, MANIFEST_NAME), "utf8")).toBe(manifestText()) + expect(verifyReleaseDirectory(out, "0.5.1")).toEqual([]) // The launcher unpacks into a single top folder and looks for `bin/`. const listing = await $`tar -tzvf ${path.join(out, "redrob-code-0.5.1-linux-x86_64.tar.gz")}`.text() @@ -98,6 +105,52 @@ describe("launcher asset names", () => { } }) + /** + * `redrob-launcher.json` is how the launcher learns, without a launcher release, that this release + * reads its shared account file (the launcher's `docs/release-manifest.md`). Exactly `{ "schema": 1, + * "sharedAccount": true }`: a wrong schema is ignored by the launcher and a false flag hides the + * sign-in, both silently, so the bytes are pinned here. + */ + describe("release manifest", () => { + test("is the launcher contract, verbatim", () => { + expect(MANIFEST_NAME).toBe("redrob-launcher.json") + expect(manifestText()).toBe('{ "schema": 1, "sharedAccount": true }\n') + const parsed = JSON.parse(manifestText()) + expect(parsed).toEqual({ schema: 1, sharedAccount: true }) + expect(parsed.schema).toBe(1) + expect(parsed.sharedAccount).toBe(true) + expect(MANIFEST).toEqual({ schema: 1, sharedAccount: true }) + }) + + test("a release directory without it fails the check", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "launcher-manifest-")) + try { + for (const name of expectedLauncherAssets("0.5.1")) fs.writeFileSync(path.join(root, name), name) + expect(verifyReleaseDirectory(root, "0.5.1")).toEqual([`missing ${MANIFEST_NAME}`]) + + writeManifest(root) + expect(verifyReleaseDirectory(root, "0.5.1")).toEqual([]) + + fs.writeFileSync(path.join(root, MANIFEST_NAME), '{ "schema": 2, "sharedAccount": false }\n') + expect(verifyReleaseDirectory(root, "0.5.1")).toEqual([ + `${MANIFEST_NAME} schema is 2, expected 1`, + `${MANIFEST_NAME} sharedAccount is false, expected true`, + ]) + + fs.writeFileSync(path.join(root, MANIFEST_NAME), "not json") + expect(verifyReleaseDirectory(root, "0.5.1")[0]).toMatch(/is not JSON/) + + fs.rmSync(path.join(root, "redrob-code-0.5.1-linux-x86_64.tar.gz")) + writeManifest(root) + expect(verifyReleaseDirectory(root, "0.5.1")).toEqual([ + "missing launcher asset redrob-code-0.5.1-linux-x86_64.tar.gz", + ]) + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } + }) + }) + unix("refuses to pack when a platform's build output is missing", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "launcher-assets-")) try {