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
36 changes: 32 additions & 4 deletions .github/release-script/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ interface Config {
timeoutSec: number;
dryRun: boolean;
continueOnError: boolean;
/**
* Directory the sweep scans, relative to the repository root.
*
* Defaults to the repository root, which is every `@girs/*` namespace package. The SDK
* channel bundles are generated into `sdk/` by a different workflow on a different cadence
* and are not committed, so a sweep that always scanned everything would publish whichever
* of the two happened to be on disk — and silently report success for the other.
*/
root: string;
/**
* How this run authenticates to npm. BOTH are supported on purpose.
*
Expand Down Expand Up @@ -353,6 +362,7 @@ function showUsage(): void {
console.log("Options:");
console.log(" --dry-run, -d Show what would be published without actually publishing");
console.log(" --continue-on-error, -c Continue processing even if some packages fail");
console.log(" --root <dir> Scan only <dir> for packages (default: the whole repository)");
console.log(" --help, -h Show this help message");
console.log("");
console.log("Environment variables:");
Expand All @@ -378,14 +388,32 @@ function getApiUrl(registry: string, packageName: string): string {
return `${baseUrl}${encodeURIComponent(packageName)}`;
}

function parseArgs(): Pick<Config, "dryRun" | "continueOnError"> {
function parseArgs(): Pick<Config, "dryRun" | "continueOnError" | "root"> {
const args = process.argv;
return {
dryRun: args.includes("--dry-run") || args.includes("-d"),
continueOnError: args.includes("--continue-on-error") || args.includes("-c"),
root: parseRoot(args),
};
}

/**
* `--root <dir>` or `--root=<dir>`, relative to the repository root. Absolute paths and `..`
* are refused: this value decides what gets published, so it stays inside the repository.
*/
function parseRoot(args: string[]): string {
const inline = args.find((arg) => arg.startsWith("--root="));
const flagAt = args.indexOf("--root");
const raw = inline ? inline.slice("--root=".length) : flagAt >= 0 ? args[flagAt + 1] : undefined;

if (raw === undefined || raw === "") return ".";
if (raw.startsWith("-")) throw new Error("--root needs a directory argument");
if (raw.startsWith("/") || raw.split("/").includes("..")) {
throw new Error(`--root must stay inside the repository: ${raw}`);
}
return raw;
}

function getEnvConfig(): Pick<Config, "token" | "registry" | "timeoutSec"> {
// An EMPTY token is no token. `${{ secrets.NODE_AUTH_TOKEN }}` still exports
// the variable when the secret is unset, so "the variable exists" says
Expand Down Expand Up @@ -648,10 +676,10 @@ async function publishPackageWithRetry(pkg: Package, config: Config): Promise<vo
}
}

async function collectPackages(): Promise<Package[]> {
async function collectPackages(root: string): Promise<Package[]> {
// Get project root (3 levels up from .github/release-script/src/)
const scriptDir = new URL(".", import.meta.url).pathname;
const projectRoot = join(scriptDir, "..", "..", "..");
const projectRoot = join(scriptDir, "..", "..", "..", root);

console.log(`📁 Scanning ${projectRoot} for packages...`);

Expand Down Expand Up @@ -919,7 +947,7 @@ async function main(): Promise<void> {
console.log(`⚙️ Config: batch=${BATCH_SIZE}, batchDelay=${BATCH_DELAY_MS}ms, publishDelay=${PUBLISH_DELAY_MS}ms, statusConcurrency=${STATUS_CONCURRENCY}`);

await assertCanAuthenticate(config);
const packages = await collectPackages();
const packages = await collectPackages(config.root);

// Check for test packages with workspace dependencies
await checkForTestPackages(packages);
Expand Down
116 changes: 116 additions & 0 deletions .github/sdk-channels/plan.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
#!/usr/bin/env node
// Decides, for ONE SDK channel, whether it has to be rebuilt and which version the rebuild
// gets. Split from the workflow on purpose: this is the decision that makes a channel silently
// stale (skip when it should have built) or a release silently empty (publish a version that
// already exists, which `--tolerate-republish` reports as success), so it is a program with a
// test rather than a shell expression inside a YAML step.
//
// There is deliberately NO state file. The registry is the state: the published manifest of a
// channel carries the SDK commit it was generated from and the generator that produced it, so
// the question "is this channel current?" is answered by the artifact itself and cannot drift
// away from a checked-in copy of the answer.
//
// Usage:
// node plan.mjs --package @girs/sdk-gnome-50 --sdk-commit <hex> --generator 4.6.0
// → {"rebuild":true,"version":"4.6.0","reason":"never published"} on stdout,
// and the same fields appended to $GITHUB_OUTPUT when running in Actions.

import { appendFileSync, realpathSync } from "node:fs";
import { pathToFileURL } from "node:url";

const REGISTRY = process.env.NPM_REGISTRY || "https://registry.npmjs.org";

/**
* @param {{sdk?: {commit?: string}, generator?: string} | null} published the manifest npm
* currently serves for this channel, or null when the package does not exist yet
* @param {string} sdkCommit the flatpak commit of the SDK this run resolved
* @param {string} generatorVersion the ts-for-gir version this run will use
* @returns {{rebuild: boolean, reason: string}}
*/
export function decide(published, sdkCommit, generatorVersion) {
if (!published) return { rebuild: true, reason: "never published" };
if (published.sdk?.commit !== sdkCommit) {
return { rebuild: true, reason: `SDK moved: ${published.sdk?.commit ?? "unknown"} → ${sdkCommit}` };
}
if (published.generator !== generatorVersion) {
return { rebuild: true, reason: `generator moved: ${published.generator ?? "unknown"} → ${generatorVersion}` };
}
return { rebuild: false, reason: `up to date at ${sdkCommit.slice(0, 12)}` };
}

/**
* The channel's version line is the generator's `major.minor` with a build counter for its
* patch, because the two move independently: an SDK updates inside a GNOME cycle without the
* generator changing, and the generator releases without the SDK moving. Reusing the
* generator's full version would make the second case unpublishable.
*
* @param {string[]} publishedVersions every version npm already serves for this package
* @param {string} generatorVersion
* @returns {string}
*/
export function nextVersion(publishedVersions, generatorVersion) {
const match = /^(\d+)\.(\d+)\./.exec(generatorVersion);
if (!match) throw new Error(`generator version is not semver: ${generatorVersion}`);
const [, major, minor] = match;

// A constant pattern, compared field by field, rather than one built from `generatorVersion`.
// Building it would be a regex assembled from an argument — and the escaping that made it
// safe, `line.replace(".", "\\.")`, replaces only the FIRST dot, so it was one input away
// from meaning something else than it read.
const patches = publishedVersions
.map((version) => /^(\d+)\.(\d+)\.(\d+)$/.exec(version))
.filter((found) => found !== null && found[1] === major && found[2] === minor)
.map((found) => Number.parseInt(found[3], 10));

return patches.length === 0
? `${major}.${minor}.0`
: `${major}.${minor}.${Math.max(...patches) + 1}`;
}

/**
* @param {string} packageName
* @returns {Promise<{versions: string[], latest: object | null}>}
*/
export async function readRegistry(packageName) {
const response = await fetch(`${REGISTRY}/${encodeURIComponent(packageName)}`);
if (response.status === 404) return { versions: [], latest: null };
if (!response.ok) {
throw new Error(`registry returned ${response.status} for ${packageName}`);
}
const packument = await response.json();
const versions = Object.keys(packument.versions ?? {});
const latestTag = packument["dist-tags"]?.latest;
return { versions, latest: latestTag ? (packument.versions[latestTag] ?? null) : null };
}

function argValue(name) {
const at = process.argv.indexOf(`--${name}`);
if (at < 0 || !process.argv[at + 1]) throw new Error(`missing --${name}`);
return process.argv[at + 1];
}

async function main() {
const packageName = argValue("package");
const sdkCommit = argValue("sdk-commit");
const generatorVersion = argValue("generator");

const { versions, latest } = await readRegistry(packageName);
const { rebuild, reason } = decide(latest, sdkCommit, generatorVersion);
const version = rebuild ? nextVersion(versions, generatorVersion) : (latest?.version ?? "");

const plan = { rebuild, version, reason };
console.log(JSON.stringify(plan));

if (process.env.GITHUB_OUTPUT) {
appendFileSync(
process.env.GITHUB_OUTPUT,
`rebuild=${rebuild}\nversion=${version}\nreason=${reason}\n`,
);
}
}

// Run as a program, importable as a module: the test imports `decide` and `nextVersion`
// without the CLI trying to reach the registry.
if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
await main();
}
67 changes: 67 additions & 0 deletions .github/sdk-channels/plan.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// The two decisions that can fail silently, held to cases that must go both ways.
//
// A channel that skips when it should build goes stale without a single red run, and a rebuild
// that reuses an existing version is refused by npm as EPUBLISHCONFLICT — or, with a tolerant
// publisher, reported as success while publishing nothing. Neither shows up in a log you would
// read on a green run, so they are tested rather than watched.

import assert from "node:assert/strict";
import { test } from "node:test";

import { decide, nextVersion } from "./plan.mjs";

const SDK = "c87589be513db588f67de1a27879315dc9697ed2bd8467bd3d55860bf4da2f42";
const OTHER = "0000000000000000000000000000000000000000000000000000000000000000";

test("a channel that was never published is built", () => {
const { rebuild, reason } = decide(null, SDK, "4.6.0");
assert.equal(rebuild, true);
assert.match(reason, /never published/);
});

test("a channel whose SDK and generator are unchanged is skipped", () => {
const published = { sdk: { commit: SDK }, generator: "4.6.0" };
assert.equal(decide(published, SDK, "4.6.0").rebuild, false);
});

test("a moved SDK rebuilds, even with the same generator", () => {
const published = { sdk: { commit: OTHER }, generator: "4.6.0" };
const { rebuild, reason } = decide(published, SDK, "4.6.0");
assert.equal(rebuild, true);
assert.match(reason, /SDK moved/);
});

test("a moved generator rebuilds, even with the same SDK", () => {
const published = { sdk: { commit: SDK }, generator: "4.5.0" };
const { rebuild, reason } = decide(published, SDK, "4.6.0");
assert.equal(rebuild, true);
assert.match(reason, /generator moved/);
});

test("a manifest without provenance rebuilds rather than assuming it matches", () => {
assert.equal(decide({}, SDK, "4.6.0").rebuild, true);
});

test("the first build of a generator line starts at .0", () => {
assert.equal(nextVersion([], "4.6.0"), "4.6.0");
assert.equal(nextVersion(["4.5.0", "4.5.1"], "4.6.0"), "4.6.0");
});

test("a further build of the same line takes the next free patch", () => {
assert.equal(nextVersion(["4.6.0"], "4.6.0"), "4.6.1");
assert.equal(nextVersion(["4.6.0", "4.6.1", "4.6.2"], "4.6.3"), "4.6.3");
});

test("the counter follows the highest patch, not the count", () => {
// A gap (a yanked or failed publish) must not hand out a version that already exists.
assert.equal(nextVersion(["4.6.0", "4.6.3"], "4.6.0"), "4.6.4");
});

test("versions from other lines and non-releases do not occupy the line", () => {
// A prerelease of the same number is a different version, so `4.6.0` is still free.
assert.equal(nextVersion(["4.5.9", "4.6.0-rc.1", "not-a-version"], "4.6.0"), "4.6.0");
});

test("a generator version that is not semver fails loudly", () => {
assert.throws(() => nextVersion([], "latest"), /not semver/);
});
Loading