diff --git a/.github/release-script/src/index.ts b/.github/release-script/src/index.ts
index 84d55b3cfd..2ca102ea83 100644
--- a/.github/release-script/src/index.ts
+++ b/.github/release-script/src/index.ts
@@ -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.
*
@@ -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
Scan only for packages (default: the whole repository)");
console.log(" --help, -h Show this help message");
console.log("");
console.log("Environment variables:");
@@ -378,14 +388,32 @@ function getApiUrl(registry: string, packageName: string): string {
return `${baseUrl}${encodeURIComponent(packageName)}`;
}
-function parseArgs(): Pick {
+function parseArgs(): Pick {
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 ` or `--root=`, 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 {
// An EMPTY token is no token. `${{ secrets.NODE_AUTH_TOKEN }}` still exports
// the variable when the secret is unset, so "the variable exists" says
@@ -648,10 +676,10 @@ async function publishPackageWithRetry(pkg: Package, config: Config): Promise {
+async function collectPackages(root: string): Promise {
// 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...`);
@@ -919,7 +947,7 @@ async function main(): Promise {
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);
diff --git a/.github/sdk-channels/plan.mjs b/.github/sdk-channels/plan.mjs
new file mode 100644
index 0000000000..58fcc9a4e6
--- /dev/null
+++ b/.github/sdk-channels/plan.mjs
@@ -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 --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();
+}
diff --git a/.github/sdk-channels/plan.test.mjs b/.github/sdk-channels/plan.test.mjs
new file mode 100644
index 0000000000..7f023e0876
--- /dev/null
+++ b/.github/sdk-channels/plan.test.mjs
@@ -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/);
+});
diff --git a/.github/workflows/sdk-types.yml b/.github/workflows/sdk-types.yml
new file mode 100644
index 0000000000..d4eb1abd10
--- /dev/null
+++ b/.github/workflows/sdk-types.yml
@@ -0,0 +1,271 @@
+# Publishes one `@girs/sdk--` package per Flatpak SDK channel: the whole GIR
+# set of that SDK, generated as ONE self-contained npm package (`ts-for-gir --bundle`).
+#
+# WHY A SEPARATE WORKFLOW. `release.yml` publishes the ~700 per-namespace packages from the
+# committed tree whenever this repository is pushed. The channels have neither property: they
+# are regenerated from a Flatpak SDK rather than committed (34 MB per channel, rebuilt monthly,
+# reproducible in seconds β the registry is the artifact store, this repository holds only the
+# recipe), and they move on their own cadence, because an SDK updates inside a GNOME cycle
+# without ts-for-gir releasing and ts-for-gir releases without the SDK moving.
+#
+# WHY NO STATE FILE. Each published channel manifest carries the SDK commit and the generator
+# version it was built from, so `plan.mjs` asks the registry whether a rebuild is due. A
+# checked-in copy of that answer would be a second truth that drifts.
+#
+# ADDING A CHANNEL is one line in `sdk-channels.json` β plus, once, `gjsify onboard` for the new
+# package name against THIS workflow file. npm Trusted Publishing can update a package but
+# cannot create one, and the 404 it fails with reads like a broken trusted publisher.
+name: SDK Types
+
+on:
+ # ts-for-gir released: every channel is rebuilt against the new generator.
+ repository_dispatch:
+ types: [generator-released]
+ # The SDKs move on their own: a weekly poll catches an SDK update inside a cycle. Channels
+ # whose SDK commit and generator are both unchanged cost one registry request and stop.
+ schedule:
+ - cron: "0 5 * * 1"
+ workflow_dispatch:
+ inputs:
+ branch:
+ description: "Only this SDK branch (default: every channel in sdk-channels.json)"
+ required: false
+ dry_run:
+ description: "Generate and plan, publish nothing"
+ type: boolean
+ default: false
+
+concurrency:
+ group: npm-publish-sdk-types
+ cancel-in-progress: false
+
+env:
+ node-version: 24.x
+
+jobs:
+ plan:
+ name: Channels
+ runs-on: ubuntu-24.04
+ # Reads the repository and the public registry, writes nothing.
+ permissions:
+ contents: read
+ outputs:
+ matrix: ${{ steps.channels.outputs.matrix }}
+ runtime: ${{ steps.channels.outputs.runtime }}
+ remotes: ${{ steps.channels.outputs.remotes }}
+ generator: ${{ steps.generator.outputs.version }}
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Test the planning script
+ run: node --test .github/sdk-channels/plan.test.mjs
+
+ - name: Read sdk-channels.json
+ id: channels
+ env:
+ ONLY: ${{ inputs.branch }}
+ run: |
+ set -euo pipefail
+ matrix=$(jq -c --arg only "$ONLY" \
+ '[.channels[] | select($only == "" or .branch == $only)]' sdk-channels.json)
+ if [ "$(jq 'length' <<<"$matrix")" -eq 0 ]; then
+ echo "no channel matches '${ONLY}'" >&2
+ exit 1
+ fi
+ # Every channel must name a remote the manifest defines; a typo here would
+ # otherwise surface as `flatpak install` failing against a remote that was never
+ # added, six steps later and under the wrong name.
+ jq -e '[.channels[].remote] - (.remotes | keys) | length == 0' sdk-channels.json > /dev/null \
+ || { echo "a channel names a remote that sdk-channels.json does not define" >&2; exit 1; }
+ {
+ echo "matrix=$matrix"
+ echo "runtime=$(jq -r .runtime sdk-channels.json)"
+ echo "remotes=$(jq -c .remotes sdk-channels.json)"
+ } >> "$GITHUB_OUTPUT"
+
+ # Resolved once and passed to every leg: two legs of one run must not silently use
+ # different generators, which is exactly what `@latest` per job would allow.
+ #
+ # A release dispatch names its own version, and it has to: `release-types.yml` (which
+ # sends it) and `release-app.yml` (which publishes the CLI) both fire on the SAME
+ # `release: published` event, so asking npm for `latest` here races the publish that is
+ # meant to have happened already. Waiting for the named version turns that race into a
+ # wait; every other trigger has no version in mind and asks for the current one.
+ - name: Resolve the generator version
+ id: generator
+ env:
+ RELEASE: ${{ github.event.client_payload.release }}
+ run: |
+ set -euo pipefail
+ if [ -n "${RELEASE:-}" ]; then
+ wanted="${RELEASE#v}"
+ echo "waiting for @ts-for-gir/cli@${wanted} to appear on the registryβ¦"
+ for attempt in $(seq 1 40); do
+ if npm view "@ts-for-gir/cli@${wanted}" version > /dev/null 2>&1; then
+ echo "version=${wanted}" >> "$GITHUB_OUTPUT"
+ echo "::notice::generator ${wanted} (from the release dispatch, after ${attempt} check(s))"
+ exit 0
+ fi
+ sleep 30
+ done
+ echo "@ts-for-gir/cli@${wanted} did not appear within 20 minutes" >&2
+ exit 1
+ fi
+ echo "version=$(npm view @ts-for-gir/cli version)" >> "$GITHUB_OUTPUT"
+
+ # A generator without `--bundle` does NOT fail on the flag β yargs ignores what it does
+ # not know β it quietly emits per-namespace packages named `@girs/gtk-4.0` instead, into
+ # a directory this workflow then hands to the publisher. The manifest check downstream
+ # would catch it, but only after a full generation and only by accident. Asked here, the
+ # answer is one line and the failure names the cause.
+ - name: Require a generator that can bundle
+ env:
+ GENERATOR: ${{ steps.generator.outputs.version }}
+ run: |
+ set -euo pipefail
+ if ! npx --yes "@ts-for-gir/cli@${GENERATOR}" generate --help | grep -q -- "--bundle"; then
+ echo "@ts-for-gir/cli@${GENERATOR} has no --bundle: the channels need a release that carries it" >&2
+ exit 1
+ fi
+ echo "::notice::generator ${GENERATOR} supports --bundle"
+
+ channel:
+ name: ${{ matrix.channel.package }}
+ needs: plan
+ runs-on: ubuntu-24.04
+ timeout-minutes: 60
+ environment: npm-release
+ permissions:
+ contents: read
+ id-token: write
+ strategy:
+ fail-fast: false
+ matrix:
+ channel: ${{ fromJson(needs.plan.outputs.matrix) }}
+ steps:
+ - uses: actions/checkout@v6
+
+ - uses: actions/setup-node@v7
+ with:
+ node-version: ${{ env.node-version }}
+ registry-url: "https://registry.npmjs.org"
+ package-manager-cache: false
+
+ # npm >= 11.5.1 is a hard requirement for Trusted Publishing; below it a publish fails as
+ # an AUTH error, which reads like a broken trusted publisher rather than a stale npm.
+ - name: Pin an npm that can use Trusted Publishing
+ run: |
+ npm install -g npm@^11.5.1
+ npm --version
+
+ # The remote comes from the channel, not from a global default: measured on 2026-09-05,
+ # stable `org.gnome.Sdk` on Flathub ends at 50, so `master` lives on gnome-nightly and a
+ # pre-release cycle on flathub-beta. One shared remote would install the wrong thing or
+ # nothing at all.
+ - name: Install flatpak and the SDK
+ env:
+ REMOTES: ${{ needs.plan.outputs.remotes }}
+ REMOTE: ${{ matrix.channel.remote }}
+ REF: ${{ needs.plan.outputs.runtime }}//${{ matrix.channel.branch }}
+ run: |
+ set -euo pipefail
+ sudo apt-get update
+ sudo apt-get install -y flatpak jq
+ url=$(jq -r --arg name "$REMOTE" '.[$name]' <<<"$REMOTES")
+ flatpak remote-add --if-not-exists --user "$REMOTE" "$url"
+ # A branch a remote does not carry is a declaration that ran ahead of reality β the
+ # usual case being a GNOME cycle added here before Flathub publishes it. Say which,
+ # rather than let `install` fail with a resolution error that names neither.
+ if ! flatpak remote-info --user "$REMOTE" "$REF" > /dev/null 2>&1; then
+ echo "$REMOTE does not carry $REF yet" >&2
+ exit 1
+ fi
+ flatpak install --user --noninteractive --no-related "$REMOTE" "$REF"
+
+ # The SDK ships the GIR XML the Platform runtime does not, and it lies on disk as ordinary
+ # files β no sandbox to enter, so the generator runs beside it with the normal toolchain.
+ - name: Locate the GIR files
+ id: sdk
+ run: |
+ set -euo pipefail
+ ref="${{ needs.plan.outputs.runtime }}//${{ matrix.channel.branch }}"
+ location=$(flatpak info --user --show-location "$ref")
+ girs="$location/files/share/gir-1.0"
+ count=$(find "$girs" -name '*.gir' | wc -l)
+ if [ "$count" -eq 0 ]; then
+ echo "no GIR files under $girs β the SDK layout changed" >&2
+ exit 1
+ fi
+ {
+ echo "girs=$girs"
+ echo "commit=$(flatpak info --user --show-commit "$ref")"
+ echo "count=$count"
+ } >> "$GITHUB_OUTPUT"
+ echo "::notice::$ref carries $count GIR files"
+
+ - name: Plan this channel
+ id: channel_plan
+ run: |
+ node .github/sdk-channels/plan.mjs \
+ --package '${{ matrix.channel.package }}' \
+ --sdk-commit '${{ steps.sdk.outputs.commit }}' \
+ --generator '${{ needs.plan.outputs.generator }}'
+
+ - name: Up to date
+ if: steps.channel_plan.outputs.rebuild != 'true'
+ run: echo "::notice::${{ matrix.channel.package }} skipped β ${{ steps.channel_plan.outputs.reason }}"
+
+ - name: Generate the bundle
+ if: steps.channel_plan.outputs.rebuild == 'true'
+ run: |
+ set -euo pipefail
+ mkdir -p sdk
+ npx --yes @ts-for-gir/cli@${{ needs.plan.outputs.generator }} generate '*' \
+ --girDirectories '${{ steps.sdk.outputs.girs }}' \
+ --outdir './sdk/${{ matrix.channel.branch }}' \
+ --bundle '${{ matrix.channel.package }}' \
+ --bundleMeta "$(jq -nc \
+ --arg id '${{ needs.plan.outputs.runtime }}' \
+ --arg branch '${{ matrix.channel.branch }}' \
+ --arg commit '${{ steps.sdk.outputs.commit }}' \
+ --arg generator '${{ needs.plan.outputs.generator }}' \
+ --arg version '${{ steps.channel_plan.outputs.version }}' \
+ '{sdk: {id: $id, branch: $branch, commit: $commit}, generator: $generator, version: $version}')" \
+ --ignoreVersionConflicts
+
+ # The manifest decides what is published, so it is checked before the sweep runs rather
+ # than trusted because the generator exited 0.
+ - name: Check the manifest
+ if: steps.channel_plan.outputs.rebuild == 'true'
+ run: |
+ set -euo pipefail
+ manifest="./sdk/${{ matrix.channel.branch }}/package.json"
+ node -e '
+ const [file, name, version, commit] = process.argv.slice(1);
+ const manifest = require(require("node:path").resolve(file));
+ const fail = (message) => { console.error(message); process.exit(1); };
+ if (manifest.name !== name) fail(`name ${manifest.name} != ${name}`);
+ if (manifest.version !== version) fail(`version ${manifest.version} != ${version}`);
+ if (manifest.sdk?.commit !== commit) fail("sdk.commit missing or wrong");
+ const subpaths = Object.keys(manifest.exports ?? {});
+ if (subpaths.length < 50) fail(`only ${subpaths.length} subpaths β the bundle is short`);
+ console.log(`${manifest.name}@${manifest.version}: ${subpaths.length} subpaths`);
+ ' "$manifest" '${{ matrix.channel.package }}' '${{ steps.channel_plan.outputs.version }}' '${{ steps.sdk.outputs.commit }}'
+
+ - name: Type-check the release script
+ if: steps.channel_plan.outputs.rebuild == 'true' && !inputs.dry_run
+ working-directory: .github/release-script
+ run: |
+ npm ci
+ npm run check
+
+ # Same publisher as release.yml, pointed at `sdk/` β including its retry policy and its
+ # refusal to treat "this version already exists" as success.
+ - name: Publish
+ if: steps.channel_plan.outputs.rebuild == 'true'
+ run: |
+ node --experimental-specifier-resolution=node --experimental-strip-types \
+ --experimental-transform-types --no-warnings \
+ ./.github/release-script/src/index.ts --root sdk ${{ inputs.dry_run && '--dry-run' || '' }}
+ env:
+ NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }}
diff --git a/.gitignore b/.gitignore
index 40b878db5b..410f59a2c5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,3 @@
-node_modules/
\ No newline at end of file
+node_modules/
+# SDK channel bundles: regenerated per run by sdk-types.yml, published to npm, never committed
+sdk/
diff --git a/sdk-channels.json b/sdk-channels.json
new file mode 100644
index 0000000000..9817600769
--- /dev/null
+++ b/sdk-channels.json
@@ -0,0 +1,15 @@
+{
+ "$comment": "The SDK channels published from this repository. Adding a channel is a one-line change here plus one `gjsify onboard` bootstrap for the new package name β npm Trusted Publishing can update a package but cannot create one, and the 404 it fails with reads like a broken trusted publisher.",
+ "runtime": "org.gnome.Sdk",
+ "$remotes": "Per channel, because they do not all live in the same place: measured against the Flathub OSTree repo on 2026-09-05, stable `org.gnome.Sdk` ends at 50 β `master` is only on gnome-nightly, and 51 exists so far only as `51beta` on flathub-beta.",
+ "remotes": {
+ "flathub": "https://dl.flathub.org/repo/flathub.flatpakrepo",
+ "flathub-beta": "https://dl.flathub.org/beta-repo/flathub-beta.flatpakrepo",
+ "gnome-nightly": "https://nightly.gnome.org/gnome-nightly.flatpakrepo"
+ },
+ "channels": [
+ { "branch": "49", "remote": "flathub", "package": "@girs/sdk-gnome-49" },
+ { "branch": "50", "remote": "flathub", "package": "@girs/sdk-gnome-50" },
+ { "branch": "master", "remote": "gnome-nightly", "package": "@girs/sdk-gnome-master" }
+ ]
+}