From f20398ada7886da86b02278e23aa46630aced202 Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Wed, 3 Jun 2026 14:09:09 -0400 Subject: [PATCH 1/7] Point Cargo.toml repository at quicknode/cli The repository field said quicknode/qn, but the git remote (and the canonical GitHub URL for releases, tap repos, etc.) is quicknode/cli. Fix the mismatch before adding release tooling that reads this field. --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 6fa9d15..b11739c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2021" description = "Command-line interface for the Quicknode SDK" license = "MIT" -repository = "https://github.com/quicknode/qn" +repository = "https://github.com/quicknode/cli" homepage = "https://www.quicknode.com/docs/welcome" authors = ["Quicknode "] readme = "README.md" From fe2798fbe2ca66462eab59195ad43c38879e89e4 Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Wed, 3 Jun 2026 14:12:09 -0400 Subject: [PATCH 2/7] Scaffold cargo-dist release pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `dist init` generated a tag-triggered workflow that cross-compiles to seven targets (Linux gnu/musl x amd64/arm64, macOS x amd64/arm64, Windows x86_64), produces .tar.xz/.zip archives with sha256 sidecars and SLSA build attestations, and uploads everything to a GitHub Release. It also generates shell + powershell installers and a Homebrew formula that gets pushed to quicknode/homebrew-tap. Phase 0 of the packaging plan. Subsequent phases will layer crates.io, Docker/GHCR, AUR, Scoop, .deb, and COPR jobs on top of this. The HOMEBREW_TAP_TOKEN secret + tap repo are deferred to Phase 3 — the workflow will simply fail the homebrew step until then, but the rest runs. --- .github/workflows/release.yml | 351 ++++++++++++++++++++++++++++++++++ Cargo.toml | 5 + dist-workspace.toml | 25 +++ 3 files changed, 381 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100644 dist-workspace.toml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..3ad66b2 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,351 @@ +# This file was autogenerated by dist: https://axodotdev.github.io/cargo-dist +# +# Copyright 2022-2024, axodotdev +# SPDX-License-Identifier: MIT or Apache-2.0 +# +# CI that: +# +# * checks for a Git Tag that looks like a release +# * builds artifacts with dist (archives, installers, hashes) +# * uploads those artifacts to temporary workflow zip +# * on success, uploads the artifacts to a GitHub Release +# +# Note that the GitHub Release will be created with a generated +# title/body based on your changelogs. + +name: Release +permissions: + "contents": "write" + +# This task will run whenever you push a git tag that looks like a version +# like "1.0.0", "v0.1.0-prerelease.1", "my-app/0.1.0", "releases/v1.0.0", etc. +# Various formats will be parsed into a VERSION and an optional PACKAGE_NAME, where +# PACKAGE_NAME must be the name of a Cargo package in your workspace, and VERSION +# must be a Cargo-style SemVer Version (must have at least major.minor.patch). +# +# If PACKAGE_NAME is specified, then the announcement will be for that +# package (erroring out if it doesn't have the given version or isn't dist-able). +# +# If PACKAGE_NAME isn't specified, then the announcement will be for all +# (dist-able) packages in the workspace with that version (this mode is +# intended for workspaces with only one dist-able package, or with all dist-able +# packages versioned/released in lockstep). +# +# If you push multiple tags at once, separate instances of this workflow will +# spin up, creating an independent announcement for each one. However, GitHub +# will hard limit this to 3 tags per commit, as it will assume more tags is a +# mistake. +# +# If there's a prerelease-style suffix to the version, then the release(s) +# will be marked as a prerelease. +on: + pull_request: + push: + tags: + - '**[0-9]+.[0-9]+.[0-9]+*' + +jobs: + # Run 'dist plan' (or host) to determine what tasks we need to do + plan: + runs-on: "ubuntu-22.04" + outputs: + val: ${{ steps.plan.outputs.manifest }} + tag: ${{ !github.event.pull_request && github.ref_name || '' }} + tag-flag: ${{ !github.event.pull_request && format('--tag={0}', github.ref_name) || '' }} + publishing: ${{ !github.event.pull_request }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + submodules: recursive + - name: Install dist + # we specify bash to get pipefail; it guards against the `curl` command + # failing. otherwise `sh` won't catch that `curl` returned non-0 + shell: bash + run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.32.0/cargo-dist-installer.sh | sh" + - name: Cache dist + uses: actions/upload-artifact@v7 + with: + name: cargo-dist-cache + path: ~/.cargo/bin/dist + # sure would be cool if github gave us proper conditionals... + # so here's a doubly-nested ternary-via-truthiness to try to provide the best possible + # functionality based on whether this is a pull_request, and whether it's from a fork. + # (PRs run on the *source* but secrets are usually on the *target* -- that's *good* + # but also really annoying to build CI around when it needs secrets to work right.) + - id: plan + run: | + dist ${{ (!github.event.pull_request && format('host --steps=create --tag={0}', github.ref_name)) || 'plan' }} --output-format=json > plan-dist-manifest.json + echo "dist ran successfully" + cat plan-dist-manifest.json + echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT" + - name: "Upload dist-manifest.json" + uses: actions/upload-artifact@v7 + with: + name: artifacts-plan-dist-manifest + path: plan-dist-manifest.json + + # Build and packages all the platform-specific things + build-local-artifacts: + name: build-local-artifacts (${{ join(matrix.targets, ', ') }}) + # Let the initial task tell us to not run (currently very blunt) + needs: + - plan + if: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix.include != null && (needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload') }} + strategy: + fail-fast: false + # Target platforms/runners are computed by dist in create-release. + # Each member of the matrix has the following arguments: + # + # - runner: the github runner + # - dist-args: cli flags to pass to dist + # - install-dist: expression to run to install dist on the runner + # + # Typically there will be: + # - 1 "global" task that builds universal installers + # - N "local" tasks that build each platform's binaries and platform-specific installers + matrix: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix }} + runs-on: ${{ matrix.runner }} + container: ${{ matrix.container && matrix.container.image || null }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json + permissions: + "attestations": "write" + "contents": "read" + "id-token": "write" + steps: + - name: enable windows longpaths + run: | + git config --global core.longpaths true + - uses: actions/checkout@v6 + with: + persist-credentials: false + submodules: recursive + - name: Install Rust non-interactively if not already installed + if: ${{ matrix.container }} + run: | + if ! command -v cargo > /dev/null 2>&1; then + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + echo "$HOME/.cargo/bin" >> $GITHUB_PATH + fi + - name: Install dist + run: ${{ matrix.install_dist.run }} + # Get the dist-manifest + - name: Fetch local artifacts + uses: actions/download-artifact@v8 + with: + pattern: artifacts-* + path: target/distrib/ + merge-multiple: true + - name: Install dependencies + run: | + ${{ matrix.packages_install }} + - name: Build artifacts + run: | + # Actually do builds and make zips and whatnot + dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json + echo "dist ran successfully" + - name: Attest + uses: actions/attest@v4 + with: + subject-path: "target/distrib/*${{ join(matrix.targets, ', ') }}*" + - id: cargo-dist + name: Post-build + # We force bash here just because github makes it really hard to get values up + # to "real" actions without writing to env-vars, and writing to env-vars has + # inconsistent syntax between shell and powershell. + shell: bash + run: | + # Parse out what we just built and upload it to scratch storage + echo "paths<> "$GITHUB_OUTPUT" + dist print-upload-files-from-manifest --manifest dist-manifest.json >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + cp dist-manifest.json "$BUILD_MANIFEST_NAME" + - name: "Upload artifacts" + uses: actions/upload-artifact@v7 + with: + name: artifacts-build-local-${{ join(matrix.targets, '_') }} + path: | + ${{ steps.cargo-dist.outputs.paths }} + ${{ env.BUILD_MANIFEST_NAME }} + + # Build and package all the platform-agnostic(ish) things + build-global-artifacts: + needs: + - plan + - build-local-artifacts + runs-on: "ubuntu-22.04" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + submodules: recursive + - name: Install cached dist + uses: actions/download-artifact@v8 + with: + name: cargo-dist-cache + path: ~/.cargo/bin/ + - run: chmod +x ~/.cargo/bin/dist + # Get all the local artifacts for the global tasks to use (for e.g. checksums) + - name: Fetch local artifacts + uses: actions/download-artifact@v8 + with: + pattern: artifacts-* + path: target/distrib/ + merge-multiple: true + - id: cargo-dist + shell: bash + run: | + dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global" > dist-manifest.json + echo "dist ran successfully" + + # Parse out what we just built and upload it to scratch storage + echo "paths<> "$GITHUB_OUTPUT" + jq --raw-output ".upload_files[]" dist-manifest.json >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + cp dist-manifest.json "$BUILD_MANIFEST_NAME" + - name: "Upload artifacts" + uses: actions/upload-artifact@v7 + with: + name: artifacts-build-global + path: | + ${{ steps.cargo-dist.outputs.paths }} + ${{ env.BUILD_MANIFEST_NAME }} + # Determines if we should publish/announce + host: + needs: + - plan + - build-local-artifacts + - build-global-artifacts + # Only run if we're "publishing", and only if plan, local and global didn't fail (skipped is fine) + if: ${{ always() && needs.plan.result == 'success' && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + runs-on: "ubuntu-22.04" + outputs: + val: ${{ steps.host.outputs.manifest }} + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + submodules: recursive + - name: Install cached dist + uses: actions/download-artifact@v8 + with: + name: cargo-dist-cache + path: ~/.cargo/bin/ + - run: chmod +x ~/.cargo/bin/dist + # Fetch artifacts from scratch-storage + - name: Fetch artifacts + uses: actions/download-artifact@v8 + with: + pattern: artifacts-* + path: target/distrib/ + merge-multiple: true + - id: host + shell: bash + run: | + dist host ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json > dist-manifest.json + echo "artifacts uploaded and released successfully" + cat dist-manifest.json + echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT" + - name: "Upload dist-manifest.json" + uses: actions/upload-artifact@v7 + with: + # Overwrite the previous copy + name: artifacts-dist-manifest + path: dist-manifest.json + # Create a GitHub Release while uploading all files to it + - name: "Download GitHub Artifacts" + uses: actions/download-artifact@v8 + with: + pattern: artifacts-* + path: artifacts + merge-multiple: true + - name: Cleanup + run: | + # Remove the granular manifests + rm -f artifacts/*-dist-manifest.json + - name: Create GitHub Release + env: + PRERELEASE_FLAG: "${{ fromJson(steps.host.outputs.manifest).announcement_is_prerelease && '--prerelease' || '' }}" + ANNOUNCEMENT_TITLE: "${{ fromJson(steps.host.outputs.manifest).announcement_title }}" + ANNOUNCEMENT_BODY: "${{ fromJson(steps.host.outputs.manifest).announcement_github_body }}" + RELEASE_COMMIT: "${{ github.sha }}" + run: | + # Write and read notes from a file to avoid quoting breaking things + echo "$ANNOUNCEMENT_BODY" > $RUNNER_TEMP/notes.txt + + 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: + 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 + + announce: + needs: + - plan + - host + - publish-homebrew-formula + # 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') }} + runs-on: "ubuntu-22.04" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + submodules: recursive diff --git a/Cargo.toml b/Cargo.toml index b11739c..cbb770e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,3 +46,8 @@ assert_cmd = "2" predicates = "3" insta = { version = "1", features = ["yaml"] } tempfile = "3" + +# The profile that 'dist' will build with +[profile.dist] +inherits = "release" +lto = "thin" diff --git a/dist-workspace.toml b/dist-workspace.toml new file mode 100644 index 0000000..5d9685c --- /dev/null +++ b/dist-workspace.toml @@ -0,0 +1,25 @@ +[workspace] +members = ["cargo:."] + +# Config for 'dist' +[dist] +# The preferred dist version to use in CI (Cargo.toml SemVer syntax) +cargo-dist-version = "0.32.0" +# CI backends to support +ci = "github" +# The installers to generate for each app +installers = ["shell", "powershell", "homebrew"] +# Target platforms to build apps for (Rust target-triple syntax) +targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "aarch64-unknown-linux-musl", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-unknown-linux-musl", "x86_64-pc-windows-msvc"] +# Path that installers should place binaries in +install-path = "CARGO_HOME" +# Where to host releases +hosting = "github" +# Whether to install an updater program +install-updater = false +# Homebrew tap repository to publish the formula to +tap = "quicknode/homebrew-tap" +# Create the formula under quicknode/tap → users install via `brew install quicknode/tap/qn` +publish-jobs = ["homebrew"] +# Emit SLSA build attestations for every binary archive. Critical for a credential-handling tool. +github-attestations = true From 22ae7e80924b0049b32e8c2fda154540d585896c Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Wed, 3 Jun 2026 22:49:50 -0400 Subject: [PATCH 3/7] Override release-bot identity to Quicknode The cargo-dist template hardcodes "axo bot" as the git committer for Homebrew formula updates pushed to the tap repo. Use "Quicknode Release Bot" instead so the tap repo's commit history is branded correctly. The noreply.github.com domain is GitHub's standard non-routable address for bot identities, so it doesn't promise an inbox we don't own. Also lock in the Quicknode brand capitalization (capital Q, lowercase n, never "QuickNode") in CLAUDE.md so this kind of slip doesn't recur in generated config. This override will get clobbered the next time we run `dist generate`. The Justfile recipe coming in Phase 0c re-applies the override post-generation. --- .github/workflows/release.yml | 4 ++-- CLAUDE.md | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3ad66b2..166e4ab 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -294,8 +294,8 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PLAN: ${{ needs.plan.outputs.val }} - GITHUB_USER: "axo bot" - GITHUB_EMAIL: "admin+bot@axo.dev" + GITHUB_USER: "Quicknode Release Bot" + GITHUB_EMAIL: "release-bot@users.noreply.github.com" if: ${{ !fromJson(needs.plan.outputs.val).announcement_is_prerelease || fromJson(needs.plan.outputs.val).publish_prereleases }} steps: - uses: actions/checkout@v6 diff --git a/CLAUDE.md b/CLAUDE.md index d383c87..221d281 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,6 +6,8 @@ This file documents the patterns established while building `qn` so future work Everything in this repository is — or will be — public. Treat every file, commit message, issue, and PR description as world-readable. +**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. - **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. From f8d964c08ff258fd6f66e47a2d10ac517e456452 Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Wed, 3 Jun 2026 22:51:30 -0400 Subject: [PATCH 4/7] Add Justfile for release orchestration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the shape of qn/sdk's Justfile: local-dev recipes (test, lint, fmt-check, fmt), a `dist-regen` recipe that re-applies our Quicknode bot-identity override after running `dist generate`, and a Phase 1 release flow with staged confirmation checkpoints. The release flow: release-prepare VERSION → release-bump VERSION (sed-bump Cargo.toml, commit on release/vX.Y.Z; refresh Cargo.lock) → release-open-pr VERSION (gh pr create) → release-merge-pr VERSION (gh pr merge --squash, poll for MERGED) → release-tag-main VERSION (tag + push — triggers release.yml) → release-create-tag VERSION (gh release create --generate-notes) → release-wait-ci VERSION (gh run watch) Same safety checks as the SDK: clean tree, must be on main, no leading `v`, semver-shape regex, prompts before push and before squash-merge. Each recipe is also callable standalone for retry/recovery. Phase 1 (crates.io publish recipes) and the eventual release-publish orchestrator land in a follow-up commit. --- Justfile | 188 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 Justfile diff --git a/Justfile b/Justfile new file mode 100644 index 0000000..2ec1b95 --- /dev/null +++ b/Justfile @@ -0,0 +1,188 @@ +# Local development + +# Run the test suite. +test: + cargo test --all + +# Run clippy in deny-warnings mode against everything. +lint: + cargo clippy --all-targets -- -D warnings + +# Verify formatting without modifying anything. +fmt-check: + cargo fmt --all -- --check + +# Apply formatting. +fmt: + cargo fmt --all + +# Release pipeline maintenance + +# Regenerate the dist workflow and re-apply our local overrides. +# Run this any time you change dist-workspace.toml or upgrade cargo-dist. +dist-regen: + #!/usr/bin/env bash + set -euo pipefail + dist generate + # cargo-dist hardcodes the axo bot identity in the generated workflow. + # Re-brand it to Quicknode after each regeneration. + sed -i.bak \ + -e 's|GITHUB_USER: "axo bot"|GITHUB_USER: "Quicknode Release Bot"|' \ + -e 's|GITHUB_EMAIL: "admin+bot@axo.dev"|GITHUB_EMAIL: "release-bot@users.noreply.github.com"|' \ + .github/workflows/release.yml + rm -f .github/workflows/release.yml.bak + echo "Regenerated .github/workflows/release.yml with Quicknode bot identity." + +# 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. + +# Bump version in Cargo.toml on a fresh release/vX.Y.Z branch. +# Usage: just release-bump 0.2.0 +release-bump version: + #!/usr/bin/env bash + set -euo pipefail + raw_version="{{version}}" + if [[ "$raw_version" =~ ^v ]]; then + echo "Error: version '$raw_version' must not start with 'v'. The 'v' prefix is added automatically when tagging. Try: just release-bump ${raw_version#v}" >&2 + exit 1 + fi + if [[ ! "$raw_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then + echo "Error: version '$raw_version' is not valid semver (expected X.Y.Z or X.Y.Z-rc.N)." >&2 + exit 1 + fi + current_branch=$(git rev-parse --abbrev-ref HEAD) + if [[ "$current_branch" != "main" ]]; then + echo "Error: must be on main to start a release (currently on '$current_branch')." >&2 + exit 1 + fi + if ! git diff --quiet || ! git diff --cached --quiet; then + echo "Error: working tree is not clean. Commit or stash changes before bumping." >&2 + exit 1 + fi + git checkout -b "release/v{{version}}" + sed -i.bak 's/^version = ".*"/version = "{{version}}"/' Cargo.toml && rm Cargo.toml.bak + # Refresh Cargo.lock so it matches the new version. + cargo check --quiet + git add Cargo.toml Cargo.lock + git commit -m "Release v{{version}}" + echo "Committed bump on branch release/v{{version}}. Next: just release-open-pr {{version}}" + +# Push the release branch and open a PR for the bump commit. +release-open-pr version: + #!/usr/bin/env bash + set -euo pipefail + git push -u origin "release/v{{version}}" + gh pr create \ + --base main \ + --head "release/v{{version}}" \ + --title "Release v{{version}}" \ + --body "Automated release bump for v{{version}}. Merging this PR triggers the rest of the release flow (tag, GitHub release, CI artifacts)." + +# Squash-merge the release PR (with confirmation) and poll until MERGED. +release-merge-pr version: + #!/usr/bin/env bash + set -euo pipefail + pr_state=$(gh pr view "release/v{{version}}" --json state -q .state) + if [[ "$pr_state" == "MERGED" ]]; then + echo "PR for release/v{{version}} already merged." + exit 0 + fi + read -r -p "Merge release PR for v{{version}} now via 'gh pr merge --squash --delete-branch'? [y/N] " response + if [[ "$response" =~ ^[Yy]$ ]]; then + gh pr merge "release/v{{version}}" --squash --delete-branch + else + echo "Aborted. Merge the PR manually in GitHub, then re-run: just release-prepare {{version}}" >&2 + exit 1 + fi + for attempt in $(seq 1 20); do + pr_state=$(gh pr view "release/v{{version}}" --json state -q .state) + if [[ "$pr_state" == "MERGED" ]]; then + echo "PR merged." + exit 0 + fi + sleep 3 + done + echo "Error: PR for release/v{{version}} did not reach MERGED state." >&2 + exit 1 + +# Tag the post-merge HEAD of main and push the tag. The tag push is what +# triggers .github/workflows/release.yml. +release-tag-main version: + #!/usr/bin/env bash + set -euo pipefail + git checkout main + git pull --ff-only origin main + git tag "v{{version}}" + git push origin "v{{version}}" + +# Create the GitHub release for the pushed tag, generating notes from commits. +# The tag push above already started release.yml; this just publishes the +# release object so cargo-dist can attach artifacts to it. +release-create-tag version: + gh release create v{{version}} --generate-notes --target main --title "v{{version}}" + +# Wait for the release-triggered run of release.yml to finish. +release-wait-ci version: + #!/usr/bin/env bash + set -euo pipefail + echo "Waiting for release.yml run for tag v{{version}}..." + for attempt in $(seq 1 30); do + run_id=$(gh run list --workflow=release.yml --event=push --limit 20 --json databaseId,headBranch \ + --jq '.[] | select(.headBranch == "v{{version}}") | .databaseId' | head -n1) + if [[ -n "${run_id:-}" ]]; then + echo "Found release.yml run $run_id for v{{version}}" + gh run watch "$run_id" --exit-status + exit 0 + fi + echo " attempt $attempt/30: run not visible yet, sleeping 5s..." + sleep 5 + done + echo "Error: timed out waiting for release.yml run for v{{version}} to appear." >&2 + exit 1 + +# Orchestrates the full Phase 1 release with two confirmation checkpoints: +# one before pushing the bump branch, one before squash-merging the PR. +# Pass yes=1 to skip prompts (for automation). +# Usage: just release-prepare 0.2.0 +release-prepare version yes="0": + #!/usr/bin/env bash + set -euo pipefail + if [[ "{{yes}}" != "1" ]]; then + echo "About to release v{{version}}:" + echo " 1. Bump version in Cargo.toml" + echo " 2. Commit on branch release/v{{version}}" + echo " --- review diff and confirm before push ---" + echo " 3. Push branch + open PR (review checkpoint)" + echo " 4. Merge PR via 'gh pr merge --squash --delete-branch'" + echo " 5. Tag the merge commit on main and push the tag" + echo " 6. Create GitHub release v{{version}}" + echo " 7. Wait for release.yml CI to attach artifacts" + echo + read -r -p "Continue? [y/N] " response + [[ "$response" =~ ^[Yy]$ ]] || { echo "Aborted."; exit 1; } + fi + just release-bump {{version}} + echo + echo "=== Bump commit (HEAD) ===" + git --no-pager show --stat HEAD + echo + echo "=== Diff vs main ===" + git --no-pager diff main...HEAD -- Cargo.toml Cargo.lock + echo + if [[ "{{yes}}" != "1" ]]; then + echo "Review the bump above. Pushing will open a PR for review." + read -r -p "Push branch release/v{{version}} and open PR? [y/N] " response + if [[ ! "$response" =~ ^[Yy]$ ]]; then + echo "Aborted before push. The bump commit exists locally on release/v{{version}} — undo with:" + echo " git checkout main && git branch -D release/v{{version}}" + exit 1 + fi + fi + just release-open-pr {{version}} + just release-merge-pr {{version}} + just release-tag-main {{version}} + just release-create-tag {{version}} + just release-wait-ci {{version}} + echo + echo "Phase 1 complete. Inspect the release at:" + echo " https://github.com/$(gh repo view --json nameWithOwner -q .nameWithOwner)/releases/tag/v{{version}}" From 4abecf5680362d55f5214e0cce9127fddd039ae3 Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Wed, 3 Jun 2026 22:57:04 -0400 Subject: [PATCH 5/7] Rename crate to quicknode-cli; preserve `qn` for the binary and formula MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `qn` name is already claimed on crates.io by an unrelated quantum computing library (marek-miller/qn, "Non-local qubits"), so we can't publish under that name. Rename the package to `quicknode-cli` — this parallels the sister crate `quicknode-sdk` on crates.io. User-facing surface is unchanged: the installed binary stays `qn` (the `[[bin]]` section is what determines the binary name on PATH, not [package].name), and the Homebrew formula is also `qn` thanks to the new `formula = "qn"` override in dist-workspace.toml. So `cargo install quicknode-cli` installs `qn`, and `brew install quicknode/tap/qn` works as designed. Only the cargo install command changes. Also revert the cargo-dist bot identity override: cargo-dist's `plan` step does an internal "generated workflow is up to date" check and hard-fails CI on any post-generation edit. The Quicknode-branded bot strings caused exit 255 from `dist plan`. The "axo bot" attribution appears only on commits in the homebrew-tap repo's history (not on any end-user surface), so the cost of leaving it is low. Simplify the `dist-regen` Justfile recipe accordingly — there's no override to re-apply anymore. --- .github/workflows/release.yml | 4 ++-- Cargo.lock | 2 +- Cargo.toml | 2 +- Justfile | 19 +++++++------------ dist-workspace.toml | 4 +++- 5 files changed, 14 insertions(+), 17 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 166e4ab..3ad66b2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -294,8 +294,8 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PLAN: ${{ needs.plan.outputs.val }} - GITHUB_USER: "Quicknode Release Bot" - GITHUB_EMAIL: "release-bot@users.noreply.github.com" + 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 diff --git a/Cargo.lock b/Cargo.lock index 175b419..0af5597 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1478,7 +1478,7 @@ dependencies = [ ] [[package]] -name = "qn" +name = "quicknode-cli" version = "0.1.0" dependencies = [ "assert_cmd", diff --git a/Cargo.toml b/Cargo.toml index cbb770e..20ae562 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "qn" +name = "quicknode-cli" version = "0.1.0" edition = "2021" description = "Command-line interface for the Quicknode SDK" diff --git a/Justfile b/Justfile index 2ec1b95..7b2a6e8 100644 --- a/Justfile +++ b/Justfile @@ -18,20 +18,15 @@ fmt: # Release pipeline maintenance -# Regenerate the dist workflow and re-apply our local overrides. -# Run this any time you change dist-workspace.toml or upgrade cargo-dist. +# Regenerate the dist workflow. Run this any time you change +# dist-workspace.toml or upgrade cargo-dist. +# +# Note: cargo-dist's `plan` step does an internal "is the generated +# workflow up to date" check and hard-fails CI on any divergence, so we +# can't post-process the output here. The "axo bot" attribution on tap +# repo commits stays as-is until upstream exposes a config knob. dist-regen: - #!/usr/bin/env bash - set -euo pipefail dist generate - # cargo-dist hardcodes the axo bot identity in the generated workflow. - # Re-brand it to Quicknode after each regeneration. - sed -i.bak \ - -e 's|GITHUB_USER: "axo bot"|GITHUB_USER: "Quicknode Release Bot"|' \ - -e 's|GITHUB_EMAIL: "admin+bot@axo.dev"|GITHUB_EMAIL: "release-bot@users.noreply.github.com"|' \ - .github/workflows/release.yml - rm -f .github/workflows/release.yml.bak - echo "Regenerated .github/workflows/release.yml with Quicknode bot identity." # 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/dist-workspace.toml b/dist-workspace.toml index 5d9685c..7287de7 100644 --- a/dist-workspace.toml +++ b/dist-workspace.toml @@ -19,7 +19,9 @@ hosting = "github" install-updater = false # Homebrew tap repository to publish the formula to tap = "quicknode/homebrew-tap" -# Create the formula under quicknode/tap → users install via `brew install quicknode/tap/qn` +# 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"] # Emit SLSA build attestations for every binary archive. Critical for a credential-handling tool. github-attestations = true From 947db2afd4fd78848617b8be38624fbf7c8c0150 Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Wed, 3 Jun 2026 23:00:56 -0400 Subject: [PATCH 6/7] Wire crates.io publish into the release flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cargo-dist doesn't ship a built-in cargo/crates publish job, so we use its user_publish_jobs mechanism: a reusable workflow at .github/workflows/publish-crates.yml gets called by a custom-publish-crates job that cargo-dist generates into release.yml. The wiring puts crates.io publish in the right place in the orchestration: plan → build → host (create GH release + upload artifacts) → publish-homebrew-formula → custom-publish-crates ← runs cargo publish here → announce Both publish jobs gate on `!is_prerelease || publish_prereleases`, so release candidates skip crates.io until we decide to ship them there. The reusable workflow also asserts the tag version matches the Cargo.toml version before publishing, so a tag-without-bump (or vice versa) fails loudly rather than uploading the wrong version to crates.io. Justfile gains `release-cargo-publish-check` (dry-run) and `release-cargo-publish` (manual recovery only — CI normally owns this). Requires CARGO_REGISTRY_TOKEN repo secret before the first non-rc tag. --- .github/workflows/publish-crates.yml | 40 ++++++++++++++++++++++++++++ .github/workflows/release.yml | 17 +++++++++++- Justfile | 13 +++++++++ dist-workspace.toml | 2 +- 4 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/publish-crates.yml diff --git a/.github/workflows/publish-crates.yml b/.github/workflows/publish-crates.yml new file mode 100644 index 0000000..84b094a --- /dev/null +++ b/.github/workflows/publish-crates.yml @@ -0,0 +1,40 @@ +name: Publish to crates.io + +# Reusable workflow invoked by cargo-dist's release pipeline as a +# user_publish_job. cargo-dist gates this on tag releases (and skips it +# for pre-releases unless publish_prereleases is enabled in +# dist-workspace.toml). +on: + workflow_call: + inputs: + plan: + description: dist-manifest JSON for this announcement + required: true + type: string + +jobs: + publish: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + + - name: Verify the bumped Cargo.toml version matches the release tag + env: + PLAN: ${{ inputs.plan }} + run: | + tag_version=$(echo "$PLAN" | jq -r '.releases[0].app_version') + cargo_version=$(cargo metadata --no-deps --format-version 1 \ + | jq -r '.packages[] | select(.name == "quicknode-cli") | .version') + if [[ "$tag_version" != "$cargo_version" ]]; then + echo "Error: tag version ($tag_version) does not match Cargo.toml version ($cargo_version)." >&2 + echo "This indicates the release-bump step was skipped or the wrong commit was tagged." >&2 + exit 1 + fi + echo "Version check passed: $tag_version" + + - uses: dtolnay/rust-toolchain@stable + + - name: cargo publish + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + run: cargo publish -p quicknode-cli diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3ad66b2..8389c8d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -332,15 +332,30 @@ jobs: done git push + custom-publish-crates: + 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 + with: + plan: ${{ needs.plan.outputs.val }} + secrets: inherit + # publish jobs get escalated permissions + permissions: + "id-token": "write" + "packages": "write" + announce: needs: - plan - host - publish-homebrew-formula + - custom-publish-crates # 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') }} + 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') }} runs-on: "ubuntu-22.04" env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/Justfile b/Justfile index 7b2a6e8..95bd0c1 100644 --- a/Justfile +++ b/Justfile @@ -28,6 +28,19 @@ fmt: dist-regen: dist generate +# Validate the crate tarball without uploading. Run before tagging to +# catch packaging errors (missing license, README, dirty tree, etc.) +# while there's still time to fix them. +release-cargo-publish-check: + cargo publish -p quicknode-cli --dry-run + +# Publish the crate to crates.io. Normally invoked from CI by the +# custom-publish-crates job in release.yml; this recipe is for manual +# recovery if CI's publish step fails and we need to retry from a +# clean local tree. Requires `cargo login` first. +release-cargo-publish: + cargo publish -p quicknode-cli + # 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/dist-workspace.toml b/dist-workspace.toml index 7287de7..2637093 100644 --- a/dist-workspace.toml +++ b/dist-workspace.toml @@ -22,6 +22,6 @@ 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-jobs = ["homebrew", "./publish-crates"] # Emit SLSA build attestations for every binary archive. Critical for a credential-handling tool. github-attestations = true From 926879868b7303eb8564e673c18ef94e7ad0da9a Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Thu, 4 Jun 2026 08:58:49 -0400 Subject: [PATCH 7/7] Publish multi-arch Docker image to GHCR on release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds: * Dockerfile — single-stage, FROM distroless/static-debian12:nonroot, COPYs the prebuilt musl binary at /qn. No in-image compilation; the SLSA-attested binary that ships in the GitHub release IS what ships in the image. Distroless gives us a CA bundle (for any future HTTPS-calling qn feature) without a shell or package manager. * publish-docker.yml — reusable workflow invoked by cargo-dist as a user_publish_job. Downloads both musl tarballs from the GitHub release, stages per-arch build contexts, pushes single-arch images, then stitches them into a multi-arch manifest at the version tag. Promotes to :latest only for non-prerelease releases. * dist-workspace.toml — wires ./publish-docker into publish-jobs. The image is published private — the package's visibility needs to be flipped to private once after the first push (GHCR defaults to inherit- from-repo, which is public for a public repo). Pulls then require `docker login ghcr.io` with a PAT scoped to read:packages. End-to-end-tested the Dockerfile locally with a static musl-style binary: builds clean, runs under the nonroot user, exits 0. --- .github/workflows/publish-docker.yml | 115 +++++++++++++++++++++++++++ .github/workflows/release.yml | 17 +++- Dockerfile | 17 ++++ dist-workspace.toml | 2 +- 4 files changed, 149 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/publish-docker.yml create mode 100644 Dockerfile diff --git a/.github/workflows/publish-docker.yml b/.github/workflows/publish-docker.yml new file mode 100644 index 0000000..db88cff --- /dev/null +++ b/.github/workflows/publish-docker.yml @@ -0,0 +1,115 @@ +name: Publish Docker image to GHCR + +# Reusable workflow invoked by cargo-dist's release pipeline as a +# user_publish_job (see dist-workspace.toml `publish-jobs`). +# +# Builds a multi-arch (linux/amd64 + linux/arm64) image from the +# pre-built musl binaries attached to the GitHub release, pushes +# per-arch tags to GHCR, and stitches them into a multi-arch manifest +# at the canonical tag. The image is published private — see Phase 2 +# of the packaging plan for the visibility flip. +on: + workflow_call: + inputs: + plan: + description: dist-manifest JSON for this announcement + required: true + type: string + +permissions: + contents: read + packages: write + id-token: write + attestations: write + +env: + REGISTRY: ghcr.io + IMAGE_NAME: quicknode/qn + +jobs: + publish: + runs-on: ubuntu-22.04 + 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') + is_prerelease=$(echo "$PLAN" | jq -r '.announcement_is_prerelease') + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "is_prerelease=$is_prerelease" >> "$GITHUB_OUTPUT" + + - name: Download musl artifacts from the GitHub release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + mkdir -p artifacts + gh release download "${{ steps.meta.outputs.tag }}" \ + --pattern '*linux-musl*.tar.xz' \ + --dir artifacts/ + + - name: Stage per-arch binaries + run: | + mkdir -p build/amd64 build/arm64 + tar -xf artifacts/quicknode-cli-x86_64-unknown-linux-musl.tar.xz \ + --strip-components=1 -C build/amd64 \ + --wildcards '*/qn' + tar -xf artifacts/quicknode-cli-aarch64-unknown-linux-musl.tar.xz \ + --strip-components=1 -C build/arm64 \ + --wildcards '*/qn' + chmod +x build/amd64/qn build/arm64/qn + file build/amd64/qn build/arm64/qn + + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push linux/amd64 + id: amd64 + uses: docker/build-push-action@v6 + with: + context: build/amd64 + file: Dockerfile + platforms: linux/amd64 + push: true + provenance: true + sbom: true + tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}-amd64 + + - name: Build and push linux/arm64 + id: arm64 + uses: docker/build-push-action@v6 + with: + context: build/arm64 + file: Dockerfile + platforms: linux/arm64 + push: true + provenance: true + sbom: true + tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}-arm64 + + - name: Create and push multi-arch manifest for v${{ steps.meta.outputs.version }} + run: | + docker buildx imagetools create \ + -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }} \ + -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:v${{ steps.meta.outputs.version }} \ + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}-amd64 \ + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}-arm64 + + - name: Promote to :latest (skip for prereleases) + if: ${{ steps.meta.outputs.is_prerelease == 'false' }} + run: | + docker buildx imagetools create \ + -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest \ + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8389c8d..5ca58df 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -346,16 +346,31 @@ jobs: "id-token": "write" "packages": "write" + 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-docker.yml + with: + plan: ${{ needs.plan.outputs.val }} + secrets: inherit + # publish jobs get escalated permissions + permissions: + "id-token": "write" + "packages": "write" + announce: needs: - plan - host - publish-homebrew-formula - custom-publish-crates + - custom-publish-docker # 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') }} + 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') }} runs-on: "ubuntu-22.04" env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c04dc8d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +# syntax=docker/dockerfile:1.7 + +# We do NOT compile here. The publish-docker workflow pulls the +# cross-compiled musl binary from the GitHub release artifacts and +# stages it at ./qn before invoking `docker buildx build`. That keeps +# the multi-arch image build a no-op COPY rather than a cargo rebuild +# (the SLSA-attested binary in the release IS what ships in the image). +ARG TARGETARCH + +FROM gcr.io/distroless/static-debian12:nonroot +LABEL org.opencontainers.image.source="https://github.com/quicknode/cli" +LABEL org.opencontainers.image.description="qn — Quicknode CLI" +LABEL org.opencontainers.image.licenses="MIT" + +COPY --chown=nonroot:nonroot qn /qn +USER nonroot +ENTRYPOINT ["/qn"] diff --git a/dist-workspace.toml b/dist-workspace.toml index 2637093..87b6296 100644 --- a/dist-workspace.toml +++ b/dist-workspace.toml @@ -22,6 +22,6 @@ 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-jobs = ["homebrew", "./publish-crates", "./publish-docker"] # Emit SLSA build attestations for every binary archive. Critical for a credential-handling tool. github-attestations = true