Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
67 changes: 66 additions & 1 deletion packages/redrob/script/launcher-assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@
* `--unsigned` is the build artifact (`redrob-<os>-<arch>/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"
Expand All @@ -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<string, unknown>
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`.
Expand Down Expand Up @@ -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<string[]> {
const written: string[] = []
fs.mkdirSync(inputs.out, { recursive: true })
Expand Down Expand Up @@ -137,6 +197,7 @@ export async function pack(inputs: Inputs): Promise<string[]> {
}
written.push(name)
}
writeManifest(inputs.out)
return written
}

Expand All @@ -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 ")}`)
}
55 changes: 54 additions & 1 deletion packages/redrob/test/installation/launcher-assets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

/**
Expand Down Expand Up @@ -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/<app-id>`.
const listing = await $`tar -tzvf ${path.join(out, "redrob-code-0.5.1-linux-x86_64.tar.gz")}`.text()
Expand All @@ -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 {
Expand Down
Loading