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
4 changes: 2 additions & 2 deletions plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
118 changes: 104 additions & 14 deletions scripts/sync-version.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,58 @@
/**
* 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 = [
['package.json', '\t'],
['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/<owner>/<repo>/<ref>/<path>`, 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
Expand All @@ -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);
Expand All @@ -48,7 +97,8 @@ function load(file: string): Record<string, unknown> {
}

/**
* 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.
Expand All @@ -69,25 +119,65 @@ function assertAgreement(): string {
'\nrun `pnpm exec tsx scripts/sync-version.ts <version>` 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 <version>` to re-pin it',
);
}
}

return expected;
}

const argument = process.argv[2];
if (!argument) fail('usage: tsx scripts/sync-version.ts <version|--check>');
function main(): void {
const argument = process.argv[2];
if (!argument) fail('usage: tsx scripts/sync-version.ts <version|--check>');

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();
97 changes: 97 additions & 0 deletions tests/store-image-pins.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;

/** 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');
});
});