diff --git a/plugin.json b/plugin.json index b97e3fb..d7f84ce 100644 --- a/plugin.json +++ b/plugin.json @@ -3,8 +3,8 @@ "name": "cs2tracker-extension", "common_name": "CS2Tracker Extension", "description": "Adds CS2Tracker profile links to Steam profiles and friend lists.", - "thumbnail": "https://raw.githubusercontent.com/MuhammedResulBilkil/cs2tracker-extension/master/assets/thumbnail.png", - "splash_image": "https://raw.githubusercontent.com/MuhammedResulBilkil/cs2tracker-extension/master/assets/splash.png", + "thumbnail": "https://raw.githubusercontent.com/MuhammedResulBilkil/cs2tracker-extension/v1.0.0/assets/thumbnail.png", + "splash_image": "https://raw.githubusercontent.com/MuhammedResulBilkil/cs2tracker-extension/v1.0.0/assets/splash.png", "version": "1.0.0", "backendType": "lua", "webkitApiVersion": "2.0.0" diff --git a/scripts/sync-version.ts b/scripts/sync-version.ts index cb2e48f..f5177c6 100644 --- a/scripts/sync-version.ts +++ b/scripts/sync-version.ts @@ -1,23 +1,37 @@ /** - * Keeps `package.json` and `plugin.json` on one version, and asserts that they are. + * Keeps `package.json` and `plugin.json` on one version, keeps the store's images pinned to that + * version, and asserts that both hold. * * Two files carry the plugin's version and nothing in the build forces them to match: * `package.json` is what npm-shaped tooling reads, `plugin.json` is what Millennium and the store * read and what users see in the plugin list. `semantic-release` calls this script from its prepare * step so a release writes both at once. * + * The same step re-pins `thumbnail` and `splash_image`. Millennium's schema requires an absolute + * URL for both, so a submodule pin in the plugin database does not cover them: the store fetches + * whatever those URLs resolve to at the moment someone opens the listing. Left on a branch ref they + * stay mutable after review, which means the images a reviewer approved need not be the images a + * user is shown. Rewriting them here — in the same prepare step, from the same version — is what + * makes the reviewed asset and the displayed asset the same file, without adding a release step + * anyone has to remember. + * + * The rewrite is safe against the chicken-and-egg it looks like it has. `@semantic-release/git` + * commits `plugin.json` during prepare and semantic-release tags *that* commit, so the tag named by + * these URLs is the tag they are committed under, and `assets/` exists there. + * * Two modes: * - * tsx scripts/sync-version.ts 1.2.3 write that version to both files - * tsx scripts/sync-version.ts --check assert the two already agree, writing nothing + * tsx scripts/sync-version.ts 1.2.3 write that version to both files and re-pin the images + * tsx scripts/sync-version.ts --check assert the files already agree, writing nothing * - * The `--check` mode runs in CI, because the failure this guards against is a hand-edit to one file - * that never reaches the other. That drift is silent — the plugin builds and loads fine, and the - * wrong number only surfaces in the store listing. + * The `--check` mode runs in CI, because the failures this guards against are silent: a hand-edit + * to one file that never reaches the other, or an image URL nudged back onto a branch ref. Both + * build and load fine, and only surface in the store listing. */ import { readFileSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; /** The version-bearing files, each with the indentation it is written back with. */ const FILES = [ @@ -25,6 +39,20 @@ const FILES = [ ['plugin.json', '\t'], ] as const; +/** The `plugin.json` fields the store renders as images, and therefore fetches at display time. */ +export const PINNED_IMAGE_FIELDS = ['thumbnail', 'splash_image'] as const; + +/** + * `https://raw.githubusercontent.com////`, split so the ref can be replaced + * without touching either side of it. + * + * Owner and repo are matched rather than hardcoded, so a fork re-pins to its own tags instead of + * silently serving this repository's images. The pattern is only ever applied to + * `PINNED_IMAGE_FIELDS`, which is what keeps it away from `$schema` — also a + * raw.githubusercontent.com URL, and one that is *meant* to track Millennium's `main`. + */ +const RAW_ASSET_URL = /^(https:\/\/raw\.githubusercontent\.com\/[^/]+\/[^/]+\/)([^/]+)(\/.+)$/; + /** * Plain semver, no `v` prefix. `semantic-release` hands over exactly this, so anything else is a * mistake worth stopping on — most plausibly an uninterpolated `${nextRelease.version}` escaping @@ -34,6 +62,27 @@ const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; const root = process.cwd(); +/** The git ref a release of `version` is tagged as, and therefore the ref its images pin to. */ +export function refForVersion(version: string): string { + return `v${version}`; +} + +/** The ref segment of a raw.githubusercontent.com URL, or `null` if it is not one. */ +export function refOfAssetUrl(url: string): string | null { + return RAW_ASSET_URL.exec(url)?.[2] ?? null; +} + +/** + * The same URL with its ref segment replaced by `version`'s tag. Throws rather than returning the + * input unchanged: a URL this cannot parse is one the release would otherwise leave mutable, and + * silently doing nothing is the failure this whole mechanism exists to prevent. + */ +export function repinAssetUrl(url: string, version: string): string { + const parts = RAW_ASSET_URL.exec(url); + if (!parts) throw new Error(`not a raw.githubusercontent.com asset URL: ${JSON.stringify(url)}`); + return `${parts[1]}${refForVersion(version)}${parts[3]}`; +} + function fail(message: string): never { console.error(`sync-version: ${message}`); process.exit(1); @@ -48,7 +97,8 @@ function load(file: string): Record { } /** - * Reads both files and fails unless they carry one well-formed version. Returns it. + * Reads both files and fails unless they carry one well-formed version and the store images are + * pinned to it. Returns the version. * * Deliberately re-reads from disk rather than trusting what was just written: the point of the * check is the state of the working tree, not the intent of this process. @@ -69,25 +119,65 @@ function assertAgreement(): string { '\nrun `pnpm exec tsx scripts/sync-version.ts ` to bring them back in line', ); } + + const manifest = load('plugin.json'); + const want = refForVersion(expected); + for (const field of PINNED_IMAGE_FIELDS) { + const url = manifest[field]; + if (typeof url !== 'string') fail(`plugin.json has no string "${field}" field`); + + const ref = refOfAssetUrl(url); + if (ref === null) fail(`plugin.json "${field}" is not a raw.githubusercontent.com URL: ${JSON.stringify(url)}`); + if (ref !== want) { + fail( + `plugin.json "${field}" is pinned to ${JSON.stringify(ref)}, not ${JSON.stringify(want)}:\n` + + ` ${url}\n` + + 'the store fetches this URL when the listing is opened, so a branch ref lets the image\n' + + 'change after review — run `pnpm exec tsx scripts/sync-version.ts ` to re-pin it', + ); + } + } + return expected; } -const argument = process.argv[2]; -if (!argument) fail('usage: tsx scripts/sync-version.ts '); +function main(): void { + const argument = process.argv[2]; + if (!argument) fail('usage: tsx scripts/sync-version.ts '); + + if (argument === '--check') { + const version = assertAgreement(); + console.log(`package.json and plugin.json agree on ${version}, images pinned to ${refForVersion(version)}`); + return; + } -if (argument === '--check') { - console.log(`package.json and plugin.json agree on ${assertAgreement()}`); -} else { if (!SEMVER.test(argument)) fail(`refusing to write a version that is not plain semver: ${JSON.stringify(argument)}`); for (const [file, indent] of FILES) { const parsed = load(file); parsed.version = argument; + + if (file === 'plugin.json') { + for (const field of PINNED_IMAGE_FIELDS) { + const url = parsed[field]; + if (typeof url !== 'string') fail(`plugin.json has no string "${field}" field`); + try { + parsed[field] = repinAssetUrl(url, argument); + } catch (error) { + fail(String(error instanceof Error ? error.message : error)); + } + } + } + writeFileSync(join(root, file), `${JSON.stringify(parsed, null, indent)}\n`, 'utf8'); console.log(`updated ${file} to ${argument}`); } const written = assertAgreement(); if (written !== argument) fail(`asked for ${argument} but the files now read ${written}`); - console.log(`package.json and plugin.json agree on ${written}`); + console.log(`package.json and plugin.json agree on ${written}, images pinned to ${refForVersion(written)}`); } + +// Only run the CLI when this file is the entrypoint, so the helpers above stay importable by tests. +const entrypoint = process.argv[1]; +if (entrypoint !== undefined && resolve(entrypoint) === fileURLToPath(import.meta.url)) main(); diff --git a/tests/store-image-pins.test.ts b/tests/store-image-pins.test.ts new file mode 100644 index 0000000..7eb184d --- /dev/null +++ b/tests/store-image-pins.test.ts @@ -0,0 +1,97 @@ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { PINNED_IMAGE_FIELDS, refForVersion, refOfAssetUrl, repinAssetUrl } from '../scripts/sync-version'; + +/** + * The store fetches `thumbnail` and `splash_image` when someone opens the listing, not when the + * plugin is reviewed or installed. Millennium's schema requires an absolute URL for both, so the + * submodule pin in the plugin database does not cover them: left on a branch ref, the image a + * reviewer approved and the image a user is shown are free to differ forever after. + * + * `scripts/sync-version.ts` re-pins both to the release tag during `semantic-release`'s prepare + * step. This suite holds the two halves of that: the manifest on disk is pinned right now, and the + * rewrite that keeps it pinned behaves. + */ +// fileURLToPath is given a string, not a URL object: the happy-dom test environment replaces the +// global URL constructor, and Node rejects the resulting foreign instance. +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); + +const manifest = JSON.parse(readFileSync(join(ROOT, 'plugin.json'), 'utf8')) as Record; + +/** Refs that move. A pin to any of these is not a pin. */ +const MUTABLE_REFS = ['master', 'main', 'HEAD', 'latest', 'dev']; + +describe('plugin.json store images', () => { + it('pins every store image to the current version', () => { + const want = refForVersion(manifest.version); + for (const field of PINNED_IMAGE_FIELDS) { + expect(refOfAssetUrl(manifest[field]), `${field} should be pinned to ${want}`).toBe(want); + } + }); + + it('leaves no store image on a ref that can move', () => { + for (const field of PINNED_IMAGE_FIELDS) { + expect(MUTABLE_REFS).not.toContain(refOfAssetUrl(manifest[field])); + } + }); + + /** + * `$schema` is also a raw.githubusercontent.com URL and is deliberately *not* pinned — it tracks + * Millennium's `main` so the editor hints follow the schema as it changes. It is excluded by + * living outside PINNED_IMAGE_FIELDS rather than by anything in the URL, so the exclusion is + * worth asserting: widening that list to "every raw URL in the manifest" would silently freeze + * the schema reference too. + */ + it('does not pin $schema, which is meant to track Millennium main', () => { + expect(PINNED_IMAGE_FIELDS).not.toContain('$schema'); + expect(refOfAssetUrl(manifest.$schema)).toBe('main'); + }); +}); + +describe('repinAssetUrl', () => { + const base = 'https://raw.githubusercontent.com/MuhammedResulBilkil/cs2tracker-extension'; + + it('moves a branch ref onto the release tag', () => { + expect(repinAssetUrl(`${base}/master/assets/thumbnail.png`, '1.2.3')).toBe(`${base}/v1.2.3/assets/thumbnail.png`); + }); + + it('moves an already-pinned tag onto the new one, so releases re-pin rather than stick', () => { + expect(repinAssetUrl(`${base}/v1.0.0/assets/splash.png`, '1.0.1')).toBe(`${base}/v1.0.1/assets/splash.png`); + }); + + it('rewrites only the ref, leaving owner, repo and nested path intact', () => { + const forked = 'https://raw.githubusercontent.com/someone-else/their-fork/master/assets/deep/nested.png'; + expect(repinAssetUrl(forked, '2.0.0')).toBe( + 'https://raw.githubusercontent.com/someone-else/their-fork/v2.0.0/assets/deep/nested.png', + ); + }); + + /** + * The schema names Imgur as an acceptable host, so a URL this cannot parse is plausible rather + * than absurd — and it is exactly the case where returning the input unchanged would leave a + * mutable image in a released manifest while reporting success. + */ + it('throws rather than silently passing through a URL it cannot pin', () => { + expect(() => repinAssetUrl('https://i.imgur.com/abc123.png', '1.0.0')).toThrow(/not a raw\.githubusercontent\.com/); + }); + + it('throws on a raw URL with no path after the ref', () => { + expect(() => repinAssetUrl(`${base}/master`, '1.0.0')).toThrow(); + }); +}); + +describe('refOfAssetUrl', () => { + it('returns null for a URL that is not a raw.githubusercontent.com asset', () => { + expect(refOfAssetUrl('https://i.imgur.com/abc123.png')).toBeNull(); + expect(refOfAssetUrl('https://github.com/owner/repo/blob/main/a.png')).toBeNull(); + }); +}); + +describe('refForVersion', () => { + it('is the tag semantic-release creates, so the pin and the tag cannot drift', () => { + expect(refForVersion('1.0.0')).toBe('v1.0.0'); + expect(refForVersion('2.3.4-beta.1')).toBe('v2.3.4-beta.1'); + }); +});