Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
---
title: ADR-0018 — Dependency version policy for the published CLI
---

# ADR-0018 — Dependency version policy for the published CLI

**Status:** Proposed
**Date:** 2026-10-12
**Spec:** [Supply-chain hardening](/design/supply-chain-hardening/) (NIW2-312)

## Context

`laqi` is one published package whose eight runtime dependencies are installed by npm on the user's machine (`apps/cli/package.json`). Three of them (`hono`, `typescript`, `zod`) are pinned exactly; the catalog in the root `package.json` repeats the pins so every workspace resolves one copy, and a test keeps the two in step.

The pin on `hono@4.12.3` (2026-02-26) outlived 38 advisories, two in the CORS middleware laqi mounts on every mock, for seven months. No user received a fix, because the pin is what the registry hands out. The intent behind the pin was reproducibility, but an npm consumer never sees `bun.lock`: the transitive closure (40 packages) was already resolved by ranges on their machine. The exact pin bought staleness and nothing else.

A second question follows: if ranges are the rule, how does the version CI tested stay close to the version users resolve, and who moves it?

## Decision

1. **Every runtime dependency of the published CLI is a caret range** (`^MAJOR.MINOR.PATCH`) whose floor is the version `bun.lock` resolves. Exact pins and tilde ranges are not allowed in `apps/cli/package.json#dependencies`. The catalog holds the same range strings for the packages it shares with the CLI, so the single-copy guarantee inside the monorepo is unchanged.
2. **`bun.lock` is the tested version.** CI installs with `--frozen-lockfile`; a range change and its lockfile land in the same PR.
3. **Runtime dependencies stay unbundled.** tsdown folds in only the workspace packages and the SDKs it already bundles for tree-shaking reasons (`@modelcontextprotocol/sdk`, `@clack/*`). The eight runtime dependencies are installed by the consumer's package manager.
4. **Dependabot moves the floor**, weekly, with minor and patch updates grouped into one PR per ecosystem and majors as individual PRs; `bun` and `github-actions` ecosystems. A major bump of a runtime dependency is never grouped and carries a changelog-visible commit.
5. **The production gate is what ships, not the dev tree.** `bun audit --prod --audit-level=moderate` runs locally and in CI; the release gate additionally packs the tarball, installs it with npm in an empty directory, audits that install and runs the binary.

## Alternatives rejected

**Exact pins (status quo).** The strongest argument is that a user installs exactly what CI tested. It is false for a package without a consumer lockfile: only the direct dependency is fixed, its closure is not, and the direct one is then the only thing that cannot receive a patch. Rejected on the evidence of `hono@4.12.3`.

**Bundle every runtime dependency into `dist/`.** Zero install-time variance and a faster `npx`. Rejected: `chokidar` relies on the optional native `fsevents`, which a bundle cannot carry; `typescript` is 23 MB and loaded lazily on first use by design, which bundling would undo or complicate; every upstream patch would then require a laqi release, moving the staleness problem rather than solving it; and the tarball's licence and attribution surface grows by forty packages.

**Renovate instead of Dependabot.** Renovate's npm manager understands catalogs explicitly, pins action digests and maintains lockfiles. Rejected for now because it is a third-party GitHub App with write access to the repository, which is the kind of standing capability this work removes elsewhere, and Dependabot is native, keeps the `# vX.Y.Z` comment beside pinned SHAs, and GitHub documents Bun version updates. Reopen condition below.

**Tilde ranges for "risky" dependencies.** Patch-only ranges would narrow the gap between tested and resolved versions. Rejected: minor releases are where upstream ships most security fixes (hono's CORS fixes landed in 4.12.25 and 4.12.34, then 4.13.x), and a tilde would again hold users on an unpatched minor.

**`typescript` as an optional peer.** Saves 23 MB per install for users who never paste TypeScript models. Not rejected on merit; deferred, because `npx laqi` must keep working with no extra step and the generation stack is already lazy. Its own ticket.

## Consequences

**Good.** Users receive upstream patches on their next install without waiting for a laqi release. Advisory noise on `laqi` in downstream audits disappears as soon as the floor is raised. The repository has one place where versions live (the catalog), one place where the tested version lives (`bun.lock`), and a bot that moves both.

**Costs.** A fresh install may resolve a newer patch than CI tested; the weekly Dependabot cadence and the pack-install smoke in the release gate bound that gap to days and to "it installs, audits clean and starts". Dependabot's handling of `catalog:` entries is unverified at the time of this decision. Majors of runtime dependencies (`effect` 4, `chokidar` 5, `@hono/node-server` 2, `typescript` 6/7) will arrive as PRs and each needs a human decision; none is implied by this ADR.

## Non-goals

- Pinning or bundling policy for dev dependencies; they do not ship, and `bun.lock` already pins them for contributors.
- A consumer-side lockfile or `npm shrinkwrap`; a `npm-shrinkwrap.json` in the tarball would restore exact installs for users at the price of freezing the closure again, and is rejected for the same reason as exact pins.
- Provenance verification by consumers.

## Reopen condition

- Dependabot leaves `catalog:` entries untouched after two consecutive weekly runs: adopt Renovate for the `bun` ecosystem (keeping Dependabot for `github-actions` is acceptable).
- A runtime dependency ships a breaking change inside a minor or patch that reaches users before CI: narrow that one dependency to a tilde range with a comment naming the incident, and keep the rest.
- A bundled-everything build is measured to be smaller than the install it replaces and `chokidar` no longer needs `fsevents`: revisit bundling.
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
---
title: ADR-0019 — What the release pipeline trusts
---

# ADR-0019 — What the release pipeline trusts

**Status:** Proposed
**Date:** 2026-10-12
**Spec:** [Supply-chain hardening](/design/supply-chain-hardening/) (NIW2-312)
**Supersedes:** the "Tokens" section and the validation-skip paragraph of the addendum in [ADR-0010](/decisions/0010-release-and-npm/). The version topology, the dist-tag rule and the two-workflow split of ADR-0010 stand.

## Context

ADR-0010 built the release path: release-please cuts a tag, a tag-triggered workflow publishes with provenance. It left three trust decisions implicit, and the audit found all three wanting (verified on 2026-10-12 through the owner's account):

- The publish job authenticates with a long-lived `NPM_TOKEN`, a bearer capability that publishes `laqi@latest` to every team that runs `npx laqi`, held in an environment with no protection rules.
- Every action is addressed by a movable tag, while the jobs hold the npm token, a Cloudflare API token and a PAT with write on contents and pull requests.
- `main` has no protection, no ruleset guards `v*` tags, the default `GITHUB_TOKEN` permission is `write`, and a release is published after a build with no lint, type-check or tests on the tagged commit. Release PRs skip validation by branch name.

Other teams will run what this pipeline publishes. The question is not "is the pipeline convenient" but "what, exactly, does it trust, and is each of those things earned".

## Decision

The pipeline trusts **this repository's workflow files at a reviewed commit, verified on the same SHA, run by GitHub-hosted runners**, and nothing else.

1. **Publishing identity is the workflow, not a secret.** npm Trusted Publishing binds the right to publish `laqi` to `csdev19/laqi`, workflow file `release-npm.yml`, environment `production`. The job runs on Node 24 (npm ≥ 11.5.1) and sets no `NODE_AUTH_TOKEN`. Once the first OIDC publish succeeds, the package disallows tokens and the granular token is revoked.
2. **Actions are immutable references.** Every `uses:` is a full commit SHA with a version comment; the repository setting requires it; Dependabot moves them.
3. **Publishing is gated on the tagged SHA.** `release-npm.yml` and `deploy-site.yml` have a `verify` job (`bun run verify`, `bun run audit`, pack-and-install smoke) that the publishing job `needs:`. This is the hub's rule R1 applied; a tag is a trigger, not a certificate.
4. **The release PR is validated.** `validate.yml` runs on `release-please--*` branches; `Validate` is a required status check on `main`. A skipped job cannot be required, and a branch name is choosable by anyone with push.
5. **The repository enforces the path.** Ruleset on `main` (pull request required, `Validate` required, no force push, no deletion, no bypass); ruleset on `v*` and `site-v*` tags (creation, update and deletion restricted to repository admins, which is what lets release-please's PAT cut tags); `production` environment restricted to those tags plus `main` for dry runs, with a required reviewer; `RELEASE_PLEASE_TOKEN` in its own `release-please` environment restricted to `main`; default workflow token read-only; Dependabot alerts, secret scanning and push protection on.

## Alternatives rejected

**Keep `NPM_TOKEN`, rotate it, require 2FA.** The proven path with no cut-over risk. Rejected: rotation shortens exposure but a token at rest is still a publish capability that a compromised action or a leaked log can use; OIDC removes the object entirely and ties publishing to a reviewed workflow file. The cut-over is ordered so a working path exists at every step (spec, checklist B).

**Tag pinning with Dependabot "security updates" for actions.** Simpler to read. Rejected: GitHub's own guidance says a full-length SHA is the only immutable reference; a moved tag runs with `id-token: write`.

**Skip validation on release PRs, use a cheap gate job (hub `pr-checks` pattern).** Saves a run per merge to `main`. Rejected: the release PR is the last point before a tag exists, and the hub's normative release-gated-verification page names it the candidate tier that runs `verify`. The playbook's step 6 predates that page. Minutes on a public repository are the price.

**No required reviewer on `production`.** One click fewer per release; keeps "merging the release PR is the act of releasing" literally true. Kept as an option for the owner (spec open question 2). Rejected as the default because it is the only control that stops a stolen PAT from publishing unattended: the PAT can cut a tag (admin bypass on the tag ruleset is required for release-please), the tag triggers the publish, and without a reviewer nothing human stands between them.

**Download the verified artifact into the publish job.** Publishes the bytes that were verified. Rejected for now: same SHA, same lockfile, same Bun version; the hub asks for verification on the same SHA. Reopen if a build proves non-deterministic.

**Self-hosted or third-party runners.** Not considered: npm trusted publishing supports GitHub-hosted runners only.

## Consequences

**Good.** No publish secret exists to steal. Each action's code is fixed until a reviewed PR moves it. A release is published only after the full `verify` and an audit of the tarball on the tagged commit. Tag creation and merges to `main` go through the pipeline or not at all. The one remaining long-lived credential (`RELEASE_PLEASE_TOKEN`) can cut a tag but cannot publish without a human approval.

**Costs.** One approval click per release and per site deploy. One extra `Validate` run per merge to `main` (the release PR updates). Two environments instead of one. Dependabot opens weekly PRs that must be merged for the pins not to rot. The first OIDC publish cannot be rehearsed end to end; the recovery procedure is written down in the spec. The PAT still expires and will one day fail a tag cut (ADR-0010's cost, now halved).

## Non-goals

- Verifying provenance on the consumer side, SLSA levels beyond what npm records, Sigstore policy.
- Signed commits on `main` (a ruleset option; not required here because the pipeline trusts reviewed workflow files at a SHA, not authorship).
- Replacing the PAT for release-please with a GitHub App installation token. It would remove the last long-lived credential; deferred because the app's installation itself is a standing capability and the environment restriction already bounds the PAT.

## Reopen condition

- npm or GitHub changes the trusted-publishing contract (workflow-filename binding, environment binding, runner restriction) in a way that the checklist no longer satisfies.
- The owner finds the `production` approval click unacceptable in practice: drop the reviewer, keep the tag ruleset and the deployment policy, and record the trade in this ADR's status line.
- A second maintainer joins: the `main` ruleset should then require one approval, and the tag ruleset's bypass list should name people rather than a role.
- A build on the same SHA produces different bytes: switch the publish job to downloading `verify`'s artifact.
Loading
Loading