Skip to content
Merged
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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Everything in this repository is — or will be — public. Treat every file, co
**Brand capitalization: always `Quicknode` (capital Q, lowercase n).** Never `QuickNode`, `quickNode`, or `QUICKNODE`. This applies to every surface — code, comments, commit messages, docs, generated workflow strings, formula descriptions, package metadata, error messages, anywhere the brand name appears.

- **Never commit secrets.** No API keys, tokens, account IDs, customer identifiers, internal hostnames, or non-public URLs. The CLI itself reads keys from env vars or `~/.config/qn/config.toml`; both live outside the repo. If you spot a leaked secret in a diff, stop and flag it before committing.
- **Don't mention internal secret names either, even ones that don't exist yet.** Names invented for proposed/planned secrets (e.g. for a release pipeline that needs a PAT we haven't created) are still internal infrastructure detail and read as roadmap leaks. Use generic language in code comments, commit messages, PR descriptions, etc. ("a CI secret for tap push", "a PAT with contents:write on the bucket") rather than a specific env var name. *Standard*, externally-documented names that come from a public tool's own docs are fine to mention (e.g. `CARGO_REGISTRY_TOKEN` is the name `cargo publish` itself reads).
- **Never include internal Quicknode information** in code, comments, commit messages, fixtures, snapshots, or test data: no internal Slack/Linear/Jira links, no employee names, no internal team structure, no unreleased product/feature names, no non-public roadmap detail, no private infrastructure details. If something would be inappropriate to post on the public crates.io page, it doesn't belong here.
- **Test fixtures must use fake data.** Endpoint IDs like `ep-1`, wallets like `0xabc`, URLs like `https://hook.example.com`, emails like `alice@example.com`. Don't paste real responses from a real account.
- **No internal-only justifications in commit messages or comments.** "Fixes the bug from the Slack thread" leaks the existence of the Slack thread. State the technical reason instead: "Fixes incorrect URL path for `update_endpoint_status`."
Expand Down
115 changes: 111 additions & 4 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,10 @@ release-cargo-publish:
cargo publish -p quicknode-cli

# Manually update the Homebrew tap with the formula attached to a given
# release. Use this until we have a HOMEBREW_TAP_TOKEN secret and can
# add "homebrew" to publish-jobs in dist-workspace.toml — at which
# point the cargo-dist workflow takes over and this recipe becomes
# obsolete.
# release. Use this until CI has a PAT with contents:write on the tap
# repo and can automate the formula push — at which point we add
# "homebrew" to publish-jobs in dist-workspace.toml, the cargo-dist
# workflow takes over, and this recipe becomes a manual-recovery fallback.
#
# Usage: just release-update-homebrew-tap 0.1.0 ~/qn/homebrew-tap
#
Expand Down Expand Up @@ -84,6 +84,113 @@ release-update-homebrew-tap version tap_path:
echo "Committed qn {{version}} to {{tap_path}}. To publish:"
echo " git -C {{tap_path}} push"

# Manually update the AUR `qn-bin` package with a PKGBUILD + .SRCINFO
# for a given release. Use this until CI has SSH access to push to the
# AUR git remote — at which point a CI publish-aur job takes over and
# this recipe becomes a manual-recovery fallback.
#
# The PKGBUILD downloads our prebuilt linux-gnu tarballs from the GitHub
# release (separate sha256 per arch). x86_64 and aarch64 only — cargo-dist
# does not build i686 or armv7.
#
# Usage: just release-update-aur-bin 0.1.4 ~/qn/aur-qn-bin
#
# Precondition: aur_path is a clean local clone of
# ssh://aur@aur.archlinux.org/qn-bin.git. First-time setup requires the
# package name `qn-bin` to be registered on the AUR — see AUR docs for
# the initial `git clone` + push.
release-update-aur-bin version aur_path:
#!/usr/bin/env bash
set -euo pipefail
if [[ ! -d "{{aur_path}}/.git" ]]; then
echo "Error: {{aur_path}} is not a git checkout. Clone ssh://aur@aur.archlinux.org/qn-bin.git there first." >&2
exit 1
fi
if ! git -C "{{aur_path}}" diff --quiet || ! git -C "{{aur_path}}" diff --cached --quiet; then
echo "Error: {{aur_path}} has uncommitted changes. Commit or stash them first." >&2
exit 1
fi
# Fetch both sha256 sidecars from the release.
x86_url="https://github.com/quicknode/cli/releases/download/v{{version}}/quicknode-cli-x86_64-unknown-linux-gnu.tar.xz.sha256"
arm_url="https://github.com/quicknode/cli/releases/download/v{{version}}/quicknode-cli-aarch64-unknown-linux-gnu.tar.xz.sha256"
if ! x86_line=$(curl -sfL "$x86_url"); then
echo "Error: could not download $x86_url (does the release exist?)" >&2
exit 1
fi
if ! arm_line=$(curl -sfL "$arm_url"); then
echo "Error: could not download $arm_url" >&2
exit 1
fi
# Each sidecar is `<hex> *<filename>` — take just the hex.
x86_hash=$(echo "$x86_line" | awk '{print $1}')
arm_hash=$(echo "$arm_line" | awk '{print $1}')
for h in "$x86_hash" "$arm_hash"; do
if [[ ! "$h" =~ ^[0-9a-f]{64}$ ]]; then
echo "Error: parsed hash '$h' is not a 64-char hex string." >&2
exit 1
fi
done
cat > "{{aur_path}}/PKGBUILD" <<EOF
# Maintainer: Quicknode <support@quicknode.com>
pkgname=qn-bin
pkgver={{version}}
pkgrel=1
pkgdesc='Command-line interface for the Quicknode SDK'
arch=('x86_64' 'aarch64')
url='https://github.com/quicknode/cli'
license=('MIT')
depends=('glibc')
provides=('qn')
conflicts=('qn')
source_x86_64=("\$pkgname-\$pkgver-x86_64.tar.xz::https://github.com/quicknode/cli/releases/download/v\$pkgver/quicknode-cli-x86_64-unknown-linux-gnu.tar.xz")
source_aarch64=("\$pkgname-\$pkgver-aarch64.tar.xz::https://github.com/quicknode/cli/releases/download/v\$pkgver/quicknode-cli-aarch64-unknown-linux-gnu.tar.xz")
sha256sums_x86_64=('$x86_hash')
sha256sums_aarch64=('$arm_hash')

package() {
local archdir
case "\$CARCH" in
x86_64) archdir='quicknode-cli-x86_64-unknown-linux-gnu' ;;
aarch64) archdir='quicknode-cli-aarch64-unknown-linux-gnu' ;;
esac
install -Dm755 "\$archdir/qn" "\$pkgdir/usr/bin/qn"
install -Dm644 "\$archdir/LICENSE" "\$pkgdir/usr/share/licenses/\$pkgname/LICENSE"
install -Dm644 "\$archdir/README.md" "\$pkgdir/usr/share/doc/\$pkgname/README.md"
}
EOF
# Generate .SRCINFO manually — we can't run `makepkg --printsrcinfo` on
# macOS dev boxes. Mirrors the deterministic field order makepkg emits.
cat > "{{aur_path}}/.SRCINFO" <<EOF
pkgbase = qn-bin
pkgdesc = Command-line interface for the Quicknode SDK
pkgver = {{version}}
pkgrel = 1
url = https://github.com/quicknode/cli
arch = x86_64
arch = aarch64
license = MIT
depends = glibc
provides = qn
conflicts = qn
source_x86_64 = qn-bin-{{version}}-x86_64.tar.xz::https://github.com/quicknode/cli/releases/download/v{{version}}/quicknode-cli-x86_64-unknown-linux-gnu.tar.xz
sha256sums_x86_64 = $x86_hash
source_aarch64 = qn-bin-{{version}}-aarch64.tar.xz::https://github.com/quicknode/cli/releases/download/v{{version}}/quicknode-cli-aarch64-unknown-linux-gnu.tar.xz
sha256sums_aarch64 = $arm_hash

pkgname = qn-bin
EOF
cd "{{aur_path}}"
# Stage both, even if only one changed; AUR convention is to commit them together.
git add PKGBUILD .SRCINFO
if git diff --cached --quiet PKGBUILD .SRCINFO; then
echo "PKGBUILD/.SRCINFO already at v{{version}}. Nothing to commit."
exit 0
fi
git commit -m "qn-bin {{version}}"
echo
echo "Committed qn-bin {{version}} to {{aur_path}}. To publish:"
echo " git -C {{aur_path}} push"

# Release Phase 1: bump → branch → PR → merge → tag → GH release → wait for CI.
# Each recipe is callable on its own; release-prepare orchestrates them with prompts.

Expand Down
125 changes: 125 additions & 0 deletions RELEASING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# Releasing `qn`

How to cut a release. The pipeline is mostly automated via cargo-dist; a few channels still need manual maintainer steps until CI has the right credentials to do them itself.

## Per-release flow

Three named recipes in the `Justfile`:

1. **`just release-prepare X.Y.Z`** — orchestrates the bump → branch → PR → squash-merge → tag-push → wait-for-CI sequence. Tag push is what fires `release.yml`; do not also run `release-create-tag` (it races cargo-dist's host job).

Internally calls `release-bump`, `release-open-pr`, `release-merge-pr`, `release-tag-main`, `release-wait-ci`. Each is also runnable standalone if a step fails and you need to retry.

2. **`release.yml` runs in CI** — cross-compiles 7 targets, creates the GitHub Release, attaches archives + sha256 sidecars + SLSA attestations, then fans out to per-channel publish jobs.

Publish channels currently in CI:
- `custom-publish-crates` → publishes `quicknode-cli` to crates.io
- `custom-publish-docker` → builds multi-arch image, pushes to `ghcr.io/quicknode/qn`
- `custom-publish-deb` → packages `.deb` per arch, uploads to the GitHub Release as assets

3. **Maintainer manual steps after CI succeeds.** Three channels still need a person to drive the publish because CI doesn't yet have the credentials it needs.

## Manual steps after each release

Run these from the repo root (`~/qn/cli`) after `release.yml` is green for the new tag.

### Homebrew

Sync the formula cargo-dist generated as a release artifact into the tap repo:

```fish
just release-update-homebrew-tap X.Y.Z ~/qn/homebrew-tap
git -C ~/qn/homebrew-tap push
```

The recipe downloads `qn.rb` from the GitHub Release, copies it to `Formula/qn.rb` in the tap clone, commits with a clean message, and prints the push command. It does not push itself — review the diff first.

### Scoop

Bump the canonical `version` in `bucket/qn.json`:

```fish
just release-update-scoop-bucket X.Y.Z ~/qn/scoop-bucket
git -C ~/qn/scoop-bucket push
```

The recipe pulls the Windows zip's sha256 from the release, renders a manifest with `version`, `hash`, and an `autoupdate` block, and stages it at `bucket/qn.json`. Once a user has tapped the bucket, `scoop update` finds new versions on its own — this manual step just keeps `scoop search qn` honest about what's current.

### AUR

Bump `pkgver` in the `qn-bin` AUR package:

```fish
just release-update-aur-bin X.Y.Z ~/qn/qn-bin
git -C ~/qn/qn-bin push
```

The recipe pulls both Linux gnu sha256 sidecars (x86_64 + aarch64) from the release, renders a `PKGBUILD` + `.SRCINFO`, and stages them. Push goes to `ssh://aur@aur.archlinux.org/qn-bin.git` — the AUR's git remote.

## One-time setup notes

A few channels needed manual setup the first time. Captured here so the next maintainer doesn't have to rediscover them.

### Homebrew tap (`quicknode/homebrew-tap`)

Public repo on GitHub. Must be public — `brew tap` does an anonymous git clone. Has a single `Formula/qn.rb` per formula. cargo-dist generates the formula as a release artifact (whether or not we auto-publish), so the maintainer's job is just to commit it into the tap.

### Scoop bucket (`quicknode/scoop-bucket`)

Public repo on GitHub. Must be public — Scoop does an anonymous git clone. Has `bucket/qn.json` per package. We hand-render the manifest in the recipe (cargo-dist doesn't generate one).

### AUR (`qn-bin`)

Maintainer needs an AUR account at <https://aur.archlinux.org> with an SSH key registered. Once that's set up:

```fish
# Confirm the name isn't taken (one-time, before first push)
curl -sf "https://aur.archlinux.org/rpc/v5/info?arg[]=qn-bin" \
| python3 -c "import json,sys; print(json.load(sys.stdin).get('resultcount'))"
# 0 = free

# Clone the (currently empty) AUR git remote
mkdir -p ~/qn
cd ~/qn
git clone ssh://aur@aur.archlinux.org/qn-bin.git
# AUR returns: "warning: You appear to have cloned an empty repository." — expected.

# Render PKGBUILD + .SRCINFO from the latest release
cd ~/qn/cli
just release-update-aur-bin X.Y.Z ~/qn/qn-bin

# AUR expects the default branch to be `master`. Modern git defaults to `main`,
# so rename the freshly-created branch before the first push.
git -C ~/qn/qn-bin branch -m main master
git -C ~/qn/qn-bin push -u origin master
```

The first push registers the package on the AUR. Subsequent pushes just update it — no rename needed (the branch stays `master`).

After publishing: confirm via `https://aur.archlinux.org/packages/qn-bin` (the RPC at `/rpc/v5/info` can lag the package page by a few minutes — trust the web page, not the RPC, for fresh registrations).

## Recovery: a publish channel failed

If a single publish-* job in `release.yml` fails (e.g. crates.io rejected the publish because the token expired), the rest of the release is still good — the GitHub Release, attestations, and other channels remain published.

To retry just the failed job:

```fish
gh run rerun <run-id> --failed --repo quicknode/cli
```

For crates.io specifically, the manual fallback if CI's auth is broken is:

```fish
just release-cargo-publish
# Requires `cargo login` first.
```

## Sanity-checks before tagging

- `just lint` clean (`cargo clippy --all-targets -- -D warnings`)
- `just test` clean
- `just release-cargo-publish-check` clean (validates the crate tarball without uploading)
- `dist plan` exits 0 (verifies the generated workflow matches `dist-workspace.toml`)

If `dist plan` complains the workflow is out of date, run `just dist-regen` to regenerate and commit the result.
9 changes: 5 additions & 4 deletions dist-workspace.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,11 @@ hosting = "github"
# Whether to install an updater program
install-updater = false
# Homebrew tap repository to publish the formula to. The "homebrew"
# entry is deliberately omitted from publish-jobs until we have a
# HOMEBREW_TAP_TOKEN secret — without it, the formula push step would
# fail CI on every release. The formula itself is still generated and
# attached to each GitHub Release. Until the token exists, run
# entry is deliberately omitted from publish-jobs until CI has a PAT
# with contents:write on the tap repo — without that secret, the
# formula push step would fail CI on every release. The formula
# itself is still generated and attached to each GitHub Release.
# Until that's wired, run
# `just release-update-homebrew-tap VERSION TAP_PATH` to push the
# formula to quicknode/homebrew-tap manually.
tap = "quicknode/homebrew-tap"
Expand Down
Loading