Release secrets runbook

June 1, 2026 · View on GitHub

One-time setup for the Phase 6 release pipeline. Everything here is configured through web UIs on each registry — not in code — because the state lives on crates.io / PyPI / npm / GitHub, not in this repo.

Written first-person so future-you isn't re-discovering it at 2am. Each step has a "verify" line you can run to confirm it took.

Read order: top to bottom. The dependencies run one way:

  • Sections 1 + 4 + 5 can be done right now (they don't reference workflow files).
  • Sections 2 + 3 (trusted publishers) point at release.yml, which has been live since Phase 6d. Each publisher activates on the first workflow run that matches its subject claim.

1. crates.io API token → CRATES_IO_TOKEN repo secret

Why this exists and not OIDC: Cargo / crates.io doesn't support OIDC trusted publishing yet (they're working on it upstream). So crates.io is the only registry still needing a long-lived token. Everything else (PyPI, npm) is OIDC.

Steps:

  1. Log in at https://crates.io/.
  2. Click your avatar → Account SettingsAPI TokensNew Token.
  3. Fill in:
    • Name: github-actions-release
    • Expiration: 1 year (set a calendar reminder to rotate).
    • Scopes: check only publish-new and publish-update. The other scopes (yank, change-owners) aren't needed for releases.
    • Crates: leave blank for now (scope-to-crate lands when we first publish; until then we need the token broad enough to create the sqlrite crate).
  4. Click Create. Copy the token — this is the only time it's visible.
  5. Back in GitHub: SettingsSecrets and variablesActions → under Environment secrets for the release environment (created in section 4 below), click New secret. Name: CRATES_IO_TOKEN. Value: paste the token.
    • Do NOT add this as a repo-level secret. Scoping to the environment means only jobs with environment: release can read it, and those jobs require maintainer approval before running (section 4).

Verify: run gh secret list --env release (GitHub CLI) and confirm CRATES_IO_TOKEN appears. Or: in the GitHub UI, open the release environment and check the secrets list.


2. PyPI trusted publisher

Why trusted publishing: no long-lived PyPI token. Every workflow run authenticates via short-lived OIDC tokens that GitHub Actions mints on the fly. Rotation is automatic; there's nothing to leak.

Package name: sqlrite on PyPI.

2a. Reserve the name

  1. Log in at https://pypi.org/.
  2. Search for sqlrite — confirm it's not already taken. (If it is, we'll need a different name; update sdk/python/pyproject.toml's name field + the napi.name field in sdk/nodejs/package.json for consistency and file an issue.)
  3. You can optionally pre-register an empty project via https://pypi.org/manage/projects/Register a project. Not strictly required — the first successful publish creates the project record. Pre-registering lets you configure the trusted publisher before the first release, which is what the next steps do.

2b. Add the publisher

  1. Go to https://pypi.org/manage/account/publishing/.
  2. Under Add a new pending publisher, fill in:
    • PyPI Project Name: sqlrite
    • Owner: joaoh82
    • Repository name: rust_sqlite
    • Workflow name: release.yml (the filename, not the path — PyPI looks for it at .github/workflows/release.yml)
    • Environment name: release (must match the name we create in section 4)
  3. Click Add. The publisher appears as "pending" until the first successful OIDC-authenticated publish; at that point PyPI swaps it to active automatically.

Verify: the publisher shows up in the list on the same page with status "pending". Once Phase 6d runs its first canary release, status flips to "active".


3. npm trusted publishers (three packages)

Why three: we publish @joaoh82/sqlrite (Node.js bindings from sdk/nodejs/), @joaoh82/sqlrite-wasm (browser bindings from sdk/wasm/), and sqlrite-notes (the chat-with-your-notes example from examples/nodejs-notes/) as separate npm packages. Each needs its own trusted-publisher record — set up §3a + §3b for the first two scoped packages and §3c for the unscoped example.

Why both are scoped: npm's registry rejects unscoped names that are too similar to existing popular packages — sqlrite is levenshtein-distance 1 from sqlite/sqlite3, and sqlrite-wasm would be distance 1 from sqlite-wasm. Scoping under @joaoh82 (the author's npm user scope) bypasses the check entirely — same pattern as @napi-rs/*, @swc/core, @aws-sdk/*. We learned this the hard way on the Node package during the v0.1.5 canary; for the WASM package we went scoped preemptively in Phase 6h.

3a. Publish a placeholder for each scoped package

npm requires the package to exist before you can configure a trusted publisher for it (no PyPI-style "pending publisher" flow as of late 2025). The bootstrap is a one-time manual publish of an empty 0.0.0 placeholder using your local credentials. Scoped packages under your own user scope are auto-owned, so no separate name reservation is needed beyond the publish itself.

For each of @joaoh82/sqlrite and @joaoh82/sqlrite-wasm:

mkdir /tmp/scoped-placeholder && cd /tmp/scoped-placeholder
cat > package.json <<'JSON'
{
  "name": "@joaoh82/sqlrite",
  "version": "0.0.0",
  "description": "Placeholder — real package ships from rust_sqlite CI",
  "license": "MIT"
}
JSON
npm login   # if not already
npm publish --access public
# Repeat for @joaoh82/sqlrite-wasm — change the name field.

The placeholder is harmless; the first CI release publishes a real 0.X.Y over the top.

3b. Trusted publisher for each package

For each placeholder you just published:

  1. Go to the package's settings page:
  2. Find the Trusted Publisher section (under Settings, not the package list).
  3. Add publisher:
    • Publisher: GitHub Actions
    • Organization or user: joaoh82
    • Repository: rust_sqlite (repo basename only — not joaoh82/rust_sqlite (npm prepends the owner field), and definitely not a full URL like the package's npmjs.com/package/…/access page. Pasting that URL into this field is exactly what silently broke the sqlrite-notes publish — see §3c.)
    • Workflow filename: release.yml (basename, not .github/workflows/release.yml)
    • Environment: release (case-sensitive — must match the environment: release block on the publish- jobs in the workflow)*
  4. Save.

Verify: each package's settings page should show the trusted publisher with status "active" after the first successful CI publish.

Why every field matters: the OIDC subject claim our workflow sends to npm is repo:joaoh82/rust_sqlite:environment:release. npm builds the matcher from the form fields above; if any field disagrees with the OIDC claim, npm responds 404 ("OIDC token exchange error - package not found"), which is npm's misleading way of saying "no trusted publisher record matches your token's claims". Burned us once on v0.1.7 (typo'd repo name in the form); kept the form field reference here so the next person doesn't have to re-debug.

3c. sqlrite-notes example — third npm package (SQLR-64)

The examples/nodejs-notes/ example ships as a third npm package so users can npx sqlrite-notes init <dir> on a fresh machine. The publish-notes-example job in release.yml handles it end-to-end with the same OIDC pattern as publish-nodejs.

Why unscoped: unlike sqlrite (rejected as too similar to sqlite), sqlrite-notes is far enough from any existing npm package that the similarity check shouldn't fire. If the placeholder publish in step 1 below is rejected, fall back to the scoped form @joaoh82/sqlrite-notes — update both examples/nodejs-notes/package.json's name field and the npm publish step / GitHub Release body in release.yml.

Bootstrap (one-time, with your local credentials):

mkdir /tmp/sqlrite-notes-placeholder && cd /tmp/sqlrite-notes-placeholder
cat > package.json <<'JSON'
{
  "name": "sqlrite-notes",
  "version": "0.0.0",
  "description": "Placeholder — real package ships from rust_sqlite CI",
  "license": "MIT"
}
JSON
npm login   # if not already
npm publish

If the unscoped name is rejected, retry with @joaoh82/sqlrite-notes and amend the repo per the note above.

Trusted publisher:

  1. Go to https://www.npmjs.com/package/sqlrite-notes/access (or …/package/@joaoh82/sqlrite-notes/access if you fell back to the scoped name).
  2. Add publisher with the same field values as @joaoh82/sqlrite above — Organization joaoh82, Repository rust_sqlite, Workflow filename release.yml, Environment release.
  3. Save.

Verify: status flips from "pending" to "active" after the first successful CI publish.

Status (resolved — SQLR-13, June 2026): sqlrite-notes is configured and publishing via OIDC; it shipped its first real version at 0.11.0.

Gotcha that bit us (SQLR-13): the trusted-publisher Repository field had been set to the package's npmjs access-page URL (https://www.npmjs.com/package/sqlrite-notes/access) instead of the bare repo name. The OIDC subject claim the workflow sends is repo:joaoh82/rust_sqlite:environment:release, so it didn't match the record, and every publish-notes-example run failed with OIDC token exchange error - package not found (npm's misleading 404 for "no trusted publisher matches your token's claims"). This surfaced only when #156 made the release idempotent and a re-dispatch finally attempted the first-ever sqlrite-notes publish — before that, the wave had always died earlier and never reached this job. Fix: Edit the publisher and set Repository to exactly rust_sqlite — no owner prefix, no URL.


4. GitHub release environment

Why an environment: gives us a second human-in-the-loop gate between the Release PR merge and the actual registry publishes. Even if the PR got auto-merged (say we later wire up a bot), the maintainer still has to click "Approve and deploy" before any job that writes to a registry runs.

Steps:

  1. Go to https://github.com/joaoh82/rust_sqlite/settings/environments.
  2. Click New environment → name it releaseConfigure environment.
  3. Required reviewers: check the box, add yourself (joaoh82). Optional: add any other maintainers who should be allowed to approve publishes.
  4. Wait timer: leave at 0 (no artificial delay).
  5. Deployment branches and tags: restrict to main — publishes should only ever run off a commit that landed on main. Select Selected branches and tags → add a rule for main.
  6. Save.

Secrets live here, not at the repo level. When you add CRATES_IO_TOKEN (section 1), use this environment's Environment secrets section. Same for any temporary NPM_TOKEN (section 3b).

Verify: in the release environment page you should see:

  • Required reviewer: yourself
  • Deployment branches: main
  • Secrets: CRATES_IO_TOKEN (+ NPM_TOKEN until OIDC takes over)

5. Branch protection on main

Why: this is what turns CI from "nice to have" into "actually blocks mistakes from reaching main". Also what makes the PR-based release flow necessary (hence the whole workflow-2 + workflow-3 split — see release-plan.md).

Steps:

  1. Go to https://github.com/joaoh82/rust_sqlite/settings/branches.
  2. Under Branch protection rules, click Add rule (or Add classic branch protection rule on newer GitHub UIs).
  3. Branch name pattern: main
  4. Require a pull request before merging: ✓
    • Require approvals: ✓, set to 1.
    • Dismiss stale pull request approvals when new commits are pushed: optional (helpful for team workflows; solo-dev, skip).
    • Require review from Code Owners: skip (no CODEOWNERS file yet).
  5. Require status checks to pass before merging: ✓
    • Require branches to be up to date before merging: optional (tightens but slows down; enable once there are multiple contributors).
    • Status checks that are required: add each CI job name from ci.yml. As they appear in GitHub's dropdown after the first CI run:
      • rust (ubuntu-latest)
      • rust (macos-latest)
      • rust (windows-latest)
      • rust lint
      • python-sdk (ubuntu-latest)
      • python-sdk (macos-latest)
      • python-sdk (windows-latest)
      • nodejs-sdk (ubuntu-latest)
      • nodejs-sdk (macos-latest)
      • nodejs-sdk (windows-latest)
      • go-sdk (ubuntu-latest)
      • go-sdk (macos-latest)
      • wasm-build
      • desktop-build
  6. Require conversation resolution before merging: ✓ — prevents merging a PR with unresolved review comments.
  7. Require linear history: optional. Nicer git log; merge commits are still fine without it.
  8. Do not allow bypassing the above settings: leave unchecked (you'll want admin bypass available for emergency fixes).
  9. Save.

Verify: open a draft PR from any branch. The merge button should be disabled until CI passes + you have a review.


Verification checklist

Run through this once everything above is done:

  • gh secret list --env release shows CRATES_IO_TOKEN.
  • The release environment requires you as a reviewer and restricts to main.
  • PyPI trusted-publisher page shows rust_sqlite / release.yml / release pending for the sqlrite project.
  • npm trusted-publisher page shows the same for all three of @joaoh82/sqlrite, @joaoh82/sqlrite-wasm, and sqlrite-notes (assuming the placeholders are published per §3a / §3c — if not, those sections apply).
  • Branch protection on main requires 14 status checks + 1 review.
  • Open a dummy PR — the "Merge" button is greyed out until CI green + review given.

What isn't here (deferred follow-ups)

These are explicitly out of Phase 6c scope but worth capturing so they aren't forgotten:

  • Apple Developer ID certificate for macOS Tauri DMG signing → Phase 6.1. Procurement: $99/year at https://developer.apple.com/programs/.
  • Windows code-signing certificate for Tauri MSI signing → Phase 6.1. Procurement: ~$300/year from Sectigo / DigiCert / etc.; EV certs are pricier but skip the Windows SmartScreen "this publisher is unknown" warning.
  • Dependabot or renovate to keep deps fresh — meta-tooling, not release tooling. Can land any time.
  • CODEOWNERS file once there are multiple maintainers.
  • Required review from multiple reviewers once there are multiple maintainers.

Rolling back

If something goes sideways:

  • Revoke CRATES_IO_TOKEN: crates.io → Account Settings → API Tokens → Revoke. Generate a new one.
  • Remove a trusted publisher: on the registry's publisher management page, delete the entry. The next workflow run will fail to authenticate, which is usually what you want when rolling back.
  • Bypass branch protection for a hotfix: if you need to push directly to main for an emergency, GitHub requires admin to toggle the "Include administrators" option off in branch protection, push, then re-enable. Document the reason in a commit message.