diff --git a/.github/workflows/watch-spotify.yml b/.github/workflows/watch-spotify.yml new file mode 100644 index 0000000..74312cc --- /dev/null +++ b/.github/workflows/watch-spotify.yml @@ -0,0 +1,42 @@ +name: Watch Spotify releases + +on: + schedule: + - cron: "17 6 * * *" + workflow_dispatch: + +permissions: + contents: read + issues: write + +jobs: + watch: + name: Open an issue for Linux builds without a classmap + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Checkout the CLI tooling + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: spicetify/cli + ref: v3-beta + path: cli + + - name: Setup pnpm + uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + + - name: Setup Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Check the Linux channels + env: + GH_TOKEN: ${{ github.token }} + run: node scripts/watch-spotify.ts --cli cli diff --git a/README.md b/README.md index 7946e89..607dc32 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,18 @@ css-map renames to the same name. An inherited key also keeps its source's a live hit clears them. A derived key tracks the `required_paths` of `--derived-from`, of `--required-paths-from`, or of the newest verified key. +### New Spotify releases + +`.github/workflows/watch-spotify.yml` checks Spotify's Linux apt channels every +day. For each build without a key, it downloads the package, statically +verifies the newest older key's map against its stock CSS, and opens an issue. +The issue says whether the map still fits, in which case you publish an +inherited key, or which leaves lost a hashed class, in which case you migrate. +macOS and Windows builds aren't published anywhere a job can read without a +signed-in client, so those still need someone to notice them. To re-run the +check for a published build, use +`node scripts/watch-spotify.ts --cli ../cli --dry-run --only `. + ## Checks `pnpm check` runs everything CI runs: the type check, the index check, the key diff --git a/scripts/watch-spotify.test.mts b/scripts/watch-spotify.test.mts new file mode 100644 index 0000000..d363b3b --- /dev/null +++ b/scripts/watch-spotify.test.mts @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { arMembers, assess, baseKey, parsePackages, unpublished } from "./watch-spotify.ts"; + +// Abridged from https://repository.spotify.com/dists/testing/non-free/binary-amd64/Packages. +const PACKAGES = `Package: spotify-client +Version: 1:1.2.96.518.g366879e1 +Filename: pool/non-free/s/spotify-client/spotify-client_1.2.96.518.g366879e1_amd64.deb +SHA256: 1a86581f53c2b0e2c406150665ea05e245b9e6b286390ea61d5d193ba617eb2f + +Package: spotify-client-0.9.17 +Version: 0.9.17.8.gd06432d.31-1 +Filename: pool/non-free/s/spotify/spotify-client-0.9.17_0.9.17.8.gd06432d.31-1_amd64.deb +SHA256: 0cec1535d1c656452c6358dc244e013c761c9a329b4536ce853b0a007ce73cc6 +`; + +test("reads the spotify-client build from an apt index", () => { + assert.deepEqual(parsePackages(PACKAGES, "testing"), [ + { + version: "1.2.96.518", + key: "1020096", + channel: "testing", + url: "https://repository.spotify.com/pool/non-free/s/spotify-client/spotify-client_1.2.96.518.g366879e1_amd64.deb", + sha256: "1a86581f53c2b0e2c406150665ea05e245b9e6b286390ea61d5d193ba617eb2f", + }, + ]); +}); + +test("reports each unpublished key once", () => { + const [build] = parsePackages(PACKAGES, "testing"); + assert.deepEqual(unpublished([build, { ...build, channel: "stable" }], ["1020095"]), [build]); + assert.deepEqual(unpublished([build], ["1020096"]), []); +}); + +test("compares against the newest older key of the same minor", () => { + assert.equal(baseKey(["1020095", "1020099", "1030000", "1030001"], "1030002"), "1030001"); + assert.equal(baseKey(["1020095", "1020099"], "1030000"), "1020099"); + assert.equal(baseKey(["1020099", "1030001"], "1020097"), undefined); +}); + +test("a missing hashed class means a migration, unless the base already knew", () => { + const rows = [ + { path: "main.topbar.wrapper", class: "topbarHashAA", in_target_css: true, missing_classes: [] }, + { path: "settings.text_input", class: "e-10860-form-input", in_target_css: false, missing_classes: ["e-10860-form-input"] }, + { path: "main.topbar.retired", class: "retiredHashBB", in_target_css: false, missing_classes: ["retiredHashBB"] }, + ]; + assert.deepEqual(assess(rows), { unchanged: false, rehashed: [{ path: "main.topbar.retired", classes: ["retiredHashBB"] }] }); + assert.deepEqual(assess(rows, new Set(["main.topbar.retired"])), { unchanged: true, rehashed: [] }); +}); + +test("reads GNU ar members with padding and slash-terminated names", () => { + const header = (name: string, size: number) => + Buffer.from(`${name}/`.padEnd(16) + "0".padEnd(12) + "0".padEnd(6) + "0".padEnd(6) + "100644".padEnd(8) + String(size).padEnd(10) + "`\n"); + const archive = Buffer.concat([Buffer.from("!\n"), header("debian-binary", 4), Buffer.from("2.0\n"), header("data.tar.gz", 3), Buffer.from("abc\n")]); + const members = arMembers(archive); + assert.deepEqual([...members.keys()], ["debian-binary", "data.tar.gz"]); + assert.equal(members.get("data.tar.gz")?.toString(), "abc"); +}); diff --git a/scripts/watch-spotify.ts b/scripts/watch-spotify.ts new file mode 100644 index 0000000..cd7aaa7 --- /dev/null +++ b/scripts/watch-spotify.ts @@ -0,0 +1,202 @@ +// Watches Spotify's Linux apt channels for builds that have no classmap key, +// statically verifies the newest key's map against each one, and opens an +// issue saying whether the build looks unchanged (publish an inherited key) +// or rehashed (migrate). +// +// node scripts/watch-spotify.ts --cli ../cli --dry-run +// node scripts/watch-spotify.ts --cli ../cli # opens issues with gh +// node scripts/watch-spotify.ts --cli ../cli --dry-run --only 1020096 # re-assess a published build +// +// Only Linux builds are public without a signed-in client; macOS and Windows +// releases still need a person to notice them. + +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { parseArgs } from "node:util"; + +import { isHashLike, keyDirs, readUtf8, ROOT, sha256, versionToKey } from "./lib.ts"; + +const REPOSITORY = "https://repository.spotify.com"; +const CHANNELS = ["stable", "testing"]; + +export type Build = { version: string; key: string; channel: string; url: string; sha256: string }; +type StaticRow = { path: string; class: string; in_target_css: boolean; missing_classes?: string[] }; + +/** The spotify-client entries of an apt Packages index. */ +export function parsePackages(text: string, channel: string): Build[] { + return text + .split(/\n\n+/) + .map((stanza) => Object.fromEntries(stanza.split("\n").map((line) => [line.slice(0, line.indexOf(":")), line.slice(line.indexOf(":") + 1).trim()]))) + .filter((fields) => fields.Package === "spotify-client" && fields.Version && fields.Filename) + .map((fields) => { + // "1:1.2.96.518.g366879e1" is reported by the client as 1.2.96.518. + const version = fields.Version.replace(/^\d+:/, "").split(".").slice(0, 4).join("."); + return { version, key: versionToKey(version), channel, url: `${REPOSITORY}/${fields.Filename}`, sha256: fields.SHA256 }; + }); +} + +/** Builds whose key is not published, one per key. */ +export function unpublished(builds: Build[], published: string[]): Build[] { + const byKey = new Map(); + for (const build of builds) if (!published.includes(build.key) && !byKey.has(build.key)) byKey.set(build.key, build); + return [...byKey.values()].sort((a, b) => a.key.localeCompare(b.key)); +} + +/** The key to compare against: the newest older key of the same minor, else the newest older key. */ +export function baseKey(published: string[], key: string): string | undefined { + const older = published.filter((k) => k < key).sort(); + return older.filter((k) => k.slice(0, 3) === key.slice(0, 3)).at(-1) ?? older.at(-1); +} + +/** + * Whether the base map still fits: a hashed class missing from the target + * CSS means Spotify rehashed that component, so the map needs a migration. + * Leaves the base key already records as stale or unverified don't count. + */ +export function assess(rows: StaticRow[], known: Set = new Set()): { unchanged: boolean; rehashed: { path: string; classes: string[] }[] } { + const rehashed = rows + .filter((row) => !known.has(row.path)) + .map((row) => ({ path: row.path, classes: (row.missing_classes ?? []).filter(isHashLike) })) + .filter((row) => row.classes.length); + return { unchanged: rehashed.length === 0, rehashed }; +} + +export function issueTitle(build: Build): string { + return `Spotify ${build.version.split(".").slice(0, 3).join(".")} needs a classmap (${build.key})`; +} + +export function issueBody(build: Build, base: string, rows: StaticRow[], known: Set = new Set()): string { + const { unchanged, rehashed } = assess(rows, known); + const present = rows.filter((row) => row.in_target_css).length; + const lines = [ + `Spotify \`${build.version}\` is on the Linux \`${build.channel}\` channel, and no classmap key \`${build.key}\` is published.`, + "", + `Static verification of the \`${base}\` map against its stock CSS found ${present}/${rows.length} leaves.`, + "", + ]; + if (unchanged) { + lines.push( + `No hashed class is missing, so \`${base}\`'s map most likely still fits. Publish an inherited key after a deep CDP run on this build:`, + "", + "```sh", + `pnpm publish-key --inherit-from ${base} --spotify-version ${build.version} \\`, + ` --static-report static.json --cdp-report cdp.json`, + "```", + ); + } else { + lines.push( + `${rehashed.length} leaves lost a hashed class, so Spotify rehashed them and the map needs a migration:`, + "", + ...rehashed.map((row) => `- \`${row.path}\`: ${row.classes.map((c) => `\`${c}\``).join(", ")}`), + "", + "Follow “Changed classes” in the README.", + ); + } + lines.push("", `Package: ${build.url} (SHA-256 \`${build.sha256}\`)`); + return `${lines.join("\n")}\n`; +} + +/** The members of an ar archive (a .deb), by name. */ +export function arMembers(archive: Buffer): Map { + if (archive.toString("latin1", 0, 8) !== "!\n") throw new Error("not an ar archive"); + const members = new Map(); + let offset = 8; + while (offset + 60 <= archive.length) { + const name = archive.toString("latin1", offset, offset + 16).trim().replace(/\/$/, ""); + const size = Number(archive.toString("latin1", offset + 48, offset + 58).trim()); + members.set(name, archive.subarray(offset + 60, offset + 60 + size)); + offset += 60 + size + (size % 2); + } + return members; +} + +async function fetchText(url: string): Promise { + const response = await fetch(url); + if (!response.ok) throw new Error(`${url}: HTTP ${response.status}`); + return response.text(); +} + +async function staticRows(build: Build, base: string, cli: string, work: string): Promise { + const bytes = Buffer.from(await (await fetch(build.url)).arrayBuffer()); + if (sha256(bytes) !== build.sha256) throw new Error(`${build.url}: SHA-256 does not match the Packages index`); + const data = [...arMembers(bytes)].find(([name]) => name.startsWith("data.tar")); + if (!data) throw new Error(`${build.url}: no data archive in the package`); + writeFileSync(path.join(work, data[0]), data[1]); + execFileSync("tar", ["-xf", data[0], "./usr/share/spotify/Apps/xpui.spa"], { cwd: work }); + const report = path.join(work, "static.json"); + execFileSync( + process.execPath, + [ + path.join(cli, "scripts/classmap-capture.ts"), + "verify", + "--classmap", + path.join(ROOT, base, "classmap.json"), + "--css-map", + path.join(cli, "css-map.json"), + "--target-spa", + path.join(work, "usr/share/spotify/Apps/xpui.spa"), + "--target-version", + build.version, + "--out", + report, + ], + { stdio: "ignore" }, + ); + return JSON.parse(readUtf8(report)).rows; +} + +function issueExists(title: string): boolean { + const found = execFileSync("gh", ["issue", "list", "--state", "all", "--search", `in:title "${title}"`, "--json", "title"], { + encoding: "utf8", + }); + return (JSON.parse(found) as { title: string }[]).some((issue) => issue.title === title); +} + +async function main(): Promise { + const { values } = parseArgs({ + options: { cli: { type: "string" }, "dry-run": { type: "boolean", default: false }, only: { type: "string" } }, + }); + if (!values.cli) throw new Error("--cli is required"); + const published = keyDirs(ROOT).filter((key) => key !== values.only); + const builds: Build[] = []; + for (const channel of CHANNELS) { + // eslint-disable-next-line no-await-in-loop + builds.push(...parsePackages(await fetchText(`${REPOSITORY}/dists/${channel}/non-free/binary-amd64/Packages`), channel)); + } + const pending = unpublished(builds, published).filter((build) => !values.only || build.key === values.only); + if (!pending.length) console.log(`every Linux build has a key (${builds.map((b) => b.key).join(", ")})`); + for (const build of pending) { + const title = issueTitle(build); + if (!values["dry-run"] && issueExists(title)) { + console.log(`${title}: issue already exists`); + continue; + } + const base = baseKey(published, build.key); + if (!base) throw new Error(`no published key older than ${build.key}`); + const work = mkdtempSync(path.join(tmpdir(), "spotify-watch-")); + try { + // eslint-disable-next-line no-await-in-loop + const meta = JSON.parse(readUtf8(path.join(ROOT, base, "META.json"))); + const known = new Set([...meta.stale_leaves, ...meta.unverified_leaves]); + const body = issueBody(build, base, await staticRows(build, base, values.cli, work), known); + if (values["dry-run"]) console.log(`# ${title}\n\n${body}`); + else execFileSync("gh", ["issue", "create", "--title", title, "--body", body], { stdio: "inherit" }); + } finally { + rmSync(work, { recursive: true, force: true }); + } + } + return 0; +} + +if (import.meta.main) { + main().then( + (code) => (process.exitCode = code), + (e: Error) => { + console.error(`error: ${e.message}`); + process.exitCode = 1; + }, + ); +} +