diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..82cbabd --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,168 @@ +name: Release + +# A version tag (v0.1.0, v0.2.0-rc.1) on a commit in main publishes that +# version: attested binaries on the GitHub Release, then the crate on +# crates.io. Users verify a binary against this file's path, so renaming it +# breaks the verify command in the README. + +on: + push: + tags: ["v*"] + +permissions: + contents: read + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + check: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Tag matches the crate version + run: | + version=$(cargo metadata --locked --no-deps --format-version 1 | jq -r '.packages[0].version') + if [ "$GITHUB_REF_NAME" != "v$version" ]; then + echo "::error::tag $GITHUB_REF_NAME does not match the crate version $version" + exit 1 + fi + + - name: Tagged commit is on main + # Catches a version tag pushed on the wrong commit by mistake. + run: | + git merge-base --is-ancestor "$GITHUB_SHA" origin/main \ + || { echo "::error::$GITHUB_SHA is not on main"; exit 1; } + + - name: Package the crate + # Builds the packaged crate, so the publish job can skip that build. + run: cargo package --locked + + build: + name: build (${{ matrix.target }}) + needs: check + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + matrix: + include: + # musl builds are static, so they run on any Linux distribution. + - { os: ubuntu-latest, target: x86_64-unknown-linux-musl } + - { os: ubuntu-24.04-arm, target: aarch64-unknown-linux-musl } + - { os: macos-latest, target: aarch64-apple-darwin } + env: + TARGET: ${{ matrix.target }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install the musl toolchain + if: runner.os == 'Linux' + # ring compiles C, which needs musl-gcc. + run: sudo apt-get update && sudo apt-get install -y --no-install-recommends musl-tools + + - name: Add the target + run: rustup target add "$TARGET" + + - name: Test the shipped target + run: cargo test --locked --release --target "$TARGET" + + - name: Build + run: cargo build --locked --release --target "$TARGET" + + - name: Archive + run: | + dir="ck-$GITHUB_REF_NAME-$TARGET" + mkdir "$dir" + cp "target/$TARGET/release/ck" LICENSE README.md "$dir/" + tar -czf "$dir.tar.gz" "$dir" + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ matrix.target }} + path: ck-*.tar.gz + if-no-files-found: error + + release: + needs: build + runs-on: ubuntu-latest + timeout-minutes: 15 + # Signing happens here rather than in the build jobs, so dependency build + # scripts never run with a token that can sign or publish. + permissions: + contents: write # create the release + id-token: write # sign the attestation + attestations: write # store the attestation + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + path: dist + merge-multiple: true + + - name: Unpack the binaries + # Attesting the binary too lets a user verify ck after unpacking it. + run: for archive in dist/*.tar.gz; do tar -xzf "$archive" -C dist; done + + - name: Attest the archives and the binaries in them + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-path: | + dist/*.tar.gz + dist/ck-*/ck + + - name: Create the GitHub Release + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: | + # A rerun, after a failed upload or publish, finds the release + # already there: replace its archives with this run's attested ones + # so the publish job still runs. gh creates a release with assets as + # a draft and publishes it after the uploads, so a run that died + # mid-upload can leave a draft behind; publish it here. + if gh release view "$GITHUB_REF_NAME" > /dev/null 2>&1; then + gh release upload "$GITHUB_REF_NAME" --clobber dist/*.tar.gz + gh release edit "$GITHUB_REF_NAME" --draft=false + exit + fi + prerelease=() + case "$GITHUB_REF_NAME" in *-*) prerelease=(--prerelease) ;; esac + gh release create "$GITHUB_REF_NAME" --verify-tag --generate-notes "${prerelease[@]}" \ + dist/*.tar.gz + + publish: + needs: release + runs-on: ubuntu-latest + timeout-minutes: 15 + # crates.io accepts a token minted here only from this workflow in this + # environment: trusted publishing, so the repo holds no registry secret. + environment: release + permissions: + contents: read + id-token: write # exchange for a crates.io token + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # Needs the crate to exist on crates.io with this workflow and the + # `release` environment registered as its trusted publisher. crates.io + # attaches a publisher only to an existing crate, so 0.0.0 is published + # by hand with an API token first. Every tagged version comes after it, + # because this job cannot publish a version that already exists. + - uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18 # v1.0.5 + id: auth + + - name: Publish to crates.io + # The check job already built the packaged crate. A version can never + # be republished, so this runs last, after the GitHub Release exists. + env: + CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }} + run: cargo publish --locked --no-verify diff --git a/README.md b/README.md index 917d591..9ce79d3 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,44 @@ `ck`, the command-line client for [CacheKit](https://cachekit.io). -Pre-release: nothing is published yet. To try it, build from a checkout with -`cargo build --release` and put `target/release/ck` on your `PATH`. +## Install + +There is no install script. Install with Cargo, or download a release binary +and verify where it came from. + +### With Cargo + +```text +cargo install --locked cachekit-cli +``` + +This builds `ck` from the crate on crates.io and puts it in `~/.cargo/bin`. +Each crates.io release is published by this repository's release workflow. + +### Release binaries + +Each [release](https://github.com/cachekit-io/cachekit-cli/releases) carries +static Linux binaries for `x86_64` and `aarch64`, and a macOS binary for Apple +silicon. On other platforms, install with Cargo. Download the archive for your +platform, then verify it with the [GitHub CLI](https://cli.github.com/) before +you unpack it: + +```text +gh attestation verify ck-v0.1.0-x86_64-unknown-linux-musl.tar.gz --repo cachekit-io/cachekit-cli \ + --signer-workflow cachekit-io/cachekit-cli/.github/workflows/release.yml \ + --deny-self-hosted-runners +``` + +The check passes only if the file was built by this repository's release +workflow on a GitHub-hosted runner. `--owner cachekit-io` alone is not enough: +it accepts a file built by any workflow in any `cachekit-io` repository. The +`ck` binary inside the archive is attested too, so the same command verifies it +after unpacking. + +### From a checkout + +Build with `cargo build --release --locked` and put `target/release/ck` on +your `PATH`. ## `ck run`