From b3cf2d52a65c967f4eaec8a8353291aafa25f4d1 Mon Sep 17 00:00:00 2001 From: Lorem Dev <158839528+lorem-dev@users.noreply.github.com> Date: Wed, 9 Sep 2026 09:29:14 +0200 Subject: [PATCH 1/3] fix: clear the two warnings CI actually owns (#64) * build: silence the ts-rs serde-attribute warning Every `cargo clippy` run printed "failed to parse serde attribute". `McpParameter::options` uses `deserialize_with` (see `mcp::model::de_options`) to accept a YAML mapping as well as a list, and ts-rs has no concept of that attribute, so it warns and skips it. Skipping is the right thing: the field always SERIALIZES back as a plain list, so the emitted `Array` is correct. The warning reported a decision that was already right, on every run. Nothing is lost by silencing it. The generated bindings are committed and CI diffs them ("Fail on stale generated artifacts"), so if ts-rs ever emits a different shape for this field the build fails with an error rather than a warning nobody reads. Verified the bindings are byte-identical with the feature on. * refactor: move span keying out of the DescriptionText component `pnpm lint` reported one warning for as long as this file has existed: `react-refresh/only-export-components`, because `spansToKeyedParts` was exported from a file that also exports a component. The export was not gratuitous. Renderer tests here are node-only -- no jsdom, no testing-library -- so a component cannot be unit tested, and the keying rule (position-based, so two identical link spans stay distinct) has to live where a test can reach it. The fix is to put it in its own module rather than to stop testing it, which is the same split `features/skillInstall/lib/installSelection.ts` already uses. `DescriptionSpan` moves with it: the type is data the function transforms, not part of the component's API, and the folder barrel now sources it from its owner. Other layers were already importing their own `DescriptionSpan` from `@/services/bridge`, so nothing outside this folder changes. `pnpm lint` now reports nothing at all. --- Cargo.toml | 13 ++++++++- .../ui/DescriptionText/DescriptionText.tsx | 16 ++-------- .../shared/ui/DescriptionText/index.ts | 3 +- ...Text.test.ts => spansToKeyedParts.test.ts} | 4 +-- .../ui/DescriptionText/spansToKeyedParts.ts | 29 +++++++++++++++++++ 5 files changed, 47 insertions(+), 18 deletions(-) rename apps/desktop/src/renderer/shared/ui/DescriptionText/{DescriptionText.test.ts => spansToKeyedParts.test.ts} (91%) create mode 100644 apps/desktop/src/renderer/shared/ui/DescriptionText/spansToKeyedParts.ts diff --git a/Cargo.toml b/Cargo.toml index a73910f4..9be11692 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,4 +23,15 @@ hex = "0.4" thiserror = "2" uuid = { version = "1", features = ["v4"] } regex = "1" -ts-rs = "12" +# `no-serde-warnings` silences one diagnostic class: ts-rs warns and skips when +# it meets a serde attribute it cannot model, and `McpParameter::options` uses +# `deserialize_with` (see `mcp::model::de_options`) which ts-rs has no concept +# of. Skipping it is correct -- the field always SERIALIZES back as a plain +# list, so the emitted `Array` is right -- but the warning printed on +# every `cargo clippy` run regardless. +# +# Nothing is lost by silencing it. The generated bindings are committed and CI's +# "Fail on stale generated artifacts" step diffs them, so if ts-rs ever emits a +# different shape for this field the build fails with an error instead of a +# warning nobody reads. +ts-rs = { version = "12", features = ["no-serde-warnings"] } diff --git a/apps/desktop/src/renderer/shared/ui/DescriptionText/DescriptionText.tsx b/apps/desktop/src/renderer/shared/ui/DescriptionText/DescriptionText.tsx index 7944f100..13591178 100644 --- a/apps/desktop/src/renderer/shared/ui/DescriptionText/DescriptionText.tsx +++ b/apps/desktop/src/renderer/shared/ui/DescriptionText/DescriptionText.tsx @@ -20,13 +20,10 @@ * `DescriptionSpan[]` value satisfies it structurally. */ import { cx } from '../../lib'; +import { spansToKeyedParts } from './spansToKeyedParts'; +import type { DescriptionSpan } from './spansToKeyedParts'; import './DescriptionText.scss'; -/** One piece of a parsed description: plain text, or a link with its own - * display text and target url. Structurally identical to the backend's - * generated `DescriptionSpan`. */ -export type DescriptionSpan = { kind: 'text'; text: string } | { kind: 'link'; text: string; url: string }; - export interface DescriptionTextProps { readonly spans: readonly DescriptionSpan[]; /** Called with a link span's own `url` when its button is clicked. Never @@ -40,15 +37,6 @@ export interface DescriptionTextProps { readonly 'data-testid'?: string; } -/** One span plus a stable React key. Keyed by position: spans never reorder - * once parsed, so a position-based key stays distinct even when two link - * spans repeat the same text and url. */ -export type KeyedDescriptionSpan = DescriptionSpan & { readonly key: string }; - -export function spansToKeyedParts(spans: readonly DescriptionSpan[]): KeyedDescriptionSpan[] { - return spans.map((span, index) => ({ ...span, key: String(index) })); -} - export function DescriptionText({ spans, onOpenLink, className, 'data-testid': testId }: DescriptionTextProps) { return ( diff --git a/apps/desktop/src/renderer/shared/ui/DescriptionText/index.ts b/apps/desktop/src/renderer/shared/ui/DescriptionText/index.ts index 68bbeb1b..1e7d09a8 100644 --- a/apps/desktop/src/renderer/shared/ui/DescriptionText/index.ts +++ b/apps/desktop/src/renderer/shared/ui/DescriptionText/index.ts @@ -1,2 +1,3 @@ export { DescriptionText } from './DescriptionText'; -export type { DescriptionTextProps, DescriptionSpan } from './DescriptionText'; +export type { DescriptionTextProps } from './DescriptionText'; +export type { DescriptionSpan } from './spansToKeyedParts'; diff --git a/apps/desktop/src/renderer/shared/ui/DescriptionText/DescriptionText.test.ts b/apps/desktop/src/renderer/shared/ui/DescriptionText/spansToKeyedParts.test.ts similarity index 91% rename from apps/desktop/src/renderer/shared/ui/DescriptionText/DescriptionText.test.ts rename to apps/desktop/src/renderer/shared/ui/DescriptionText/spansToKeyedParts.test.ts index 6c1790a7..1d28c6af 100644 --- a/apps/desktop/src/renderer/shared/ui/DescriptionText/DescriptionText.test.ts +++ b/apps/desktop/src/renderer/shared/ui/DescriptionText/spansToKeyedParts.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { spansToKeyedParts } from './DescriptionText'; -import type { DescriptionSpan } from './DescriptionText'; +import { spansToKeyedParts } from './spansToKeyedParts'; +import type { DescriptionSpan } from './spansToKeyedParts'; describe('spansToKeyedParts', () => { it('keeps text and link spans in order with stable keys', () => { diff --git a/apps/desktop/src/renderer/shared/ui/DescriptionText/spansToKeyedParts.ts b/apps/desktop/src/renderer/shared/ui/DescriptionText/spansToKeyedParts.ts new file mode 100644 index 00000000..7627c063 --- /dev/null +++ b/apps/desktop/src/renderer/shared/ui/DescriptionText/spansToKeyedParts.ts @@ -0,0 +1,29 @@ +/** + * The description span model and its React keying. + * + * Kept out of `DescriptionText.tsx` for two reasons that point the same way. + * Renderer tests here are node-only -- no jsdom, no testing-library -- so a + * component cannot be unit tested and pure logic has to live where a test can + * reach it. And exporting a function from a file that also exports a component + * breaks fast refresh, which is what `react-refresh/only-export-components` + * reported for as long as this lived there. + * + * Same split, for the same reason, as + * `features/skillInstall/lib/installSelection.ts`. + */ + +/** One piece of a parsed description: plain text, or a link with its own + * display text and target url. Structurally identical to the backend's + * generated `DescriptionSpan`, declared locally so this generic component has + * no dependency on the `services` layer -- any concrete `DescriptionSpan[]` + * value satisfies it. */ +export type DescriptionSpan = { kind: 'text'; text: string } | { kind: 'link'; text: string; url: string }; + +/** One span plus a stable React key. Keyed by position: spans never reorder + * once parsed, so a position-based key stays distinct even when two link + * spans repeat the same text and url. */ +export type KeyedDescriptionSpan = DescriptionSpan & { readonly key: string }; + +export function spansToKeyedParts(spans: readonly DescriptionSpan[]): KeyedDescriptionSpan[] { + return spans.map((span, index) => ({ ...span, key: String(index) })); +} From 2b5eedef9bf269ca40c051fe903bb420c4aee604 Mon Sep 17 00:00:00 2001 From: Lorem Dev Date: Wed, 9 Sep 2026 13:17:04 +0200 Subject: [PATCH 2/3] feat: publish a static musl CLI for Linux and pick it by host glibc The prebuilt *-linux-gnu CLI is linked on the release runner (ubuntu-22.04, glibc 2.35) and a newer glibc runs older binaries but not the reverse, so on an older distribution it exits with "libc.so.6: version `GLIBC_2.34' not found" before reaching main(). Releases now also carry a statically linked musl archive per architecture, which depends on no system libc; this costs one extra cargo build and no new tooling, since every crate in the CLI's tree is pure Rust with no -sys dependency. install.sh reads the host's glibc and picks between the two, falling back to the gnu archive only on an HTTP error (an older release has no musl asset) and never on a transport failure, which would otherwise misreport a flaky network as a missing asset. A binary that cannot start now names the remedy and exits non-zero instead of reporting success. CI builds x86_64 musl and cross-checks aarch64 so the first musl link never happens on a tag, and the publish job fails if any of the eight CLI archives is absent rather than shipping a release with a hole in it. The new check-glibc-floor skill keeps the floor consistent across the workflow, the installer, the docs and the download labels, and is part of pre-release-check. The desktop app is unaffected and keeps the glibc 2.35 floor: it links the distribution's WebKitGTK and so cannot be static. --- .agents/skills/check-glibc-floor/SKILL.md | 247 ++++++++++++++++++++++ .agents/skills/pre-release-check/SKILL.md | 17 +- .github/workflows/ci.yml | 21 ++ .github/workflows/release.yml | 84 +++++++- AGENTS.md | 5 +- CHANGES.md | 17 ++ README.md | 9 +- docs/getting-started.md | 18 ++ docs/troubleshooting.md | 34 +++ scripts/install.sh | 187 ++++++++++++++-- scripts/release-downloads.mjs | 24 ++- 11 files changed, 634 insertions(+), 29 deletions(-) create mode 100644 .agents/skills/check-glibc-floor/SKILL.md diff --git a/.agents/skills/check-glibc-floor/SKILL.md b/.agents/skills/check-glibc-floor/SKILL.md new file mode 100644 index 00000000..a7dc8ba7 --- /dev/null +++ b/.agents/skills/check-glibc-floor/SKILL.md @@ -0,0 +1,247 @@ +--- +name: check-glibc-floor +description: > + Verify the Linux glibc floor is consistent everywhere it is stated: the + release workflow's runner image is the source of truth, and scripts/install.sh, + docs/, and the release download labels must all agree with it. Also confirms + the statically linked musl CLI is still built and published. +--- + +# check-glibc-floor + +The prebuilt `*-linux-gnu` CLI and the desktop bundles are linked against the +glibc of the runner image that builds them. glibc versions its symbols, and the +compatibility runs one way only: a newer glibc runs older binaries, an older one +cannot run newer binaries. So that image's version is a hard floor -- below it +the binary exits with ``libc.so.6: version `GLIBC_2.34' not found`` before +running any code. + +That floor is a single fact restated across six files: `release.yml` itself, +`scripts/install.sh`, `scripts/release-downloads.mjs`, `docs/getting-started.md`, +`docs/troubleshooting.md`, and `CHANGES.md`. Several of them say it more than +once, so check every occurrence rather than counting them. This skill verifies +they all still say the same number, and that the musl escape hatch is still +shipped. + +**The source of truth is the Linux runner image in +`.github/workflows/release.yml`.** Nothing else. When they disagree, the image +is right and the other places are stale. + +## Runner image to glibc + +| Image | glibc | +|---|---| +| `ubuntu-20.04` | 2.31 (image retired; here for reading older workflows) | +| `ubuntu-22.04`, `ubuntu-22.04-arm` | 2.35 | +| `ubuntu-24.04`, `ubuntu-24.04-arm` | 2.39 | +| `ubuntu-latest` | whatever it currently aliases (2.39 at the time of writing) | + +If the image is not in this table, look up its glibc before continuing rather +than guessing, and add the row. + +## Steps + +### 1. Read the floor from the release workflow + +```bash +grep -n "os: ubuntu" .github/workflows/release.yml +``` + +Map every Linux leg through the table above. Expect one image across all Linux +legs; if the x64 and arm64 legs differ, the floor is the HIGHER of the two and +that divergence is itself a finding worth reporting. + +`ubuntu-latest` on a Linux leg is a finding on its own: it is a moving alias, so +it silently raises the floor whenever the platform re-points it. The Linux legs +must pin an explicit image. + +Call the result FLOOR (for example `2.35`). + +### 2. Verify scripts/install.sh + +```bash +grep -n "MIN_GLIBC_MAJOR\|MIN_GLIBC_MINOR" scripts/install.sh +``` + +- `MIN_GLIBC_MAJOR` and `MIN_GLIBC_MINOR` must equal FLOOR. + +Then confirm each of these is still present and wired up; every one of them is +load-bearing, and the script silently installs an unrunnable binary if any is +dropped: + +- `glibc_version` detects the host version, trying `getconf GNU_LIBC_VERSION` + first and `ldd --version` second, and yields nothing on a musl host. +- It rejects any value that is not a dotted number, because the comparison + below it is arithmetic. +- `linux_libc` compares against `MIN_GLIBC_*` and returns `musl` when the host + is below the floor, when there is no glibc at all, and when the version could + not be read. +- `SKILLKEEPER_LIBC` is validated at the TOP LEVEL of the script, not inside + `linux_libc`. `linux_libc` runs in a command substitution, where `err` would + exit only the subshell and leave the caller building a truncated target + triple. +- The Linux branch composes the triple from the detected libc, rather than + hardcoding `-gnu`. +- A missing musl asset falls back to the gnu archive with a warning (older + releases predate the musl archives), and that fallback is skipped when + `SKILLKEEPER_LIBC` named a flavour explicitly. +- The final smoke run points at `SKILLKEEPER_LIBC=musl` when the installed gnu + binary fails to start. + +Exercise the detection rather than only reading it. This stubs `getconf` and +`ldd` on PATH, sources the two real functions out of the script, and reads the +real `MIN_GLIBC_*`, so it tests the shipped threshold rather than a copy of it. +It runs anywhere, the developer's macOS included: + +```bash +d=$(mktemp -d) +# Anchored on the function syntax, not on comment text, and then checked: a +# range that failed to find its end would run to EOF and capture the whole +# install flow, which the `.` below would EXECUTE -- downloading and writing to +# $HOME, once per row. The two guards make that impossible rather than unlikely. +sed -n '/^glibc_version() {/,/^}/p;/^linux_libc() {/,/^}/p' scripts/install.sh > "$d/funcs.sh" +grep -qE 'download |INSTALL_DIR|asset_url|Done\.' "$d/funcs.sh" && + { echo "ABORT: extraction captured installer flow, do not source it"; exit 1; } +[ "$(grep -c '^}' "$d/funcs.sh")" = 2 ] || + { echo "ABORT: expected exactly 2 functions"; exit 1; } + +# Heredocs, not printf: a printf format string containing %s consumes it +# itself and writes a stub that ignores its argument, which silently turns +# every row below into "no glibc" and the whole table into musl. +cat > "$d/getconf" <<'EOF' +#!/bin/sh +[ "${1:-}" = GNU_LIBC_VERSION ] || exit 1 +[ -n "${SK_GLIBC:-}" ] || exit 1 +printf 'glibc %s\n' "$SK_GLIBC" +EOF +cat > "$d/ldd" <<'EOF' +#!/bin/sh +echo "musl libc (x86_64)" +EOF +chmod +x "$d/getconf" "$d/ldd" + +for v in 2.28 2.31 2.34 2.35 2.39 ""; do + out=$(SK_GLIBC="$v" PATH="$d:$PATH" SK_F="$d/funcs.sh" sh -c ' + eval "$(grep "^MIN_GLIBC_" scripts/install.sh)" + LIBC=auto + err() { echo "error: $1" >&2; exit 1; } + . "$SK_F" + linux_libc') + printf 'glibc %-6s -> %s\n' "${v:-none}" "$out" +done +rm -rf "$d" +``` + +Expected, for a floor of 2.35. The boundary is exercised from both sides, and +`none` stands for a host with no glibc at all, as on Alpine: + +``` +glibc 2.28 -> musl +glibc 2.31 -> musl +glibc 2.34 -> musl +glibc 2.35 -> gnu +glibc 2.39 -> gnu +glibc none -> musl +``` + +Any `ABORT` line, or a table differing from this one, is a FAIL: either the +detection changed or the extraction no longer matches the functions. Do not +adjust the test to make it pass. Also run `sh -n scripts/install.sh`. + +### 3. Verify the musl builds are still published + +```bash +grep -n "musl" .github/workflows/release.yml +``` + +- Both Linux legs carry a `musl_target` (`x86_64-unknown-linux-musl` and + `aarch64-unknown-linux-musl`); every non-Linux leg carries an empty one. +- The toolchain step installs the musl target alongside the leg's own. +- A `Build the CLI (static, musl)` step and its `tar.gz` archive step both run + when `musl_target` is non-empty, and the archive lands in `dist-cli/`, which + the upload step collects. + +The musl archive is published ALONGSIDE the gnu one, never instead of it: +`SKILLKEEPER_VERSION` can pin an older release, and existing installs resolve +the asset name they already know. + +### 4. Verify the documentation states FLOOR + +```bash +grep -rn "glibc" docs/ README.md CHANGES.md +``` + +`CHANGES.md` is in scope deliberately: it states the floor in the entry that +introduced the musl builds, and a runner-image bump would otherwise leave the +changelog quietly asserting the old number while this gate reported CONSISTENT. + +Every stated version must equal FLOOR. At the time of writing that is: + +- `docs/getting-started.md` -- the System requirements table (CLI and desktop + rows) and the install-script paragraph. +- `docs/troubleshooting.md` -- the `GLIBC_2.34' not found` section. +- `CHANGES.md` -- the musl entry under the current version. + +Distribution versions named as remedies are part of this check, not decoration: +verify each one actually clears FLOOR before repeating it. RHEL 9, for +instance, ships glibc 2.34 and does NOT clear a floor of 2.35. + +The troubleshooting section must still say all of: the download is not corrupt, +the musl build is the fix, `SKILLKEEPER_LIBC=musl` is the command, building from +source also works, and upgrading glibc in place is not the answer (the +distribution release pins it). Removing the last point invites someone to break +a machine. + +### 5. Verify the release download labels + +```bash +grep -n "note:" scripts/release-downloads.mjs +``` + +The two `*-linux-gnu` entries must carry a `glibc +` note and the two +`*-linux-musl` entries a `static, musl` note, so a reader picking an asset by +hand can tell which one runs on their host. All four Linux triples must be in +`CLI_TARGETS`; an unlisted one degrades to a bare triple label. + +### 6. Check whether the floor rose since the last release + +This is the one case that reaches users who already have the app installed. + +```bash +git diff "$(git describe --tags --abbrev=0)" -- .github/workflows/release.yml | grep -E "^[+-].*os: ubuntu" +``` + +No `..HEAD` on purpose: that form compares committed history only, so during +release prep -- exactly when this skill runs -- a runner-image bump still +sitting in the working tree would read as "floor unchanged". The two-dot-free +form covers committed and uncommitted alike. No output means the image did not +move. + +If the Linux runner image changed since the last release tag, say so loudly and +report the old and new floors. A raised floor means hosts between the two +versions run the current desktop app fine but cannot run the next one, and the +self-updater has no glibc gate: `platform.rs::asset_key` selects on os/arch +only, and `versions.json` records no minimum (see `scanAssets` in +`scripts/gen-versions-json.mjs`). Those hosts would download, install, and end +up with an app that will not start. + +Raising the floor is therefore a release decision, not an incidental bump. Flag +it for the developer; do not decide it inside this skill. + +### 7. Report + +``` +runner image / floor: ubuntu-22.04 -> glibc 2.35 +install.sh MIN_GLIBC_*: PASS / FAIL +install.sh detection: PASS / FAIL +musl builds published: PASS / FAIL +docs state the floor: PASS / FAIL +download labels: PASS / FAIL +floor unchanged since tag: PASS / RAISED (2.35 -> 2.39) + +glibc floor: CONSISTENT / INCONSISTENT +``` + +List every mismatch with its file and line and the value it should hold. Do not +edit files automatically -- propose the corrections and let the developer apply +them. diff --git a/.agents/skills/pre-release-check/SKILL.md b/.agents/skills/pre-release-check/SKILL.md index 948fbe29..ec38345a 100644 --- a/.agents/skills/pre-release-check/SKILL.md +++ b/.agents/skills/pre-release-check/SKILL.md @@ -2,9 +2,9 @@ name: pre-release-check description: > Gate a release by running check-changes, check-docs, run-tests-and-linters, - check-licenses, and check-fixture-repo, plus verifying the version bump and - that all commits since the last release follow the conventional-commits - format. + check-licenses, check-fixture-repo, and check-glibc-floor, plus verifying the + version bump and that all commits since the last release follow the + conventional-commits format. --- # pre-release-check @@ -13,7 +13,7 @@ Run the full release gate. All checks must pass before tagging a release. ## Steps -### 1. Run the five component skills +### 1. Run the six component skills Run each skill in order and collect its result (PASS or FAIL with details): @@ -25,7 +25,13 @@ Run each skill in order and collect its result (PASS or FAIL with details): against a real working tree, so it catches wiring regressions the unit tests (which run against an in-memory filesystem) cannot see. 4. **check-docs** -- README.md, docs/ nav, command accuracy, version refs. -5. **check-changes** -- CHANGES.md Development section vs. commit history. +5. **check-glibc-floor** -- the Linux glibc floor agrees across the release + workflow, `scripts/install.sh`, docs/, and the download labels, and the musl + CLI is still published. Belongs in the release gate because the floor is set + by the runner image the release itself builds on: a bumped image quietly + invalidates every stated version, and a raised floor strands hosts that the + self-updater will still offer the update to. +6. **check-changes** -- CHANGES.md Development section vs. commit history. If check-licenses, run-tests-and-linters, or check-fixture-repo fails, report the failure and stop. The remaining checks can still be reported for completeness, @@ -86,6 +92,7 @@ check-licenses: PASS / FAIL run-tests-and-linters: PASS / FAIL check-fixture-repo: PASS / FAIL check-docs: PASS / FAIL +check-glibc-floor: PASS / FAIL check-changes: PASS / FAIL tag provenance (branch): PASS / FAIL version bump consistent: PASS / FAIL diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bede7f26..9d7a818c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -163,6 +163,11 @@ jobs: uses: dtolnay/rust-toolchain@4716b85f2fac3e324e64fa2810f6b5c3905760a5 # 1.97.1 with: components: rustfmt, clippy + # The two musl targets the release builds the static CLI for, in the + # same comma-separated form release.yml passes. Installing them here + # means a broken toolchain pin or a mistyped target list fails on a + # pull request instead of on the tag, where it would kill the release. + targets: x86_64-unknown-linux-musl,aarch64-unknown-linux-musl - name: Cache cargo registry and target uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 @@ -208,6 +213,22 @@ jobs: - name: Test workspace run: cargo test --workspace + # The release publishes a statically linked CLI for Linux (see the + # "Build the CLI (static, musl)" step in release.yml). Without this, the + # first musl link of any change happens on the tag -- and a failure there + # stops `publish`, so the tag is dead and has to be deleted and re-cut. + # + # x64 gets a full `build`, which actually links, because that is what the + # release does. arm64 gets `check`: `cargo check` never invokes the + # linker, so it cross-compiles here without an aarch64 musl linker, while + # still proving the target is installed and the tree compiles for it. + # Only the CLI is built -- the desktop app links the system WebKitGTK and + # cannot be static. + - name: Build the CLI for musl (release parity) + run: | + cargo build -p skillkeeper-cli --target x86_64-unknown-linux-musl + cargo check -p skillkeeper-cli --target aarch64-unknown-linux-musl + # `cargo test` WRITES the generated artifacts (ts-rs bindings, the mcp.yml # JSON Schema) rather than asserting them, so a commit that changes a Rust # type but forgets to commit the regenerated file is green here and only diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 00dec0fc..6d7cc9b4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -111,35 +111,47 @@ jobs: fail-fast: false matrix: include: + # `musl_target` is the extra, statically linked CLI-only target this + # leg also builds. It is empty everywhere but Linux: the desktop + # bundle links the system WebKitGTK stack and cannot be static, and + # only glibc's symbol versioning creates the problem musl solves. + # See the "Build the CLI (static)" step for the full reasoning. + # # macOS Apple Silicon - os: macos-latest target: aarch64-apple-darwin args: '--target aarch64-apple-darwin' + musl_target: '' # macOS Intel (cross-compiled on the arm64 runner) - os: macos-latest target: x86_64-apple-darwin args: '--target x86_64-apple-darwin' + musl_target: '' # Linux x64. ubuntu-22.04 keeps the AppImage's glibc floor low for # broader distro compatibility. - os: ubuntu-22.04 target: x86_64-unknown-linux-gnu args: '' + musl_target: x86_64-unknown-linux-musl # Linux arm64, built natively on the arm64 runner (free for public # repositories). Same 22.04 image as the x64 leg, so both AppImages # share a glibc floor. - os: ubuntu-22.04-arm target: aarch64-unknown-linux-gnu args: '' + musl_target: aarch64-unknown-linux-musl # Windows x64 - os: windows-latest target: x86_64-pc-windows-msvc args: '' + musl_target: '' # Windows arm64 (cross-compiled on the x64 runner, as macOS Intel is). # Only the Rust target changes; Node, pnpm and the frontend build stay # on the x64 image. - os: windows-latest target: aarch64-pc-windows-msvc args: '--target aarch64-pc-windows-msvc' + musl_target: '' steps: - name: Checkout repository @@ -167,6 +179,14 @@ jobs: # documented subset. A missing patchelf fails the arm64 leg, and # `publish` needs every leg -- so the whole release is blocked to save # one small download. + # Deliberately no musl-tools. rustc links the musl targets + # self-contained (it ships musl's libc.a and crt objects with + # rust-std) and drives the link through the plain host `cc`, so the + # pure-Rust CLI never invokes musl-gcc. Nor would the package buy + # future-proofing: if a crate in that tree ever grew a C dependency, + # `cc` looks for `aarch64-linux-musl-gcc` on the arm64 leg, which + # musl-tools does not ship -- that case needs a real per-arch musl + # cross toolchain, not this. sudo apt-get install -y -o DPkg::Lock::Timeout=300 \ patchelf \ libwebkit2gtk-4.1-dev \ @@ -176,7 +196,8 @@ jobs: - name: Set up Rust toolchain uses: dtolnay/rust-toolchain@4716b85f2fac3e324e64fa2810f6b5c3905760a5 # 1.97.1 with: - targets: ${{ matrix.target }} + # Both this leg's target and, on Linux, its static musl companion. + targets: ${{ matrix.musl_target != '' && format('{0},{1}', matrix.target, matrix.musl_target) || matrix.target }} - name: Cache cargo registry and target uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 @@ -442,6 +463,39 @@ jobs: tar -czf "dist-cli/skillkeeper-cli-${{ matrix.target }}.tar.gz" \ -C "target/${{ matrix.target }}/release" skillkeeper + # ----------------------------------------------------------------------- + # Second Linux CLI: statically linked against musl. + # + # The gnu binary above is linked on this runner's glibc (2.35 on the + # 22.04 images). glibc versions its symbols and is only forward + # compatible, so that binary will not start on an older host -- it dies + # with "libc.so.6: version `GLIBC_2.34' not found" before main() runs. + # A musl build carries its own libc and depends on nothing, which puts + # the whole distro question to rest for the CLI. + # + # This costs one extra `cargo build` and no new tooling: every crate in + # the CLI's tree is pure Rust (git is a subprocess, there is no TLS or + # OpenSSL), so the static link needs no C sysroot. It is published + # ALONGSIDE the gnu archive, not instead of it -- install.sh picks + # between them from the host's glibc, and existing pinned installs keep + # resolving the asset name they already know. + # + # Keep the glibc floor in scripts/install.sh and docs/ in step with the + # runner image above; the `check-glibc-floor` skill verifies that. + # ----------------------------------------------------------------------- + - name: Build the CLI (static, musl) + if: matrix.musl_target != '' + shell: bash + run: cargo build --release -p skillkeeper-cli --target ${{ matrix.musl_target }} + + - name: Archive the CLI (tar.gz, musl) + if: matrix.musl_target != '' + shell: bash + run: | + mkdir -p dist-cli + tar -czf "dist-cli/skillkeeper-cli-${{ matrix.musl_target }}.tar.gz" \ + -C "target/${{ matrix.musl_target }}/release" skillkeeper + - name: Archive the CLI (zip) if: matrix.os == 'windows-latest' shell: pwsh @@ -529,6 +583,34 @@ jobs: echo "Staged $(find dist -type f | wc -l) files:" ls -1 dist + # The staging step above only rejects DUPLICATE basenames, and + # upload-artifact is set to `warn` on no files found -- so a matrix or + # `if:` regression that silently skips one archive step would publish a + # release missing that asset, and nothing would fail. That is not a + # cosmetic gap: scripts/install.sh reacts to a missing musl archive by + # falling back to the glibc one with a warning, so an old-distribution + # host would install a binary that cannot start. Name every CLI archive + # the two installers can ask for and fail the release if one is absent. + - name: Verify every CLI archive was built + run: | + missing='' + for a in \ + skillkeeper-cli-aarch64-apple-darwin.tar.gz \ + skillkeeper-cli-x86_64-apple-darwin.tar.gz \ + skillkeeper-cli-x86_64-unknown-linux-gnu.tar.gz \ + skillkeeper-cli-aarch64-unknown-linux-gnu.tar.gz \ + skillkeeper-cli-x86_64-unknown-linux-musl.tar.gz \ + skillkeeper-cli-aarch64-unknown-linux-musl.tar.gz \ + skillkeeper-cli-x86_64-pc-windows-msvc.zip \ + skillkeeper-cli-aarch64-pc-windows-msvc.zip; do + [ -f "dist/$a" ] || missing="$missing $a" + done + if [ -n "$missing" ]; then + for a in $missing; do echo "::error::Missing release asset: $a"; done + exit 1 + fi + echo "All 8 CLI archives present." + # The mcp.yml JSON Schema is generated from the Rust types by `cargo test` # and committed, so it ships straight from the checkout -- no build leg # produces it. It must be attached to EVERY release: the docs tell editors diff --git a/AGENTS.md b/AGENTS.md index 1c57784f..059da292 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -390,7 +390,7 @@ backend events via `listen`. It imports the ts-rs-generated types under ## Local Development Skills -Seven skills live under `.agents/skills/`. Invoke them when the situation calls +Eight skills live under `.agents/skills/`. Invoke them when the situation calls for it: | Skill | When to use | @@ -401,7 +401,8 @@ for it: | `run-tests-and-linters` | Before marking any task done -- run the full gate (lint, typecheck, test:cov at 90%). | | `check-fixture-repo` | After touching resolution, install, hooks, guidance, or MCP -- drive the built CLI against the `examples/test-repo` fixture end to end, in a throwaway state dir. The only check that exercises the real binary against a real working tree. | | `check-licenses` | After editing any `package.json` or `Cargo.toml` -- verify all npm and cargo dependencies are license-compliant and update LICENSE. | -| `pre-release-check` | Before cutting a release -- runs the five `check-*` and `run-*` skills above (not `bump-version`) plus version-bump and commit-format checks. | +| `check-glibc-floor` | After touching the release workflow's Linux legs, `scripts/install.sh`, or the stated system requirements -- verify the glibc floor agrees everywhere it is written and the static musl CLI is still published. The runner image is the source of truth. | +| `pre-release-check` | Before cutting a release -- runs the six `check-*` and `run-*` skills above (not `bump-version`) plus version-bump and commit-format checks. | --- diff --git a/CHANGES.md b/CHANGES.md index 9d66580e..a7669e78 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -40,6 +40,23 @@ ## Development +### Added + +- Statically linked musl builds of the CLI for Linux x64 and arm64 + (`skillkeeper-cli-*-unknown-linux-musl.tar.gz`), published alongside the + existing glibc ones. They depend on no system libc, so the CLI now runs on + distributions below glibc 2.35. + +### Fixed + +- The install script picks the Linux build from the host's own glibc, instead of + always downloading the glibc one and leaving older distributions with a binary + that exits with ``GLIBC_2.34' not found``. Override the choice with + `SKILLKEEPER_LIBC=gnu` or `SKILLKEEPER_LIBC=musl`. The desktop app is + unaffected and keeps its glibc 2.35 floor: it links the distribution's + WebKitGTK, so it has no static build. See + [System requirements](https://lorem-dev.github.io/skillkeeper/latest/getting-started/#system-requirements). + ## Version 0.7.1 ### Changed diff --git a/README.md b/README.md index 0c86b27c..bcdde52a 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,9 @@ for updates. Supported agents: Claude, Codex, Copilot, Cursor, and OpenCode. **CLI** (`skillkeeper`) -- one line, using only tools already on your system. The script detects your platform, downloads the matching CLI archive from the latest -release, and adds the binary to your PATH. +release, and adds the binary to your PATH. On Linux it reads the host's glibc and +picks between the glibc build and the statically linked musl one, so it installs +a binary that runs on old distributions too. macOS / Linux: @@ -61,8 +63,9 @@ irm https://raw.githubusercontent.com/lorem-dev/skillkeeper/main/scripts/install See [Getting Started](https://lorem-dev.github.io/skillkeeper/latest/getting-started/) for other options. -Hitting an install problem (for example macOS reporting the app as "damaged")? -See [Troubleshooting](https://lorem-dev.github.io/skillkeeper/latest/troubleshooting/). +Hitting an install problem -- macOS reporting the app as "damaged", or a Linux +``GLIBC_2.34' not found`` on start? See +[Troubleshooting](https://lorem-dev.github.io/skillkeeper/latest/troubleshooting/). --- diff --git a/docs/getting-started.md b/docs/getting-started.md index b105ddd7..f42dcbcf 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -11,6 +11,20 @@ Node.js 24+ and pnpm 11 are only needed to build the desktop app's renderer, not the CLI. +## System requirements + +| Platform | Requirement | +|---|---| +| Linux, CLI | Any distribution. Two builds ship: glibc (needs glibc 2.35 or newer) and static musl (needs no system libc). The install script picks one. | +| Linux, desktop app | glibc 2.35 or newer, plus WebKitGTK 4.1 and libsoup 3. No static build: the app links that stack from the distribution. | +| macOS | 11 or newer, Intel or Apple Silicon. | +| Windows | 10 or newer, x64 or arm64. | + +The glibc floor is where the release binaries are linked, not a policy choice; +check yours with `ldd --version`. Building from source links against the glibc +the host already has, so its floor is the Rust toolchain's own, far lower. For a +host below the floor, see [Troubleshooting](troubleshooting.md). + ## Installation SkillKeeper ships two front ends over the same core: @@ -43,6 +57,10 @@ irm https://raw.githubusercontent.com/lorem-dev/skillkeeper/main/scripts/install Override the install directory with `SKILLKEEPER_INSTALL_DIR`, or pin a specific release with `SKILLKEEPER_VERSION` (for example `v0.1.1`). +On Linux it also reads the host's glibc and takes the static musl build when +that is below 2.35 or absent, as on Alpine. Both builds are the same CLI; +override the choice with `SKILLKEEPER_LIBC=gnu` or `SKILLKEEPER_LIBC=musl`. + ### CLI: build from source The `skillkeeper` CLI is a Rust binary in this workspace. Build it with cargo: diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index b8632482..50ae34d4 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -66,6 +66,40 @@ Make sure the install directory is on your PATH, then verify with `skillkeeper version`. See the [CLI Reference](usage/cli.md) for the available commands. +### Linux: "version `GLIBC_2.34' not found" when running skillkeeper + +``` +/home/you/.local/bin/skillkeeper: /lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.34' not found (required by /home/you/.local/bin/skillkeeper) +``` + +The install worked and the binary is on disk; it cannot start. The glibc build +of the CLI is linked against glibc 2.35, and a newer glibc runs older binaries +but not the reverse, so it needs 2.35 or newer. Nothing is corrupt and +reinstalling changes nothing. + +Use the statically linked musl build instead, which carries its own libc: + +```shell +curl -fsSL https://raw.githubusercontent.com/lorem-dev/skillkeeper/main/scripts/install.sh | SKILLKEEPER_LIBC=musl sh +``` + +Current releases pick that build automatically, so the variable is only there to +override a wrong guess. A release older than the musl archives has no musl build +to install at all; there, build from source +(`cargo install --path crates/skillkeeper-cli`), which links against the glibc +you already have. + +Upgrading glibc in place is not the fix: the distribution release pins the +version and every binary on the system links it, so the supported route is a +full distribution upgrade using that distribution's own tool (Ubuntu 20.04 to +22.04 or later, Debian 11 to 12; note that even RHEL 9 ships 2.34, below the +floor). Installing glibc from a third-party repository or building it into +`/usr` is a known way to leave userspace unusable and needing a rescue +environment. On a host you cannot upgrade, use the musl build. + +The desktop app has the same floor and no musl build, because it links the +distribution's WebKitGTK. + ## Skills ### An orange `!` marker on an installed skill diff --git a/scripts/install.sh b/scripts/install.sh index 76d02229..2d45ed22 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -9,6 +9,7 @@ # Environment overrides: # SKILLKEEPER_VERSION release tag to install (default: latest) # SKILLKEEPER_INSTALL_DIR install directory (default: $HOME/.local/bin) +# SKILLKEEPER_LIBC Linux libc flavour: auto (default), gnu, or musl # # Windows users: use scripts/install.ps1 instead. set -eu @@ -17,12 +18,127 @@ REPO="lorem-dev/skillkeeper" BIN="skillkeeper" INSTALL_DIR="${SKILLKEEPER_INSTALL_DIR:-$HOME/.local/bin}" VERSION="${SKILLKEEPER_VERSION:-latest}" +LIBC="${SKILLKEEPER_LIBC:-auto}" + +# Lowest glibc the *-linux-gnu CLI can run on. It is the glibc of the runner +# image that links it in the release workflow (ubuntu-22.04 -> glibc 2.35): +# glibc versions its symbols, and a newer glibc runs older binaries but not the +# reverse, so a binary linked on 2.35 dies on an older host with +# "libc.so.6: version `GLIBC_2.34' not found" before it reaches main(). Hosts +# below the floor get the statically linked musl build instead, which has no +# libc dependency at all. +# +# Bump these together with the runner image in .github/workflows/release.yml +# and the requirements in docs/; the `check-glibc-floor` skill verifies that +# the three agree. +MIN_GLIBC_MAJOR=2 +MIN_GLIBC_MINOR=35 + +# Initialized before anything reads them. POSIX sh has no `local`, so without +# this an inherited environment variable named `libc` or `cpu` would reach the +# Linux-only logic below -- on macOS that drove the Linux fallback branch and +# then died on an unset `cpu`. +cpu="" +libc="" err() { printf 'error: %s\n' "$1" >&2 exit 1 } +warn() { + printf 'warning: %s\n' "$1" >&2 +} + +# Validated here rather than where it is used: `linux_libc` runs inside a +# command substitution, and an `err` there would exit only that subshell, +# leaving the caller to carry on with an empty answer. +case "$LIBC" in + auto | gnu | musl) ;; + *) err "SKILLKEEPER_LIBC must be auto, gnu or musl (got: $LIBC)" ;; +esac + +# Echo the host's glibc as "major.minor", or nothing at all when this is not +# glibc (a musl distro such as Alpine, where the musl build is the only +# option anyway). +glibc_version() { + v="" + if command -v getconf >/dev/null 2>&1; then + # A glibc-only variable: musl's getconf does not define it, so an empty + # answer here is itself informative. The redirect wraps the whole pipeline, + # not just getconf, so a host without awk cannot print at the user either. + v="$({ getconf GNU_LIBC_VERSION | awk '{ print $NF }'; } 2>/dev/null)" + fi + if [ -z "$v" ] && command -v ldd >/dev/null 2>&1; then + # Read from the whole output, not from line 1: on Debian and Ubuntu `ldd` + # is a bash script, and a host with an unconfigured locale makes bash print + # a setlocale warning ahead of it, which would hide both the musl marker + # and the version. musl's ldd says "musl libc" and carries no glibc + # version, so matching it anywhere leaves `v` empty -- the right answer. + # + # Every glibc ldd puts the version last on its `ldd (...) X.Y` line + # ("ldd (Ubuntu GLIBC 2.31-0ubuntu9.9) 2.31" -> 2.31), so take that field + # rather than the first version-shaped text on the line. + out="$(ldd --version 2>&1 || true)" + case "$out" in + *musl*) : ;; + *) v="$(printf '%s\n' "$out" | awk '/^ldd /{ print $NF; exit }')" ;; + esac + fi + + # Everything below compares with `-ge`, which must never be handed a + # surprise: accept only a dotted number, and keep just the first two parts. + case "$v" in + *.*) ;; + *) return 0 ;; + esac + major="${v%%.*}" + minor="${v#*.}" + minor="${minor%%.*}" + # Each part separately -- concatenating them would let an empty component + # ("2." -> major=2, minor="") pass and reach `test`, which answers with + # "Illegal number" on the user's terminal. Five digits or more is likewise + # not a glibc version and would overflow the comparison. + for part in "$major" "$minor"; do + case "$part" in + '' | *[!0-9]* | ?????*) return 0 ;; + esac + done + printf '%s.%s' "$major" "$minor" +} + +# Echo "gnu" or "musl" for this Linux host. +linux_libc() { + if [ "$LIBC" != "auto" ]; then + printf '%s' "$LIBC" + return 0 + fi + + v="$(glibc_version)" + if [ -z "$v" ]; then + printf 'musl' + return 0 + fi + major="${v%%.*}" + minor="${v#*.}" + if [ "$major" -gt "$MIN_GLIBC_MAJOR" ] || + { [ "$major" -eq "$MIN_GLIBC_MAJOR" ] && [ "$minor" -ge "$MIN_GLIBC_MINOR" ]; }; then + printf 'gnu' + else + printf 'musl' + fi +} + +# The `releases/latest/download/` path always redirects to the newest +# release, so a plain download needs no API call or extra tooling. +asset_url() { + if [ "$VERSION" = "latest" ]; then + printf 'https://github.com/%s/releases/latest/download/%s' "$REPO" "$1" + else + printf 'https://github.com/%s/releases/download/%s/%s' "$REPO" "$VERSION" "$1" + fi +} + # Detect OS + architecture and map them to the Rust target triple used in the # release asset names (skillkeeper-cli-.tar.gz). os="$(uname -s)" @@ -37,10 +153,13 @@ case "$os" in ;; Linux) case "$arch" in - x86_64 | amd64) target="x86_64-unknown-linux-gnu" ;; - aarch64 | arm64) target="aarch64-unknown-linux-gnu" ;; + x86_64 | amd64) cpu="x86_64" ;; + aarch64 | arm64) cpu="aarch64" ;; *) err "no prebuilt CLI for Linux $arch (build from source: cargo install --path crates/skillkeeper-cli)" ;; esac + # Two Linux builds ship per architecture; pick by the host's own glibc. + libc="$(linux_libc)" + target="${cpu}-unknown-linux-${libc}" ;; *) err "unsupported OS: $os (on Windows use scripts/install.ps1)" @@ -48,20 +167,22 @@ case "$os" in esac asset="skillkeeper-cli-${target}.tar.gz" +url="$(asset_url "$asset")" -# The `releases/latest/download/` path always redirects to the newest -# release, so a plain download needs no API call or extra tooling. -if [ "$VERSION" = "latest" ]; then - url="https://github.com/${REPO}/releases/latest/download/${asset}" -else - url="https://github.com/${REPO}/releases/download/${VERSION}/${asset}" -fi - -# Use whichever downloader is already installed. +# Use whichever downloader is already installed. `http_error` reports whether a +# failure was the server answering "no such asset" as opposed to the request +# never getting there -- the fallback below must not treat a flaky network as +# proof that an asset does not exist. if command -v curl >/dev/null 2>&1; then download() { curl -fsSL "$1" -o "$2"; } + # With -f, curl exits 22 on an HTTP error response. Every other code is + # transport (6 DNS, 7 connect, 28 timeout, ...). + http_error() { [ "$1" = 22 ]; } elif command -v wget >/dev/null 2>&1; then download() { wget -qO "$2" "$1"; } + # wget exits 8 when the server issued an error response, 4 on a network + # failure. + http_error() { [ "$1" = 8 ]; } else err "need curl or wget on PATH to download the release" fi @@ -70,7 +191,34 @@ tmp="$(mktemp -d)" trap 'rm -rf "$tmp"' EXIT INT TERM printf 'Downloading %s ...\n' "$asset" -download "$url" "$tmp/$asset" || err "download failed: $url" +# `|| rc=$?` rather than `if ! download`: `!` would make the status inside the +# branch 0, and the branch needs the real code to tell 404 from a dead network. +rc=0 +download "$url" "$tmp/$asset" || rc=$? +if [ "$rc" -ne 0 ]; then + # Releases cut before the musl builds existed carry only the gnu archive, and + # SKILLKEEPER_VERSION can still pin one. Fall back to it rather than failing + # outright -- but say plainly that it may not start here, so a GLIBC error a + # moment later is not a mystery. Only on a genuine HTTP error, and never when + # the flavour was named explicitly: that choice deserves a hard failure. + if [ "$libc" = "musl" ] && [ "$LIBC" = "auto" ] && http_error "$rc"; then + # "could not be fetched", not "is not in this release": the codes checked + # above cover every HTTP error, so this is usually a missing asset on an + # older release but can also be a 5xx or a rate limit. + warn "$asset could not be fetched; falling back to the glibc build" + warn "it needs glibc ${MIN_GLIBC_MAJOR}.${MIN_GLIBC_MINOR} or newer and may not run on this host" + asset="skillkeeper-cli-${cpu}-unknown-linux-gnu.tar.gz" + url="$(asset_url "$asset")" + # The gnu archive is what is being installed from here on, so the smoke run + # at the end must give the GLIBC hint. Without this it stayed "musl" and + # suppressed the hint in the one case it exists for. + libc="gnu" + printf 'Downloading %s ...\n' "$asset" + download "$url" "$tmp/$asset" || err "download failed: $url" + else + err "download failed: $url" + fi +fi printf 'Extracting ...\n' tar -xzf "$tmp/$asset" -C "$tmp" || err "failed to extract $asset" @@ -110,4 +258,17 @@ case ":${PATH}:" in esac printf 'Done. ' -"$INSTALL_DIR/$BIN" version || true +# A binary that cannot start says so here, in the shape of a linker error that +# reads like a corrupt download. Name the actual remedy instead of leaving the +# reader with "GLIBC_2.34 not found". +if ! "$INSTALL_DIR/$BIN" version; then + printf '\n' + warn "the binary was installed but did not run (see the output above)" + if [ "$libc" = "gnu" ]; then + warn "if that mentions GLIBC, this host is older than glibc ${MIN_GLIBC_MAJOR}.${MIN_GLIBC_MINOR};" + warn "reinstall the statically linked build with SKILLKEEPER_LIBC=musl set" + fi + # Exit non-zero: a binary that cannot start is a failed install, and + # `curl ... | sh && next-step` must not proceed as though it worked. + exit 1 +fi diff --git a/scripts/release-downloads.mjs b/scripts/release-downloads.mjs index 5e2d6f2c..f08d36a3 100644 --- a/scripts/release-downloads.mjs +++ b/scripts/release-downloads.mjs @@ -46,14 +46,25 @@ if (!existsSync(distDir)) { const url = (name) => `https://github.com/${REPO}/releases/download/${encodeURIComponent(rawTag)}/${encodeURIComponent(name)}`; -/** Rust target triple -> human platform, for the CLI archives. */ +/** + * Rust target triple -> human platform, for the CLI archives. + * + * `note` disambiguates two archives that are otherwise the same platform. Linux + * ships two builds per architecture and the difference decides whether the + * binary starts at all, so it has to be on the label: the gnu one is linked + * against the release runner's glibc, the musl one is static and depends on no + * libc. Keep the stated glibc version in step with the runner image in + * .github/workflows/release.yml (the `check-glibc-floor` skill verifies it). + */ const CLI_TARGETS = { 'aarch64-apple-darwin': { os: 'macOS', arch: 'Apple Silicon', rank: 1 }, 'x86_64-apple-darwin': { os: 'macOS', arch: 'Intel', rank: 2 }, 'x86_64-pc-windows-msvc': { os: 'Windows', arch: 'x64', rank: 3 }, 'aarch64-pc-windows-msvc': { os: 'Windows', arch: 'arm64', rank: 4 }, - 'x86_64-unknown-linux-gnu': { os: 'Linux', arch: 'x64', rank: 5 }, - 'aarch64-unknown-linux-gnu': { os: 'Linux', arch: 'arm64', rank: 6 }, + 'x86_64-unknown-linux-gnu': { os: 'Linux', arch: 'x64', rank: 5, note: 'glibc 2.35+' }, + 'aarch64-unknown-linux-gnu': { os: 'Linux', arch: 'arm64', rank: 6, note: 'glibc 2.35+' }, + 'x86_64-unknown-linux-musl': { os: 'Linux', arch: 'x64', rank: 7, note: 'static, musl' }, + 'aarch64-unknown-linux-musl': { os: 'Linux', arch: 'arm64', rank: 8, note: 'static, musl' }, }; /** Desktop bundle extension -> the format shown in the label. */ @@ -104,7 +115,7 @@ for (const name of readdirSync(distDir).sort()) { if (known) { cli.push({ name, - label: `CLI ${known.os} ${known.arch}`, + label: known.note ? `CLI ${known.os} ${known.arch} (${known.note})` : `CLI ${known.os} ${known.arch}`, rank: known.rank, }); } else { @@ -150,7 +161,10 @@ if (cli.length > 0) { '```\n\n' + '```powershell\n' + `irm https://raw.githubusercontent.com/${REPO}/main/scripts/install.ps1 | iex\n` + - '```', + '```\n\n' + + 'On Linux the script reads the host glibc and picks between the gnu and ' + + 'musl builds on its own; choose between them by hand only for a manual ' + + 'download.', ); } From 81ace3c48450125ff129d07c2cf8e62f3834ba14 Mon Sep 17 00:00:00 2001 From: Lorem Dev Date: Wed, 9 Sep 2026 13:18:19 +0200 Subject: [PATCH 3/3] release: 0.7.2 --- CHANGES.md | 2 ++ Cargo.lock | 10 +++++----- Cargo.toml | 2 +- apps/desktop/package.json | 2 +- apps/desktop/src-tauri/tauri.conf.json | 2 +- package.json | 2 +- packages/i18n/package.json | 2 +- 7 files changed, 12 insertions(+), 10 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index a7669e78..94dc22ff 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -40,6 +40,8 @@ ## Development +## Version 0.7.2 + ### Added - Statically linked musl builds of the CLI for Linux x64 and arm64 diff --git a/Cargo.lock b/Cargo.lock index 47a2c391..c9c9106c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4235,7 +4235,7 @@ checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] name = "skillkeeper" -version = "0.7.1" +version = "0.7.2" dependencies = [ "aes 0.9.3", "argon2", @@ -4270,14 +4270,14 @@ dependencies = [ [[package]] name = "skillkeeper-agents" -version = "0.7.1" +version = "0.7.2" dependencies = [ "skillkeeper-core", ] [[package]] name = "skillkeeper-cli" -version = "0.7.1" +version = "0.7.2" dependencies = [ "clap", "serde", @@ -4290,7 +4290,7 @@ dependencies = [ [[package]] name = "skillkeeper-config" -version = "0.7.1" +version = "0.7.2" dependencies = [ "serde", "serde_json", @@ -4302,7 +4302,7 @@ dependencies = [ [[package]] name = "skillkeeper-core" -version = "0.7.1" +version = "0.7.2" dependencies = [ "hex", "regex", diff --git a/Cargo.toml b/Cargo.toml index 9be11692..39d12770 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ members = [ [workspace.package] edition = "2021" -version = "0.7.1" +version = "0.7.2" license = "Apache-2.0" [workspace.dependencies] diff --git a/apps/desktop/package.json b/apps/desktop/package.json index ae4e3d41..fdd18701 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@skillkeeper/desktop", - "version": "0.7.1", + "version": "0.7.2", "private": true, "description": "Install and manage skills and hooks for AI coding agents", "author": { diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index e1945134..51ed4b9b 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "SkillKeeper", - "version": "0.7.1", + "version": "0.7.2", "identifier": "dev.lorem.skillkeeper", "build": { "beforeDevCommand": "pnpm exec vite", diff --git a/package.json b/package.json index 14810780..b4aaad41 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "skillkeeper", - "version": "0.7.1", + "version": "0.7.2", "private": true, "type": "module", "description": "Install and manage skills and hooks for AI coding agents", diff --git a/packages/i18n/package.json b/packages/i18n/package.json index ca5debf8..e3730efc 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -1,6 +1,6 @@ { "name": "@skillkeeper/i18n", - "version": "0.7.1", + "version": "0.7.2", "private": true, "author": "Lorem Dev", "homepage": "https://lorem-dev.github.io/skillkeeper/",