Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 105 additions & 34 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,13 +1,26 @@
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.
# Every push to main runs release-please, which keeps a release PR open with
# the next version and changelog. Merging that PR creates the tag and GitHub
# Release, and the jobs below then attach attested binaries and publish the
# crate to crates.io. The release runs in this workflow, not on the tag, so
# it never depends on a tag push starting a run, and a pushed tag releases
# nothing.
# If a release fails after release-please created it, rerun the failed jobs,
# or dispatch this workflow on main with the release tag: either rebuilds,
# re-attests and re-uploads that release and retries the crates.io publish.
# Users verify a binary against this file's path and refs/heads/main, so
# renaming the file or running it from another ref fails the README's check.

on:
push:
tags: ["v*"]
branches: [main]
workflow_dispatch:
inputs:
tag:
description: Existing release tag to rebuild and publish, e.g. v0.1.0
required: true
type: string

permissions:
contents: read
Expand All @@ -17,29 +30,95 @@ concurrency:
cancel-in-progress: false

jobs:
release-please:
runs-on: ubuntu-latest
timeout-minutes: 15
outputs:
release_created: ${{ steps.release.outputs.release_created }}
tag_name: ${{ steps.release.outputs.tag_name }}
# On a dispatch the steps skip but the job still succeeds: a release
# created here would otherwise lose its build to the dispatched tag, and a
# skipped job would skip every job after it.
steps:
# The App token, not GITHUB_TOKEN, opens the release PR, so CI runs on it.
- uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
if: github.event_name == 'push'
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
# Only what release-please and the assignee step use.
permission-contents: write
permission-pull-requests: write
permission-issues: write

- uses: googleapis/release-please-action@16a9c90856f42705d54a6fda1823352bdc62cf38 # v4
if: github.event_name == 'push'
id: release
with:
token: ${{ steps.app-token.outputs.token }}

- name: Assign the release PR to the maintainer
if: ${{ steps.release.outputs.pr }}
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
GH_REPO: ${{ github.repository }}
# Via env: the PR JSON carries branch and title text.
PR_JSON: ${{ steps.release.outputs.pr }}
# REST, not gh pr edit, which also queries project fields an App
# token cannot read.
run: |
number=$(jq -er .number <<< "$PR_JSON")
gh api --method POST "repos/$GH_REPO/issues/$number/assignees" -f "assignees[]=27Bslash6" > /dev/null

check:
needs: release-please
if: >-
needs.release-please.outputs.release_created == 'true'
|| (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main')
runs-on: ubuntu-latest
timeout-minutes: 15
outputs:
Comment thread
kodus-27b[bot] marked this conversation as resolved.
tag: ${{ steps.tag.outputs.tag }}
sha: ${{ steps.sha.outputs.sha }}
env:
# A dispatch names the tag; a push releases what release-please created.
TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || needs.release-please.outputs.tag_name }}
Comment thread
27Bslash6 marked this conversation as resolved.
steps:
- name: Tag is a release version
id: tag
run: |
if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::$TAG is not a release tag such as v0.1.0"
exit 1
fi
echo "tag=$TAG" >> "$GITHUB_OUTPUT"

- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Fully qualified, so a branch with the tag's name is never built.
ref: refs/tags/${{ steps.tag.outputs.tag }}
fetch-depth: 0
persist-credentials: false

- name: Tagged commit is on main
id: sha
# A dispatch can name any tag; only one on main may be built and
# attested as a run from main. Later jobs check out this exact commit,
# so a tag moved after this check cannot change what ships.
run: |
git merge-base --is-ancestor HEAD origin/main \
|| { echo "::error::$TAG is not on main"; exit 1; }
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"

- 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"
if [ "$TAG" != "v$version" ]; then
echo "::error::tag $TAG 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
Expand All @@ -57,10 +136,12 @@ jobs:
- { os: ubuntu-24.04-arm, target: aarch64-unknown-linux-musl }
- { os: macos-latest, target: aarch64-apple-darwin }
env:
TAG: ${{ needs.check.outputs.tag }}
TARGET: ${{ matrix.target }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.check.outputs.sha }}
persist-credentials: false

- name: Install the musl toolchain
Expand All @@ -79,7 +160,7 @@ jobs:

- name: Archive
run: |
dir="ck-$GITHUB_REF_NAME-$TARGET"
dir="ck-$TAG-$TARGET"
mkdir "$dir"
cp "target/$TARGET/release/ck" LICENSE README.md "$dir/"
tar -czf "$dir.tar.gz" "$dir"
Expand All @@ -91,13 +172,13 @@ jobs:
if-no-files-found: error

release:
needs: build
needs: [check, 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
contents: write # upload to the release
id-token: write # sign the attestation
attestations: write # store the attestation
steps:
Expand All @@ -117,28 +198,17 @@ jobs:
dist/*.tar.gz
dist/ck-*/ck

- name: Create the GitHub Release
- name: Upload to the GitHub Release
# release-please created the release. --clobber lets a rerun replace
# the archives with its own attested ones.
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
TAG: ${{ needs.check.outputs.tag }}
run: gh release upload "$TAG" --clobber dist/*.tar.gz

publish:
needs: release
needs: [check, release]
runs-on: ubuntu-latest
timeout-minutes: 15
# crates.io accepts a token minted here only from this workflow in this
Expand All @@ -150,12 +220,13 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.check.outputs.sha }}
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,
# by hand with an API token first. Every released 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
Expand Down
3 changes: 3 additions & 0 deletions .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
".": "0.0.0"
}
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,12 @@ 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
--source-ref refs/heads/main --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:
workflow, run from `main`, 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.
Expand Down
26 changes: 26 additions & 0 deletions release-please-config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
"release-type": "rust",
"bump-minor-pre-major": true,
"changelog-sections": [
{"type": "feat", "section": "Features"},
{"type": "fix", "section": "Bug Fixes"},
{"type": "perf", "section": "Performance Improvements"},
{"type": "security", "section": "Security"},
{"type": "revert", "section": "Reverts"},
{"type": "docs", "section": "Documentation", "hidden": true},
{"type": "chore", "section": "Miscellaneous", "hidden": true},
{"type": "refactor", "section": "Code Refactoring", "hidden": true},
{"type": "style", "section": "Styles", "hidden": true},
{"type": "test", "section": "Tests", "hidden": true},
{"type": "ci", "section": "CI/CD", "hidden": true},
{"type": "build", "section": "Build System", "hidden": true}
],
"packages": {
".": {
"package-name": "cachekit-cli",
"changelog-path": "CHANGELOG.md",
"include-component-in-tag": false
}
}
}
Loading