Skip to content

feat(sdk): publish a type bundle per Flatpak SDK - #14

Merged
JumpLink merged 5 commits into
mainfrom
feat/sdk-channels
Sep 5, 2026
Merged

feat(sdk): publish a type bundle per Flatpak SDK#14
JumpLink merged 5 commits into
mainfrom
feat/sdk-channels

Conversation

@JumpLink

@JumpLink JumpLink commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

The published @girs/* set is generated from one GIR snapshot — today GNOME 51 development (libraryVersion 4.23.3 for Gtk). An app pinned to gnome//50 (Gtk 4.22.4) is a cycle behind it and gets types offering APIs its runtime does not have. Asked for repeatedly, most recently from Workbench in gjsify/ts-for-gir#462.

This publishes one package per SDK channel: @girs/sdk-gnome-49, @girs/sdk-gnome-50, @girs/sdk-gnome-master. Each is the whole GIR set of that SDK, generated as ONE self-contained npm package by ts-for-gir --bundle (gjsify/ts-for-gir#463), so the set installs, resolves and versions as a unit. Per-namespace packages cannot give that: npm may satisfy @girs/glib-2.0 from the registry for one namespace and from the pinned copy for another, and two copies of a namespace are duplicate declare module 'gi://GLib' blocks — ts-for-gir#431.

"devDependencies": { "@girs/sdk-gnome-50": "^4.6.0" }
import "@girs/sdk-gnome-50/gtk-4.0";

Design

Nothing is committed. A channel is 34 MB, rebuilt monthly, and regenerates from the SDK in seconds — measured: generate '*' over org.gnome.Sdk//50 is 11 s for 112 namespaces, 4.9 MB packed. The registry is the artifact store; this repository holds the recipe (sdk-channels.json, the workflow, the planner). sdk/ is gitignored.

No state file. Every published channel manifest carries the SDK commit and generator version it came from, so plan.mjs asks the registry whether a rebuild is due. A checked-in copy of that answer is a second truth that drifts. Custom manifest fields do survive into the registry — verified: npm view @girs/gtk-4.0 libraryVersion4.23.3.

A separate workflow, because the cadences differ: release.yml sweeps the committed tree on every push, while an SDK updates inside a GNOME cycle without ts-for-gir releasing, and ts-for-gir releases without the SDK moving. Hence a weekly poll plus a generator-released dispatch from ts-for-gir. The publisher is the same hardened script, given a --root so each sweep publishes exactly the tree it means to.

The SDK needs no sandbox. org.gnome.Sdk//50 ships 113 GIR XML files (the Platform runtime ships only typelibs), and they lie on disk as ordinary files — the generator runs beside them with the normal toolchain.

Tests

plan.mjs makes the two decisions that fail silently when wrong: a skip that should have built goes stale on a green run, and a version that already exists is refused as EPUBLISHCONFLICT or reported as a successful no-op. Ten cases cover both directions of each — never published, unchanged, SDK moved, generator moved, missing provenance, first patch, next patch, a gap in the line, prereleases, and a non-semver generator. One of them already corrected a wrong assumption of mine.

Before the first run

@girs/sdk-gnome-* are new names, and Trusted Publishing can update a package but cannot create one — the 404 reads like a broken trusted publisher. Each needs one gjsify onboard bootstrap against sdk-types.yml (not release.yml). Worth a --dry-run dispatch first.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FvMH98Xsfh6rAJK5HRfxFq


Channel availability, measured 2026-09-05

Queried against the Flathub OSTree repo directly (the local flathub remote is a filtered Fedora one and cannot answer this):

Branch Where In this PR
49, 50 flathub, stable yes
master gnome-nightly, not flathub yes
51 nowhere yet — stable org.gnome.Sdk ends at 50 no
51beta flathub-beta no

That is why the remote moved from a single global value into each channel: master against flathub would have failed at flatpak install. GNOME 51 is due mid-September; adding it is one line here plus one gjsify onboard run for @girs/sdk-gnome-51.

Bootstrap

The three bundles are generated locally and validated, so the bootstrap is a single command. Dry-run output:

gjsify onboard: root=…/types-release | packages(sdk/*)=3
gjsify onboard: 3 of 3 package(s) selected | repo=gjsify/types workflow=sdk-types.yml
Plan: 0 already done, 3 to publish+trust, 0 to trust, 0 unreadable.

--packages 'sdk/*' scopes discovery to the bundles, so the 704 existing @girs/* packages are not touched.

Channel GIRs Namespaces Subpaths Gtk Adw
@girs/sdk-gnome-49 112 113 458 4.20.4 1.8.7
@girs/sdk-gnome-50 112 113 458 4.22.4 1.9.3
@girs/sdk-gnome-master 116 117 474 4.23.4 1.10.0

Each packs to ~5.0 MB (34 MB unpacked, 916 files for 50).

The published `@girs/*` set is generated from one GIR snapshot — today GNOME 51
development, `libraryVersion` 4.23.3 for Gtk. An app pinned to `gnome//50`
(Gtk 4.22.4) is a cycle behind it and gets types offering APIs its runtime does
not have. Asked for repeatedly, most recently from Workbench.

Each channel is now one package: `ts-for-gir --bundle` emits every namespace of
one SDK as a single self-contained npm package, so the whole set installs,
resolves and versions as a unit. Per-namespace packages cannot give that — npm
is free to satisfy `@girs/glib-2.0` from the registry for one namespace and
from the pinned copy for another, and two copies of a namespace are duplicate
`declare module 'gi://GLib'` blocks, not a version skew.

Nothing is committed. A channel is 34 MB and rebuilt monthly, and it
regenerates in seconds from the SDK, so the registry is the artifact store and
this repository holds the recipe: `sdk-channels.json`, the workflow, and the
planner. `sdk/` is gitignored.

Nor is there a state file. Every published channel manifest carries the SDK
commit and the generator version it came from, so `plan.mjs` asks the registry
whether a rebuild is due — a checked-in copy of that answer is a second truth
that drifts. The two decisions it makes both fail silently when wrong (a skip
that should have built goes stale on a green run; a version that already exists
is refused as EPUBLISHCONFLICT, or reported as a successful no-op), so they are
a tested program rather than a shell expression in a step. One of its ten cases
already corrected a wrong assumption of mine about prerelease versions.

Separate workflow, on purpose: `release.yml` sweeps the committed tree on every
push, while the channels move with the SDKs — an SDK updates inside a cycle
without ts-for-gir releasing, and ts-for-gir releases without the SDK moving.
Hence a weekly poll, plus a `generator-released` dispatch from ts-for-gir. The
publisher is the same one, given a `--root` so each sweep publishes exactly the
tree it means to.

Adding a channel stays one line here, plus one `gjsify onboard` bootstrap for
the new package name against this workflow file: Trusted Publishing can update
a package but cannot create one, and the 404 reads like a broken publisher.

Claude-Session: https://claude.ai/code/session_01FvMH98Xsfh6rAJK5HRfxFq
@JumpLink
JumpLink requested a review from ewlsh as a code owner September 5, 2026 11:53
Comment thread .github/workflows/sdk-types.yml Fixed
Comment thread .github/sdk-channels/plan.mjs Outdated
const line = `${match[1]}.${match[2]}`;

const patches = publishedVersions
.map((version) => new RegExp(`^${line.replace(".", "\\.")}\\.(\\d+)$`).exec(version))
yargs ignores flags it does not know, so `--bundle` against a ts-for-gir that
predates it is not an error: the run emits per-namespace packages named
`@girs/gtk-4.0` into `sdk/`, which is the directory this workflow hands to the
publisher. The manifest check downstream does stop it — there is no root
manifest to read — but only after a full generation, and only because that
check happens to look for something bundle mode alone produces.

Asked before any of that, the answer is one `--help` line, and the failure
names the cause instead of being inferred from a missing file. This is the
window between merging the channels and releasing the generator that carries
`--bundle`; the weekly poll runs in it either way.

Claude-Session: https://claude.ai/code/session_01FvMH98Xsfh6rAJK5HRfxFq
The channel list had one global `remote: flathub` and included `master`, which
Flathub does not carry. Measured against the Flathub OSTree repo on 2026-09-05:
stable `org.gnome.Sdk` ends at **50**. `master` is on gnome-nightly, and 51
exists so far only as `51beta` on flathub-beta — so the master leg would have
failed at `flatpak install` with a resolution error naming neither the remote
nor the reason.

Reproduced locally while preparing the bootstrap, and it fails one step earlier
than expected: `flatpak install --user gnome-nightly …` against a remote that
is configured system-wide but not per-user reads `gnome-nightly` as a package
NAME ("Nur das letzte Namenssegment darf - enthalten"). The workflow adds the
remote per-user from the URL first, so it does not hit this — but the error is
worth knowing, because it points at the package rather than the remote.

So the remote belongs to the channel, with the URLs in one map, and two checks
that were implicit are now explicit: the plan job refuses a channel naming a
remote the manifest does not define, and the channel job refuses a branch the
remote does not carry — the shape a cycle takes when it is declared here before
Flathub publishes it.

Claude-Session: https://claude.ai/code/session_01FvMH98Xsfh6rAJK5HRfxFq
CodeQL, `js/regex-injection`, high: `nextVersion` assembled its pattern from
`generatorVersion`, a command-line argument. The `^(\d+)\.(\d+)\.` guard above
it does make the value harmless in practice, but the escaping meant to make it
safe was `line.replace(".", "\\.")` — which replaces only the FIRST dot. It read
as escaping and was one input away from not being it.

A constant pattern compared field by field says the same thing without either
problem, and the ten cases pass unchanged.

Also gives the plan job an explicit `permissions: contents: read`
(`actions/missing-workflow-permissions`, medium). It reads the repository and
the public registry and writes nothing; the channel job already declared its
own.

Claude-Session: https://claude.ai/code/session_01FvMH98Xsfh6rAJK5HRfxFq
`release-types.yml`, which sends the `generator-released` dispatch, and
`release-app.yml`, which publishes the CLI, both fire on the SAME
`release: published` event. So the dispatched run asking npm for `latest`
races the publish it is a consequence of: whichever workflow wins, the channels
would be built by the PREVIOUS generator and record it as their provenance —
and, until the `--bundle` guard, would have built it silently.

The dispatch already carries the release tag, so the run now waits for that
exact version to appear (up to 20 minutes) instead of asking which is newest.
Every other trigger — the weekly poll, a manual dispatch — has no version in
mind and keeps asking for the current one.

Claude-Session: https://claude.ai/code/session_01FvMH98Xsfh6rAJK5HRfxFq
@JumpLink
JumpLink merged commit 7f57919 into main Sep 5, 2026
2 checks passed
@JumpLink
JumpLink deleted the feat/sdk-channels branch September 5, 2026 19:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants