From 6938ecb28f36c25d2f245eae7509b3abfa7db583 Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Fri, 5 Jun 2026 09:35:02 -0400 Subject: [PATCH 1/3] Add release-update-homebrew-tap recipe for manual formula pushes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cargo-dist generates qn.rb as a release artifact regardless of whether the "homebrew" entry is in publish-jobs. Until we have a HOMEBREW_TAP_TOKEN secret (which needs a Quicknode-owned PAT we don't yet have), the auto-push step stays disabled and this Justfile recipe lets a maintainer manually sync the formula to a local clone of quicknode/homebrew-tap. Usage: just release-update-homebrew-tap 0.1.0 ~/qn/homebrew-tap The recipe downloads qn.rb from the release, drops it at Formula/qn.rb in the tap clone, commits with a clean message, and prints the git push command (it does not push automatically — the maintainer reviews and pushes themselves). When HOMEBREW_TAP_TOKEN exists we'll add "homebrew" back to publish-jobs in dist-workspace.toml, the cargo-dist publish-homebrew job takes over, and this recipe becomes a fallback for manual recovery. --- Justfile | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/Justfile b/Justfile index 95bd0c1..e24b857 100644 --- a/Justfile +++ b/Justfile @@ -41,6 +41,55 @@ 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" + echo + echo "After pushing, document the install path in README.md under ## Installation:" + echo " ### Homebrew (macOS, Linux)" + echo " \`\`\`sh" + echo " brew install quicknode/tap/qn" + echo " \`\`\`" + # 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. From e0f8c7e25a7957be2610676368c854ad3aa9049c Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Fri, 5 Jun 2026 09:49:27 -0400 Subject: [PATCH 2/3] Document Homebrew + crates.io install paths in README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Quicknode tap at quicknode/homebrew-tap is populated with the v0.1.0 formula (via release-update-homebrew-tap), so `brew install quicknode/tap/qn` works for anyone with access to the tap repo. Also add the crates.io path and fix the source-build remote (which still pointed at the never-created quicknode/qn instead of quicknode/cli). Drop the trailing "after pushing, paste this README snippet" hint from the release-update-homebrew-tap recipe — the README is now updated and the hint would just rot. --- Justfile | 6 ------ README.md | 18 +++++++++++++++++- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/Justfile b/Justfile index e24b857..575e6bd 100644 --- a/Justfile +++ b/Justfile @@ -83,12 +83,6 @@ release-update-homebrew-tap version tap_path: echo echo "Committed qn {{version}} to {{tap_path}}. To publish:" echo " git -C {{tap_path}} push" - echo - echo "After pushing, document the install path in README.md under ## Installation:" - echo " ### Homebrew (macOS, Linux)" - echo " \`\`\`sh" - echo " brew install quicknode/tap/qn" - echo " \`\`\`" # 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 d1f8b36..a85ab29 100644 --- a/README.md +++ b/README.md @@ -29,8 +29,24 @@ error: null ## Installation +### From crates.io + +```sh +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 -git clone git@github.com:quicknode/qn.git && cd qn +git clone git@github.com:quicknode/cli.git && cd cli cargo install --path . ``` From 3a0c2c430dde167a568bed4b766609c44ceb1d96 Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Fri, 5 Jun 2026 10:16:14 -0400 Subject: [PATCH 3/3] Package .deb files on release; sync permissions config with main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Debian package builds to the cargo-dist release pipeline: * [package.metadata.deb] in Cargo.toml configures cargo-deb to produce qn_X.Y.Z_.deb files (using the binary name in the filename, not the crate name). * .github/workflows/publish-deb.yml is a reusable workflow that, per arch (amd64/arm64), downloads the prebuilt linux-gnu archive from the GitHub release, stages the binary where cargo-deb expects it, packages with --no-build --no-strip, and uploads the .deb back to the release as an asset. * dist-workspace.toml gets ./publish-deb in publish-jobs and contents: write in github-custom-job-permissions (needed for `gh release upload`). Hosted apt repo (aptly/reprepro + GPG signing) is out of scope for v1. Users install via `dpkg -i ./qn_X.Y.Z_amd64.deb` or `apt install ./qn_X.Y.Z_amd64.deb` after downloading from the release page. Also brings this branch's publish-crates.yml and publish-docker.yml in line with the post-v0.1.0 fixes that landed on main: * publish-crates.yml gains `permissions: contents: read` so its reusable-workflow GITHUB_TOKEN can actually checkout this internal repo (without it, actions/checkout gets a 404). * publish-docker.yml drops `attestations: write` from its top-level permissions — that grant exceeded what the caller offered and was the original cause of the v0.1.0 startup_failure. * The release.yml regeneration also drops the homebrew publish job (no HOMEBREW_TAP_TOKEN yet — we publish manually via `just release-update-homebrew-tap`). Validated locally: cargo deb produced a structurally valid .deb with the right control metadata and /usr/bin/qn + /usr/share/doc/qn/ layout. --- .github/workflows/publish-crates.yml | 8 +++ .github/workflows/publish-deb.yml | 93 ++++++++++++++++++++++++++++ .github/workflows/publish-docker.yml | 7 ++- .github/workflows/release.yml | 65 +++++-------------- Cargo.toml | 21 +++++++ dist-workspace.toml | 20 +++++- 6 files changed, 162 insertions(+), 52 deletions(-) create mode 100644 .github/workflows/publish-deb.yml diff --git a/.github/workflows/publish-crates.yml b/.github/workflows/publish-crates.yml index 84b094a..2f33471 100644 --- a/.github/workflows/publish-crates.yml +++ b/.github/workflows/publish-crates.yml @@ -12,6 +12,14 @@ on: required: true type: string +# Reusable workflows mint their own GITHUB_TOKEN with only the permissions +# declared here (NOT inherited from the caller). On a private/internal +# repo, actions/checkout needs `contents: read` or it gets a 404. The +# caller must also grant this — see dist-workspace.toml's +# github-custom-job-permissions block. +permissions: + contents: read + jobs: publish: runs-on: ubuntu-22.04 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 db88cff..4e5ecb1 100644 --- a/.github/workflows/publish-docker.yml +++ b/.github/workflows/publish-docker.yml @@ -16,11 +16,16 @@ on: required: true type: string +# 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 packages: write id-token: write - attestations: write env: REGISTRY: ghcr.io diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5ca58df..dc49a00 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -286,91 +286,58 @@ jobs: gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/* - publish-homebrew-formula: + custom-publish-crates: needs: - plan - host - runs-on: "ubuntu-22.04" - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PLAN: ${{ needs.plan.outputs.val }} - GITHUB_USER: "axo bot" - GITHUB_EMAIL: "admin+bot@axo.dev" if: ${{ !fromJson(needs.plan.outputs.val).announcement_is_prerelease || fromJson(needs.plan.outputs.val).publish_prereleases }} - steps: - - uses: actions/checkout@v6 - with: - persist-credentials: true - repository: "quicknode/homebrew-tap" - token: ${{ secrets.HOMEBREW_TAP_TOKEN }} - # So we have access to the formula - - name: Fetch homebrew formulae - uses: actions/download-artifact@v8 - with: - pattern: artifacts-* - path: Formula/ - merge-multiple: true - # This is extra complex because you can make your Formula name not match your app name - # so we need to find releases with a *.rb file, and publish with that filename. - - name: Commit formula files - run: | - git config --global user.name "${GITHUB_USER}" - git config --global user.email "${GITHUB_EMAIL}" - - for release in $(echo "$PLAN" | jq --compact-output '.releases[] | select([.artifacts[] | endswith(".rb")] | any)'); do - filename=$(echo "$release" | jq '.artifacts[] | select(endswith(".rb"))' --raw-output) - name=$(echo "$filename" | sed "s/\.rb$//") - version=$(echo "$release" | jq .app_version --raw-output) - - export PATH="/home/linuxbrew/.linuxbrew/bin:$PATH" - brew update - # We avoid reformatting user-provided data such as the app description and homepage. - brew style --except-cops FormulaAudit/Homepage,FormulaAudit/Desc,FormulaAuditStrict --fix "Formula/${filename}" || true - - git add "Formula/${filename}" - git commit -m "${name} ${version}" - done - git push + uses: ./.github/workflows/publish-crates.yml + with: + plan: ${{ needs.plan.outputs.val }} + secrets: inherit + # publish jobs get escalated permissions + permissions: + "contents": "read" - custom-publish-crates: + custom-publish-docker: needs: - plan - host if: ${{ !fromJson(needs.plan.outputs.val).announcement_is_prerelease || fromJson(needs.plan.outputs.val).publish_prereleases }} - uses: ./.github/workflows/publish-crates.yml + uses: ./.github/workflows/publish-docker.yml with: plan: ${{ needs.plan.outputs.val }} secrets: inherit # publish jobs get escalated permissions permissions: + "contents": "read" "id-token": "write" "packages": "write" - custom-publish-docker: + 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-docker.yml + uses: ./.github/workflows/publish-deb.yml with: plan: ${{ needs.plan.outputs.val }} secrets: inherit # publish jobs get escalated permissions permissions: - "id-token": "write" - "packages": "write" + "contents": "write" announce: needs: - plan - host - - publish-homebrew-formula - 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.publish-homebrew-formula.result == 'skipped' || needs.publish-homebrew-formula.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 20ae562..2a135f9 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/dist-workspace.toml b/dist-workspace.toml index 87b6296..bcb41ca 100644 --- a/dist-workspace.toml +++ b/dist-workspace.toml @@ -17,11 +17,27 @@ install-path = "CARGO_HOME" hosting = "github" # Whether to install an updater program install-updater = false -# Homebrew tap repository to publish the formula to +# 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 +# `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 = ["homebrew", "./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 + +# The repo is `internal`, so the reusable workflow's auto-minted +# GITHUB_TOKEN needs explicit contents:read to checkout the source. +# Without this the actions/checkout step in each publish workflow +# 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. 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" } }