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
112 changes: 112 additions & 0 deletions .github/workflows/publish-copr.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
name: Publish RPMs to COPR

# Reusable workflow invoked by cargo-dist's release pipeline as a
# user_publish_job (see dist-workspace.toml `publish-jobs`).
#
# Builds an SRPM from packaging/qn-bin.spec — a thin .spec whose %prep
# downloads the SLSA-attested prebuilt binary cargo-dist publishes for
# this release and verifies it against the .sha256 sidecar. No Rust
# toolchain involved on COPR's side; the binary inside the resulting
# RPM is bit-identical to the one in crates.io, Homebrew, GHCR, .deb,
# and AUR.
#
# `copr-cli build --enable-net=on` is required because the spec's
# %prep stage fetches the tarball over HTTPS from the GitHub Release.
on:
workflow_call:
inputs:
plan:
description: dist-manifest JSON for this announcement
required: true
type: string

# Reusable-workflow permissions must not exceed what the caller grants
# in dist-workspace.toml's github-custom-job-permissions block. We only
# need to read this repo to access the spec file.
permissions:
contents: read

jobs:
publish:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4

- name: Extract release tag from plan
id: meta
env:
PLAN: ${{ inputs.plan }}
run: |
version=$(echo "$PLAN" | jq -r '.releases[0].app_version')
echo "version=$version" >> "$GITHUB_OUTPUT"

- name: Install rpmbuild + copr-cli
run: |
sudo apt-get update
sudo apt-get install -y rpm python3-pip python3-venv
python3 -m venv "$HOME/copr-venv"
"$HOME/copr-venv/bin/pip" install --quiet copr-cli rich
echo "$HOME/copr-venv/bin" >> "$GITHUB_PATH"

- name: Build SRPM
env:
QN_VERSION: ${{ steps.meta.outputs.version }}
run: |
# Set up the rpmbuild tree.
mkdir -p "$HOME/rpmbuild"/{SOURCES,SPECS,SRPMS}
cp packaging/qn-bin.spec "$HOME/rpmbuild/SPECS/qn-bin.spec"

# SRPM only has the spec — the sources are fetched by mock at
# %prep time (Source0/Source1 are URLs, not local files). We
# pass --nodeps so rpmbuild doesn't try to satisfy
# BuildRequires on the runner; COPR's mock handles that.
rpmbuild -bs "$HOME/rpmbuild/SPECS/qn-bin.spec" \
--define "_topdir $HOME/rpmbuild" \
--define "qn_version $QN_VERSION" \
--nodeps
ls -la "$HOME/rpmbuild/SRPMS/"

- name: Configure copr-cli
env:
# copr-cli doesn't read env vars — it requires a config file at
# ~/.config/copr in INI format. The four fields are provisioned
# as separate repo secrets so each can be pasted as a single
# line and rotated independently. The file is assembled here at
# runtime. Verbatim values come from
# https://copr.fedorainfracloud.org/api/.
COPR_LOGIN: ${{ secrets.COPR_LOGIN }}
COPR_USERNAME: ${{ secrets.COPR_USERNAME }}
COPR_TOKEN: ${{ secrets.COPR_TOKEN }}
COPR_URL: ${{ secrets.COPR_URL }}
run: |
missing=()
[[ -z "$COPR_LOGIN" ]] && missing+=(COPR_LOGIN)
[[ -z "$COPR_USERNAME" ]] && missing+=(COPR_USERNAME)
[[ -z "$COPR_TOKEN" ]] && missing+=(COPR_TOKEN)
[[ -z "$COPR_URL" ]] && missing+=(COPR_URL)
if (( ${#missing[@]} > 0 )); then
echo "Error: COPR secrets not set on this repo:" >&2
printf ' %s\n' "${missing[@]}" >&2
echo "Provision per RELEASING.md's COPR one-time-setup section." >&2
exit 1
fi
mkdir -p ~/.config
# Use printf rather than heredoc to avoid YAML-indentation
# leaking into the file (configparser treats indented lines
# as continuations and rejects them).
printf '[copr-cli]\nlogin = %s\nusername = %s\ntoken = %s\ncopr_url = %s\n' \
"$COPR_LOGIN" "$COPR_USERNAME" "$COPR_TOKEN" "$COPR_URL" \
> ~/.config/copr
chmod 600 ~/.config/copr

- name: copr-cli build
run: |
srpm=$(ls "$HOME/rpmbuild/SRPMS/"qn-${{ steps.meta.outputs.version }}-1*.src.rpm)
if [[ ! -f "$srpm" ]]; then
echo "Error: SRPM not found at expected path under SRPMS/" >&2
exit 1
fi
echo "Uploading $srpm to quicknode/qn..."
# --enable-net=on so COPR's mock chroot can curl the prebuilt
# tarball + sha256 sidecar from the GitHub Release in %prep.
copr-cli build quicknode/qn "$srpm" --enable-net=on
16 changes: 15 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -327,17 +327,31 @@ jobs:
permissions:
"contents": "write"

custom-publish-copr:
needs:
- plan
- host
if: ${{ !fromJson(needs.plan.outputs.val).announcement_is_prerelease || fromJson(needs.plan.outputs.val).publish_prereleases }}
uses: ./.github/workflows/publish-copr.yml
with:
plan: ${{ needs.plan.outputs.val }}
secrets: inherit
# publish jobs get escalated permissions
permissions:
"contents": "read"

announce:
needs:
- plan
- host
- custom-publish-crates
- custom-publish-docker
- custom-publish-deb
- custom-publish-copr
# use "always() && ..." to allow us to wait for all publish jobs while
# still allowing individual publish jobs to skip themselves (for prereleases).
# "host" however must run to completion, no skipping allowed!
if: ${{ always() && needs.host.result == 'success' && (needs.custom-publish-crates.result == 'skipped' || needs.custom-publish-crates.result == 'success') && (needs.custom-publish-docker.result == 'skipped' || needs.custom-publish-docker.result == 'success') && (needs.custom-publish-deb.result == 'skipped' || needs.custom-publish-deb.result == 'success') }}
if: ${{ always() && needs.host.result == 'success' && (needs.custom-publish-crates.result == 'skipped' || needs.custom-publish-crates.result == 'success') && (needs.custom-publish-docker.result == 'skipped' || needs.custom-publish-docker.result == 'success') && (needs.custom-publish-deb.result == 'skipped' || needs.custom-publish-deb.result == 'success') && (needs.custom-publish-copr.result == 'skipped' || needs.custom-publish-copr.result == 'success') }}
runs-on: "ubuntu-22.04"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Expand Down
121 changes: 121 additions & 0 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,127 @@ release-update-aur-bin version aur_path:
echo "Committed qn-bin {{version}} to {{aur_path}}. To publish:"
echo " git -C {{aur_path}} push"

# Manually update the Scoop bucket with a manifest for a given release.
# Use this until CI has a PAT with contents:write on the bucket repo and
# can automate the push — at which point a CI job takes over and this
# recipe becomes a manual-recovery fallback.
#
# The generated manifest includes `checkver` + `autoupdate` so Scoop
# users get new versions on `scoop update` even without us pushing a
# new manifest for every release. We still push to bump the canonical
# `version` field so `scoop search` is honest about what's current.
#
# Usage: just release-update-scoop-bucket 0.1.4 ~/qn/scoop-bucket
#
# Precondition: bucket_path is a clean local clone of quicknode/scoop-bucket.
release-update-scoop-bucket version bucket_path:
#!/usr/bin/env bash
set -euo pipefail
if [[ ! -d "{{bucket_path}}/.git" ]]; then
echo "Error: {{bucket_path}} is not a git checkout. Clone quicknode/scoop-bucket there first." >&2
exit 1
fi
if ! git -C "{{bucket_path}}" diff --quiet || ! git -C "{{bucket_path}}" diff --cached --quiet; then
echo "Error: {{bucket_path}} has uncommitted changes. Commit or stash them first." >&2
exit 1
fi
sha_url="https://github.com/quicknode/cli/releases/download/v{{version}}/quicknode-cli-x86_64-pc-windows-msvc.zip.sha256"
if ! sha_line=$(curl -sfL "$sha_url"); then
echo "Error: could not download $sha_url (does the release exist?)" >&2
exit 1
fi
# The .sha256 sidecar is `<hex> *<filename>`; take just the hex.
hash=$(echo "$sha_line" | awk '{print $1}')
if [[ ! "$hash" =~ ^[0-9a-f]{64}$ ]]; then
echo "Error: parsed hash '$hash' is not a 64-char hex string." >&2
exit 1
fi
mkdir -p "{{bucket_path}}/bucket"
cat > "{{bucket_path}}/bucket/qn.json" <<EOF
{
"version": "{{version}}",
"description": "Command-line interface for the Quicknode SDK",
"homepage": "https://github.com/quicknode/cli",
"license": "MIT",
"architecture": {
"64bit": {
"url": "https://github.com/quicknode/cli/releases/download/v{{version}}/quicknode-cli-x86_64-pc-windows-msvc.zip",
"hash": "$hash"
}
},
"bin": "qn.exe",
"checkver": "github",
"autoupdate": {
"architecture": {
"64bit": {
"url": "https://github.com/quicknode/cli/releases/download/v\$version/quicknode-cli-x86_64-pc-windows-msvc.zip"
}
}
}
}
EOF
cd "{{bucket_path}}"
if git diff --quiet bucket/qn.json && ! git ls-files --error-unmatch bucket/qn.json >/dev/null 2>&1; then
git add bucket/qn.json
elif git diff --quiet bucket/qn.json; then
echo "bucket/qn.json is already at v{{version}}. Nothing to commit."
exit 0
else
git add bucket/qn.json
fi
git commit -m "qn {{version}}"
echo
echo "Committed qn {{version}} to {{bucket_path}}. To publish:"
echo " git -C {{bucket_path}} push"

# Run release-update-{homebrew-tap,scoop-bucket,aur-bin} in sequence for
# the latest release tag, then print the three `git push` commands the
# maintainer needs to run to publish. Auto-detects the version from the
# most recent git tag (`v<X.Y.Z>` → `<X.Y.Z>`), or accepts an override.
#
# Expects three sibling clones under `root` (defaults to ~/qn):
# ~/qn/homebrew-tap → quicknode/homebrew-tap
# ~/qn/scoop-bucket → quicknode/scoop-bucket
# ~/qn/qn-bin → ssh://aur@aur.archlinux.org/qn-bin.git
#
# Usage:
# just release-sync-manual-channels # auto-detect version, default root
# just release-sync-manual-channels ~/work/quicknode # override root
# just release-sync-manual-channels ~/qn 0.1.4 # override both
release-sync-manual-channels root="~/qn" version="":
#!/usr/bin/env bash
set -euo pipefail
# Expand a leading ~/ to $HOME/ since bash doesn't expand tildes inside
# variables. Absolute paths pass through unchanged.
root="{{root}}"
case "$root" in
"~") root="$HOME" ;;
"~/"*) root="$HOME/${root#\~/}" ;;
esac
# If no version was passed, take the latest tag (strip leading v).
version="{{version}}"
if [[ -z "$version" ]]; then
git fetch --tags --quiet
tag=$(git describe --tags --abbrev=0 2>/dev/null || true)
if [[ -z "$tag" ]]; then
echo 'Error: no git tags found and no version override passed. Run: just release-sync-manual-channels ROOT VERSION' >&2
exit 1
fi
version="${tag#v}"
fi
echo "Syncing manual channels at v${version} from clones under ${root}/"
echo
just release-update-homebrew-tap "$version" "${root}/homebrew-tap"
echo
just release-update-scoop-bucket "$version" "${root}/scoop-bucket"
echo
just release-update-aur-bin "$version" "${root}/qn-bin"
echo
echo "All three channels updated. To publish, run:"
echo " git -C ${root}/homebrew-tap push"
echo " git -C ${root}/scoop-bucket push"
echo " git -C ${root}/qn-bin 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
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,19 @@ The crate name is `quicknode-cli` but the installed binary is `qn`.
brew install quicknode/tap/qn
```

### Scoop (Windows)

```powershell
scoop bucket add quicknode https://github.com/quicknode/scoop-bucket
scoop install quicknode/qn
```

### Arch Linux (AUR)

```sh
yay -S qn-bin # or any other AUR helper
```

### From source

```sh
Expand Down
47 changes: 41 additions & 6 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,25 @@

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.

## Quick release

If the one-time setup below is done (tap, bucket, and AUR clones live under `~/qn/`), a full release is two commands:

```fish
just release-prepare X.Y.Z
# release-prepare drives bump → PR → squash-merge → tag → CI through to a green run.
# Watch the workflow; come back when it's done.

just release-sync-manual-channels
# Auto-detects the just-released version from the latest git tag, then bumps
# the Homebrew tap, Scoop bucket, and AUR qn-bin clones to match. Prints the
# three `git push` commands you run to publish.
```

Override the clone-root directory if your clones live elsewhere: `just release-sync-manual-channels ~/work/quicknode`. Override the version too if you're backfilling an older release: `just release-sync-manual-channels ~/qn 0.1.4`.

The rest of this document covers what each step does in detail, what to do if part of the pipeline fails, and the one-time setup for each channel.

## Per-release flow

Three named recipes in the `Justfile`:
Expand All @@ -16,8 +35,9 @@ Three named recipes in the `Justfile`:
- `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
- `custom-publish-copr` → builds an SRPM from `packaging/qn-bin.spec` (whose `%prep` downloads the SLSA-attested prebuilt binary), dispatches via `copr-cli build` to the `quicknode/qn` COPR project

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.
3. **Maintainer manual steps after CI succeeds.** Some channels still need a person to drive the publish because CI doesn't yet have the credentials it needs.

## Manual steps after each release

Expand Down Expand Up @@ -73,11 +93,6 @@ Public repo on GitHub. Must be public — Scoop does an anonymous git clone. Has
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
Expand All @@ -98,6 +113,26 @@ The first push registers the package on the AUR. Subsequent pushes just update i

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).

### COPR (`quicknode/qn`)

Maintainer needs a Fedora account at <https://accounts.fedoraproject.org> and a COPR project `quicknode/qn` created at <https://copr.fedorainfracloud.org>. Project settings to set on creation:

- **Chroots**: current Fedora releases + EPEL 9 (covers RHEL 9, Rocky 9, Alma 9). Skip EPEL 8 unless requested — its glibc is too old for our gnu binaries.
- **Build settings**: enable internet access for builds (the `%prep` step in `packaging/qn-bin.spec` curls the prebuilt tarball from the GitHub Release; without net the build can't download it).

CI auths to COPR via four repo secrets — `copr-cli` itself only reads credentials from a config file at `~/.config/copr` (no env-var fallback), so we provision the four fields separately and let the workflow assemble the file at build time. Generate the values at <https://copr.fedorainfracloud.org/api/>; the page shows a `[copr-cli]` config block with these four lines. Copy each field's value into its own secret:

| Secret | Field from the COPR API page |
|---|---|
| `COPR_LOGIN` | `login = …` |
| `COPR_USERNAME` | `username = …` |
| `COPR_TOKEN` | `token = …` |
| `COPR_URL` | `copr_url = …` (almost always `https://copr.fedorainfracloud.org`) |

Set each with `gh secret set COPR_LOGIN --repo quicknode/cli` etc., pasting just the value (no `login = ` prefix, no quotes). Splitting them this way also means the token can be rotated without re-pasting the other three.

`packaging/qn-bin.spec` lives in this repo; it's a thin spec whose `%prep` downloads the SLSA-attested prebuilt linux-gnu tarball and verifies it against the `.sha256` sidecar, so COPR isn't rebuilding `qn` from Rust source — it's just packaging the upstream binary into an RPM per chroot. Same trust chain as everywhere else qn ships.

## 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.
Expand Down
4 changes: 2 additions & 2 deletions dist-workspace.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ tap = "quicknode/homebrew-tap"
# Override the Homebrew formula name so `brew install quicknode/tap/qn` works
# (default would derive it from the package name `quicknode-cli`).
formula = "qn"
publish-jobs = ["./publish-crates", "./publish-docker", "./publish-deb"]
publish-jobs = ["./publish-crates", "./publish-docker", "./publish-deb", "./publish-copr"]
# Emit SLSA build attestations for every binary archive. Critical for a credential-handling tool.
github-attestations = true

Expand All @@ -41,4 +41,4 @@ github-attestations = true
# the calling job's permission grant so the called workflow can also
# declare contents:read without exceeding the caller. publish-deb
# needs contents: write so it can `gh release upload` the .deb files.
github-custom-job-permissions = { "publish-crates" = { contents = "read" }, "publish-docker" = { contents = "read", packages = "write", "id-token" = "write" }, "publish-deb" = { contents = "write" } }
github-custom-job-permissions = { "publish-crates" = { contents = "read" }, "publish-docker" = { contents = "read", packages = "write", "id-token" = "write" }, "publish-deb" = { contents = "write" }, "publish-copr" = { contents = "read" } }
Loading
Loading