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
93 changes: 93 additions & 0 deletions .github/workflows/publish-deb.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
name: Publish Debian packages

# Reusable workflow invoked by cargo-dist's release pipeline as a
# user_publish_job (see dist-workspace.toml `publish-jobs`).
#
# Packages the prebuilt linux-gnu binaries from the GitHub release
# into .deb files (amd64 + arm64) and uploads each as a release asset.
#
# v1 ships .deb files attached to releases — users install via
# `dpkg -i ./qn_X.Y.Z_amd64.deb` or `apt install ./qn_X.Y.Z_amd64.deb`.
# A hosted apt repo (aptly/reprepro + GPG) is out of scope.
on:
workflow_call:
inputs:
plan:
description: dist-manifest JSON for this announcement
required: true
type: string

# contents: write is needed for `gh release upload`. The caller
# (custom-publish-deb in release.yml) must grant this via
# dist-workspace.toml's github-custom-job-permissions block.
permissions:
contents: write

jobs:
publish:
runs-on: ubuntu-22.04
strategy:
fail-fast: false
matrix:
include:
- rust_target: x86_64-unknown-linux-gnu
deb_arch: amd64
- rust_target: aarch64-unknown-linux-gnu
deb_arch: arm64
steps:
- uses: actions/checkout@v4

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

- name: Install cargo-deb
uses: taiki-e/install-action@v2
with:
tool: cargo-deb

- name: Download linux-gnu archive for ${{ matrix.deb_arch }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
mkdir -p downloads
gh release download "${{ steps.meta.outputs.tag }}" \
--pattern 'quicknode-cli-${{ matrix.rust_target }}.tar.xz' \
--dir downloads/

- name: Stage binary at target/${{ matrix.rust_target }}/release/qn
run: |
# cargo-deb --no-build --target X expects the binary at
# target/X/release/qn. Extract the prebuilt qn from the
# release tarball into that path.
mkdir -p target/${{ matrix.rust_target }}/release
tar -xJf downloads/quicknode-cli-${{ matrix.rust_target }}.tar.xz \
--strip-components=1 \
-C target/${{ matrix.rust_target }}/release \
--wildcards '*/qn'
chmod +x target/${{ matrix.rust_target }}/release/qn
file target/${{ matrix.rust_target }}/release/qn

- name: Build .deb
run: |
# --no-build: don't recompile, use the staged binary.
# --no-strip: cargo-dist already stripped the binary upstream.
# --target: tells cargo-deb where to find the binary AND
# what dpkg arch to label the package as.
cargo deb --no-build --no-strip \
--target ${{ matrix.rust_target }} \
--output qn_${{ steps.meta.outputs.version }}_${{ matrix.deb_arch }}.deb

- name: Upload .deb to release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release upload "${{ steps.meta.outputs.tag }}" \
qn_${{ steps.meta.outputs.version }}_${{ matrix.deb_arch }}.deb \
--clobber
11 changes: 5 additions & 6 deletions .github/workflows/publish-docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,11 @@ on:
required: true
type: string

# Permissions must NOT exceed what the caller (release.yml's
# `custom-publish-docker` job) grants — reusable workflows can't widen
# permissions. Caller grants are configured via dist-workspace.toml's
# github-custom-job-permissions block. `contents: read` is needed for
# actions/checkout on this internal repo; `packages: write` is needed
# for GHCR push; `id-token: write` is needed for OIDC-backed
# Permissions must NOT exceed what the caller grants. cargo-dist's
# github-custom-job-permissions in dist-workspace.toml controls what
# the caller grants — keep these in sync. `contents: read` is needed
# for actions/checkout on this internal repo; `packages: write` is
# needed for GHCR push; `id-token: write` enables OIDC-backed
# attestations on the pushed image.
permissions:
contents: read
Expand Down
16 changes: 15 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -314,16 +314,30 @@ jobs:
"id-token": "write"
"packages": "write"

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

announce:
needs:
- plan
- host
- custom-publish-crates
- custom-publish-docker
- custom-publish-deb
# 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') }}
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') }}
runs-on: "ubuntu-22.04"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Expand Down
21 changes: 21 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,24 @@ tempfile = "3"
[profile.dist]
inherits = "release"
lto = "thin"

# cargo-deb metadata. CI's publish-deb workflow packages the prebuilt
# linux-gnu binary into a .deb per arch and attaches each to the
# GitHub Release. `name` makes the deb filename match the binary name
# (qn_X.Y.Z_amd64.deb) instead of the crate name (quicknode-cli_X.Y.Z…).
[package.metadata.deb]
name = "qn"
maintainer = "Quicknode <support@quicknode.com>"
copyright = "2026 Quicknode"
extended-description = """
qn is a command-line interface for Quicknode, built around noun-verb
commands that read naturally for both humans and agents. Manage endpoints,
streams, webhooks, the KV store, teams, usage, and billing.
"""
section = "utils"
priority = "optional"
assets = [
["target/release/qn", "usr/bin/", "755"],
["README.md", "usr/share/doc/qn/README", "644"],
["LICENSE", "usr/share/doc/qn/LICENSE", "644"],
]
43 changes: 43 additions & 0 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,49 @@ release-cargo-publish-check:
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.
#
# Usage: just release-update-homebrew-tap 0.1.0 ~/qn/homebrew-tap
#
# Precondition: tap_path is a clean local clone of quicknode/homebrew-tap.
release-update-homebrew-tap version tap_path:
#!/usr/bin/env bash
set -euo pipefail
if [[ ! -d "{{tap_path}}/.git" ]]; then
echo "Error: {{tap_path}} is not a git checkout. Clone quicknode/homebrew-tap there first." >&2
exit 1
fi
if ! git -C "{{tap_path}}" diff --quiet || ! git -C "{{tap_path}}" diff --cached --quiet; then
echo "Error: {{tap_path}} has uncommitted changes. Commit or stash them first." >&2
exit 1
fi
formula_url="https://github.com/quicknode/cli/releases/download/v{{version}}/qn.rb"
if ! curl -sfL "$formula_url" -o /tmp/qn.rb; then
echo "Error: could not download $formula_url (does the release exist?)" >&2
exit 1
fi
mkdir -p "{{tap_path}}/Formula"
cp /tmp/qn.rb "{{tap_path}}/Formula/qn.rb"
rm /tmp/qn.rb
cd "{{tap_path}}"
if git diff --quiet Formula/qn.rb && ! git ls-files --error-unmatch Formula/qn.rb >/dev/null 2>&1; then
# New file
git add Formula/qn.rb
elif git diff --quiet Formula/qn.rb; then
echo "Formula/qn.rb is already at v{{version}}. Nothing to commit."
exit 0
else
git add Formula/qn.rb
fi
git commit -m "qn {{version}}"
echo
echo "Committed qn {{version}} to {{tap_path}}. To publish:"
echo " git -C {{tap_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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ cargo install quicknode-cli

The crate name is `quicknode-cli` but the installed binary is `qn`.

### Homebrew (macOS, Linux)

```sh
brew install quicknode/tap/qn
```

### From source

```sh
Expand Down
15 changes: 8 additions & 7 deletions dist-workspace.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,17 @@ 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 Phase 3 of the
# packaging plan creates quicknode/homebrew-tap and adds the
# 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 (it just doesn't get auto-committed
# to the tap).
# attached to each GitHub Release. Until the token exists, run
# `just release-update-homebrew-tap VERSION TAP_PATH` to push the
# formula to quicknode/homebrew-tap manually.
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-jobs = ["./publish-crates", "./publish-docker", "./publish-deb"]
# Emit SLSA build attestations for every binary archive. Critical for a credential-handling tool.
github-attestations = true

Expand All @@ -38,5 +38,6 @@ github-attestations = true
# fails with `Repository not found` (a 404 masquerading as a missing
# auth claim on a private repo). Granting the permission here widens
# the calling job's permission grant so the called workflow can also
# declare contents:read without exceeding the caller.
github-custom-job-permissions = { "publish-crates" = { contents = "read" }, "publish-docker" = { contents = "read", packages = "write", "id-token" = "write" } }
# 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" } }
Loading