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/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 new file mode 100644 index 0000000..5ca58df --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,381 @@ +# 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 + + 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" + + 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') && (needs.custom-publish-docker.result == 'skipped' || needs.custom-publish-docker.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/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. 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 6fa9d15..20ae562 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,10 +1,10 @@ [package] -name = "qn" +name = "quicknode-cli" 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" @@ -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/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/Justfile b/Justfile new file mode 100644 index 0000000..95bd0c1 --- /dev/null +++ b/Justfile @@ -0,0 +1,196 @@ +# 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. 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: + 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. + +# 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}}" diff --git a/dist-workspace.toml b/dist-workspace.toml new file mode 100644 index 0000000..87b6296 --- /dev/null +++ b/dist-workspace.toml @@ -0,0 +1,27 @@ +[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" +# 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"] +# Emit SLSA build attestations for every binary archive. Critical for a credential-handling tool. +github-attestations = true