From 8cdc20053dc40b40d5cc739f1bd02cd027b22733 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Wed, 30 Sep 2026 14:30:44 +0200 Subject: [PATCH] feat(scripts): open an issue when a Linux Spotify build has no classmap A daily workflow reads Spotify's apt channels, statically verifies the newest older key against each unpublished build's stock CSS, and opens an issue that says whether to publish an inherited key or migrate. --- .github/workflows/watch-spotify.yml | 42 ++++++ README.md | 12 ++ scripts/watch-spotify.test.mts | 59 ++++++++ scripts/watch-spotify.ts | 202 ++++++++++++++++++++++++++++ 4 files changed, 315 insertions(+) create mode 100644 .github/workflows/watch-spotify.yml create mode 100644 scripts/watch-spotify.test.mts create mode 100644 scripts/watch-spotify.ts 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; + }, + ); +} +