diff --git a/.github/workflows/publish-deb.yml b/.github/workflows/publish-deb.yml new file mode 100644 index 0000000..a877bed --- /dev/null +++ b/.github/workflows/publish-deb.yml @@ -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 diff --git a/.github/workflows/publish-docker.yml b/.github/workflows/publish-docker.yml index 11370ab..4e5ecb1 100644 --- a/.github/workflows/publish-docker.yml +++ b/.github/workflows/publish-docker.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7fc976c..dc49a00 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 }} diff --git a/Cargo.toml b/Cargo.toml index 681c380..184261d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 " +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"], +] diff --git a/Justfile b/Justfile index 787829a..36767f6 100644 --- a/Justfile +++ b/Justfile @@ -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. diff --git a/README.md b/README.md index 46d9c7f..a85ab29 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/dist-workspace.toml b/dist-workspace.toml index fd2f6fd..bcb41ca 100644 --- a/dist-workspace.toml +++ b/dist-workspace.toml @@ -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 @@ -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" } }