From 9c1c1b14ab5fa8fbb9a9ad7fab77cccb860eff1a Mon Sep 17 00:00:00 2001 From: Big Boss Date: Fri, 21 Aug 2026 11:49:39 -0500 Subject: [PATCH] feat(release): npm trusted publishing (OIDC); flake dev shell with ziglint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit npm publishing has been broken for two releases. v0.2.11 (2026-02-01) and v0.3.0 (today) both failed with a registry 404 — npm's way of saying the token is not authorized — while the tag, the GitHub release, and the manifests all looked correct. NPM_TOKEN was set 2025-11-28. A long-lived secret that expires silently is the failure mode; rotating it would just reset the clock. Trusted publishing removes the secret entirely: the publish job declares `id-token: write`, npm exchanges that short-lived workflow-scoped GitHub identity for publish rights, and each package names alleneubank/linear-cli + release.yml as its trusted publisher. The workflow FILENAME is part of the trust relationship — renaming it breaks publishing until every package's config is updated. It has to be `npm publish`: bun cannot present an OIDC identity (oven-sh/bun#22423, open since 2025-09) and fails with "missing authentication". bunfig.toml existed only to hand bun the token, so it goes with it. No NPM_TOKEN fallback is left behind — a fallback would re-arm exactly the failure mode this removes. Publishing from a public repo also gets provenance attestations for free. Also adds flake.nix, which is what makes `zig build lint` runnable: the step shells out to `ziglint` and dies with FileNotFound when it is absent, which is why it never ran anywhere. The shell pins zig_0_16 (0.16.0, matching CI — the toolchain is not interchangeable) plus ziglint and the jq both release scripts parse manifests with. Verified: `nix flake check` passes; `nix develop -c` reports zig 0.16.0, ziglint 0.5.3, jq 1.8.2; `zig build lint` now executes (it reports 14 pre-existing findings, left alone here); both workflows parse; the version gate and `zig fmt --check` still pass. The npmjs.com side (five packages) is configured separately in the web UI. --- .github/workflows/release.yml | 30 ++++++++++++++--- CHANGELOG.md | 20 ++++++++++++ CLAUDE.md | 2 ++ bunfig.toml | 2 -- flake.lock | 61 +++++++++++++++++++++++++++++++++++ flake.nix | 36 +++++++++++++++++++++ scripts/publish-npm.sh | 28 ++++++++++++---- 7 files changed, 167 insertions(+), 12 deletions(-) delete mode 100644 bunfig.toml create mode 100644 flake.lock create mode 100644 flake.nix diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e58c9a8..af1991e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -106,9 +106,33 @@ jobs: publish: needs: build runs-on: ubuntu-latest + permissions: + # Trusted publishing (OIDC). `id-token: write` is the whole auth story: + # npm exchanges this short-lived, workflow-scoped GitHub identity for + # publish rights, so there is no NPM_TOKEN to expire — which is exactly + # how v0.2.11 and v0.3.0 failed to publish. + # + # Each of the five packages names alleneubank/linear-cli + release.yml as + # its trusted publisher on npmjs.com. RENAMING THIS FILE BREAKS PUBLISHING + # until every package's trusted-publisher config is updated to match. + # + # Declared per-job, which drops the workflow-level `contents: write` here: + # publishing needs to read the repo, not write it. + contents: read + id-token: write steps: - uses: actions/checkout@v4 - - uses: oven-sh/setup-bun@v2 + - uses: actions/setup-node@v4 + with: + # Trusted publishing needs Node >= 22.14.0 and npm >= 11.5.1. + node-version: "24" + registry-url: "https://registry.npmjs.org" + - name: Ensure npm supports trusted publishing + # Node 24 already ships npm 11.x; this pins the floor rather than + # trusting whatever the runner image happens to bundle. + run: | + npm install -g npm@latest + npm --version - uses: actions/download-artifact@v4 with: name: npm-packages @@ -117,6 +141,4 @@ jobs: # upload-artifact does not preserve the mode; the packaged binary must # stay executable or the npm wrapper spawns a non-executable file. run: chmod +x npm/linear-cli-*/linear - - env: - NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }} - run: ./scripts/publish-npm.sh ${GITHUB_REF_NAME#v} + - run: ./scripts/publish-npm.sh ${GITHUB_REF_NAME#v} diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c28292..9cf2558 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,26 @@ Notable changes per release. Versions before 0.3.0 are recorded in the [GitHub releases](https://github.com/alleneubank/linear-cli/releases). +## Unreleased + +### Release engineering + +- npm publishing moves to **trusted publishing (OIDC)**. The release workflow + presents a short-lived, workflow-scoped GitHub identity via + `id-token: write` and npm exchanges it for publish rights, so there is no + long-lived `NPM_TOKEN`. The token had gone stale and silently broke npm + publishing for two releases (v0.2.11 and v0.3.0 both failed with a registry + 404), while the tag, the GitHub release, and the manifests all looked + correct. +- `scripts/publish-npm.sh` uses `npm publish` rather than `bun publish`: bun + cannot present an OIDC identity (oven-sh/bun#22423) and fails with "missing + authentication". `bunfig.toml` existed only to feed bun the token and is + removed. +- Publishing from a public repo with OIDC also produces provenance + attestations automatically. +- `flake.nix` provides a dev shell pinned to Zig 0.16.0 with `ziglint` and + `jq`, so `zig build lint` works instead of failing with `FileNotFound`. + ## 0.3.0 The first release since v0.2.11, covering the Zig 0.16 migration, a diff --git a/CLAUDE.md b/CLAUDE.md index 1b18a87..be432db 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,4 +36,6 @@ - Flags must reach the API or not exist. `issue delete --reason` was removed rather than deprecated because `issueDelete` takes only `(id, permanentlyDelete)` — the value was echoed into this CLI's own output and read like an audit trail in Linear. Do not add a flag whose only effect is on local output while naming something the API owns. - GraphQL client already handles HTTP status + GraphQL errors; retries only for 5xx with small backoff. Preserve explicit error messaging. - Releases are cut by pushing a `v*` tag. `build.zig` derives `--version` from `git describe --tags`, so the tag is the only source of truth for the binary; the four hand-maintained manifests (`package.json`, `.claude-plugin/plugin.json`, `npm/*/package.json` including its `optionalDependencies` pins) must agree with it, and `scripts/check-versions.sh` is the gate that proves it — it runs in CI on every push and again in the release workflow *before* anything is published. Bump all four together or the gate fails closed. One tag drives two channels: npm (`scripts/publish-npm.sh`) and GitHub release assets. The assets are `linear---.tar.gz` + `.sha256`, macOS/Linux on both arches, Linux statically linked against musl; the naming mirrors zmx and `alleneubank/agent-statusline` because that is the shape mise's `github:` backend resolves per platform. Do not add an `asset_pattern` on the consuming side — it replaces mise's per-arch matching instead of narrowing it, collapsing every platform onto whichever asset sorts first. +- npm publishing is **trusted publishing (OIDC)**, not a token. The release workflow's `publish` job declares `id-token: write` and npm exchanges that short-lived, workflow-scoped GitHub identity for publish rights; each of the five packages names `alleneubank/linear-cli` + the workflow **filename** as its trusted publisher on npmjs.com. Renaming `.github/workflows/release.yml` therefore breaks publishing until every package's config is updated to match — the filename is part of the trust relationship, not an implementation detail. It must be `npm publish`, never `bun publish`: bun cannot present an OIDC identity (oven-sh/bun#22423) and fails with "missing authentication", which is why `bunfig.toml` is gone. Do not reintroduce an `NPM_TOKEN` fallback — a stale token is precisely what silently broke publishing for v0.2.11 and v0.3.0 while every other signal looked green, and a fallback would re-arm that failure mode instead of surfacing it. +- `flake.nix` is the dev shell: Zig pinned to `zig_0_16` (matching CI and the README — the toolchain is not interchangeable), plus `ziglint` and `jq`. `zig build lint` shells out to `ziglint` and dies with `FileNotFound` without it, which is why the step is absent from CI; run it from `nix develop`. ziglint findings are advisory and are not a release gate. - Tests: offline unit coverage exists for config, flag parsing, printer; keep them passing. Online tests are gated by env and should remain optional. diff --git a/bunfig.toml b/bunfig.toml deleted file mode 100644 index 3a1f6bd..0000000 --- a/bunfig.toml +++ /dev/null @@ -1,2 +0,0 @@ -[install] -registry = { url = "https://registry.npmjs.org", token = "$NPM_CONFIG_TOKEN" } diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..e35cb6a --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1787135253, + "narHash": "sha256-RD2kNWCG+Bjo6h+JVjWVNntZs2GtRoeY2xHjts/FNkA=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "ffb3c9b700e759be2ef13237c9d8f953b32a1e46", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..de60376 --- /dev/null +++ b/flake.nix @@ -0,0 +1,36 @@ +{ + description = "linear-cli — a single-binary Linear client built with Zig"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = + { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem ( + system: + let + pkgs = nixpkgs.legacyPackages.${system}; + in + { + devShells.default = pkgs.mkShell { + packages = with pkgs; [ + # Pinned, not `zig`: 0.15.2 cannot link on macOS 26 and the 0.16 + # std.Io migration is not backward compatible, so the shell has to + # agree with .github/workflows/*.yml and the README. + zig_0_16 + + # `zig build lint` shells out to this binary and fails with + # FileNotFound when it is absent — which is why the step was left + # out of CI. .ziglint.zon holds the rule set. + ziglint + + # scripts/check-versions.sh and scripts/publish-npm.sh both parse + # the package manifests with jq. + jq + ]; + }; + } + ); +} diff --git a/scripts/publish-npm.sh b/scripts/publish-npm.sh index e828b62..ccc882e 100755 --- a/scripts/publish-npm.sh +++ b/scripts/publish-npm.sh @@ -1,24 +1,40 @@ #!/bin/bash +# Publish the five npm packages for a release. +# +# Auth is npm TRUSTED PUBLISHING (OIDC), not a token: the workflow grants +# `id-token: write`, npm exchanges that short-lived GitHub identity for publish +# rights, and each package on npmjs.com names this repo + workflow filename as +# its trusted publisher. There is no NPM_TOKEN to leak, rotate, or let expire — +# which is what broke v0.2.11 and v0.3.0. +# +# `npm publish`, not `bun publish`: bun cannot present an OIDC identity +# (oven-sh/bun#22423, open since 2025-09) and fails with "missing +# authentication". Do not switch this back for speed. +# +# Requires npm >= 11.5.1 and Node >= 22.14.0. Publishing from a public repo +# also gets provenance attestations for free. set -euo pipefail VERSION="${1:?Usage: publish-npm.sh }" -# Auth configured via bunfig.toml (reads $NPM_CONFIG_TOKEN) - -# Update versions +# Update versions. .github/workflows/release.yml has already run +# scripts/check-versions.sh --expect "$VERSION" against the committed +# manifests, so this only restates what the gate proved. for f in npm/*/package.json; do jq --arg v "$VERSION" '.version = $v' "$f" > tmp && mv tmp "$f" done jq --arg v "$VERSION" '.optionalDependencies |= with_entries(.value = $v)' \ npm/linear-cli/package.json > tmp && mv tmp npm/linear-cli/package.json -# Publish platform packages first, then main +# Platform packages first: the wrapper pins them as optionalDependencies, so +# publishing it first would leave a window where `npm install` resolves a +# wrapper whose binaries do not exist yet. for p in darwin-arm64 darwin-x64 linux-x64 linux-arm64; do echo "Publishing @0xbigboss/linear-cli-${p}..." - (cd "npm/linear-cli-${p}" && bun publish --access public) + (cd "npm/linear-cli-${p}" && npm publish --access public) done echo "Publishing @0xbigboss/linear-cli..." -(cd npm/linear-cli && bun publish --access public) +(cd npm/linear-cli && npm publish --access public) echo "Done"