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" } + ] +}