diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 122e18d..4282784 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,7 +75,7 @@ jobs: strategy: fail-fast: false matrix: - runs-on: [ubuntu-latest, macos-14, windows-latest] + runs-on: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.runs-on }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.github/workflows/prepare-prerelease.yml b/.github/workflows/prepare-prerelease.yml index b2ad09e..704b97b 100644 --- a/.github/workflows/prepare-prerelease.yml +++ b/.github/workflows/prepare-prerelease.yml @@ -1,5 +1,11 @@ name: Prepare prerelease +# DEPRECATED: use release-plz-prerelease.yml instead. +# This knope-based workflow still works for callers pinned to a tag, and now +# also with `GITHUB_TOKEN` alone, but it will be removed in a future breaking +# release. +# See the README's "Migrating from knope to release-plz". + # Reusable workflow: cut a release candidate from the branch it is dispatched # on, so it can be tested before merging. Callers wire this to # workflow_dispatch only; the automatic dry-run-and-release half lives in @@ -37,13 +43,17 @@ on: token: description: >- Token used to push the prerelease commit and tag and to publish the - prerelease. Must be a PAT or app token if the push needs to trigger - further workflows, such as a binary build: pushes made with - GITHUB_TOKEN do not start new runs. - required: true + prerelease. Falls back to the job's own token, which is enough here: + the prerelease runs all its stages in one workflow and needs no + downstream trigger. The branch must accept a push from + github-actions. + required: false +# Write access, not read: the prerelease pushes a commit and a tag. This caps +# what the called job's token can do, and `contents: read` here made the +# `GITHUB_TOKEN` fallback impossible. permissions: - contents: read + contents: write jobs: prepare-prerelease: @@ -66,7 +76,7 @@ jobs: fetch-depth: 0 # Prerelease the dispatched branch, so its tag lands on that branch. ref: ${{ github.ref }} - token: ${{ secrets.token }} + token: ${{ secrets.token || github.token }} - name: Install knope uses: knope-dev/action@19617851f9f13ab2f27a05989c55efb18aca3675 # v2.1.2 @@ -94,7 +104,7 @@ jobs: - name: Prerelease env: - GITHUB_TOKEN: ${{ secrets.token }} + GITHUB_TOKEN: ${{ secrets.token || github.token }} PRERELEASE_LABEL: ${{ inputs.prerelease-label }} KNOPE_WORKFLOW: ${{ inputs.knope-workflow }} run: | diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index a0abc5c..c7b5b4a 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -1,5 +1,11 @@ name: Prepare release +# DEPRECATED: use release-plz.yml instead. +# This knope-based workflow needs a PAT or app token to work at all: pushes made +# with `GITHUB_TOKEN` start no workflow runs. It still works for callers pinned +# to a tag and will be removed in a future breaking release. +# See the README's "Migrating from knope to release-plz". + # Reusable workflow: the automatic half of a knope-driven release. # # pull_request dry run, so a PR shows what it would release diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index f0ce806..eb08ad2 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -1,5 +1,11 @@ name: Publish release +# DEPRECATED: use release-plz.yml (set `bin`); its publish job replaces this instead. +# This knope-based workflow needs a PAT or app token to work at all: pushes made +# with `GITHUB_TOKEN` start no workflow runs. It still works for callers pinned +# to a tag and will be removed in a future breaking release. +# See the README's "Migrating from knope to release-plz". + # Reusable workflow: the second half of a split knope release. The caller's # `draft-release` workflow prepared and pushed the version bump; this runs # knope's `Release` step once the artifacts to attach exist. diff --git a/.github/workflows/release-plz-prerelease.yml b/.github/workflows/release-plz-prerelease.yml new file mode 100644 index 0000000..94f4fd0 --- /dev/null +++ b/.github/workflows/release-plz-prerelease.yml @@ -0,0 +1,156 @@ +name: release-plz prerelease + +# Reusable workflow: publish a release candidate from the branch it is +# dispatched on, using only the job's `GITHUB_TOKEN`. Wire it to +# `workflow_dispatch` only. Replaces `rust-cli-prerelease.yml`. +# +# The purpose of this flow is to let consumers use a version of the tool that +# includes unmerged work, for example to try a branch in another repository's +# CI before the change lands. It is not a release candidate of the next +# release: the tag points at the branch head, not at a release PR. +# +# release-plz has no prerelease flow: it releases the version in the tree, and +# its release PR computes the next stable version. So this does not use +# release-plz to release anything. Instead it +# +# 1. builds the branch's binaries as the requested version, applying +# `release-plz set-version` in the runner's checkout only; +# 2. creates a draft GitHub release, attaches the binaries, and publishes it +# as a prerelease. +# +# Differences from the knope flow, all deliberate: +# - Nothing is committed or pushed. The tag `` is created +# at the branch's head commit, whose Cargo.toml still carries the old version. +# The binaries report the requested version. +# - The version is an explicit input; nothing computes the next `rc.N`. +# - No changelog is written, and the prerelease notes are GitHub's generated +# notes. +# +# The calling job must grant `contents: write`. The release is created as a +# draft and published only after the binaries are attached, as in +# release-plz.yml. +on: + workflow_call: + inputs: + bin: + description: Name of the binary to build, as `cargo build -p` expects it. + type: string + required: true + version: + description: >- + Full prerelease version, e.g. `1.3.0-rc.0`. Must carry a SemVer + prerelease suffix, so it can never collide with a stable release. + type: string + required: true + tag-prefix: + description: Prefix of the git tag. Must match the stable tags (release-plz's default is `v`). + type: string + required: false + default: v + matrix: + description: Platform matrix; see rust-cli-build.yml for the default. + type: string + required: false + default: "" + toolchain: + type: string + required: false + default: stable + artifact-path: + description: Directory the build artifacts are gathered into before upload. + type: string + required: false + default: release + default-branch: + description: Branch that releases through release-plz.yml, and so is refused here. + type: string + required: false + default: main + secrets: + token: + description: Optional PAT or app token, used instead of `GITHUB_TOKEN`. + required: false + +permissions: + contents: read + +jobs: + check: + name: check + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + tag: ${{ steps.check.outputs.tag }} + steps: + - name: Validate the request + id: check + env: + VERSION: ${{ inputs.version }} + TAG_PREFIX: ${{ inputs.tag-prefix }} + REF: ${{ github.ref }} + DEFAULT_BRANCH: ${{ inputs.default-branch }} + GH_TOKEN: ${{ secrets.token || github.token }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + if [[ "${REF}" != refs/heads/* ]]; then + echo "::error::Dispatch this from a branch, not ${REF}." + exit 1 + fi + if [[ "${REF}" == "refs/heads/${DEFAULT_BRANCH}" ]]; then + echo "::error::${DEFAULT_BRANCH} releases through release-plz.yml; dispatch this from the branch you want a release candidate for." + exit 1 + fi + # SemVer 2.0.0 with a mandatory prerelease part. + re='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-([0-9A-Za-z-]+)(\.[0-9A-Za-z-]+)*$' + if ! [[ "${VERSION}" =~ ${re} ]]; then + echo "::error::'${VERSION}' is not a SemVer prerelease version such as 1.3.0-rc.0." + exit 1 + fi + tag="${TAG_PREFIX}${VERSION}" + if gh release view "${tag}" --repo "${REPO}" >/dev/null 2>&1; then + echo "::error::A release named ${tag} already exists; pick the next version." + exit 1 + fi + echo "tag=${tag}" >> "${GITHUB_OUTPUT}" + + build: + needs: check + uses: rigetti/qcs-gha-infrastructure/.github/workflows/rust-cli-build.yml@v0.4.10 + with: + bin: ${{ inputs.bin }} + matrix: ${{ inputs.matrix }} + toolchain: ${{ inputs.toolchain }} + version: ${{ inputs.version }} + # The head of the dispatched branch, which is also what gets tagged. + ref: ${{ github.sha }} + + publish: + name: publish + needs: [check, build] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + path: ${{ inputs.artifact-path }} + merge-multiple: true + + - name: Create the draft, attach the binaries, publish the prerelease + env: + GH_TOKEN: ${{ secrets.token || github.token }} + REPO: ${{ github.repository }} + TAG: ${{ needs.check.outputs.tag }} + SHA: ${{ github.sha }} + ARTIFACT_PATH: ${{ inputs.artifact-path }} + run: | + set -euo pipefail + ls -l "${ARTIFACT_PATH}" + gh release create "${TAG}" --repo "${REPO}" \ + --draft --prerelease --target "${SHA}" \ + --title "${TAG}" --generate-notes + gh release upload "${TAG}" "${ARTIFACT_PATH}"/* --repo "${REPO}" + gh release edit "${TAG}" --repo "${REPO}" --draft=false + echo "Published prerelease ${TAG} at ${SHA}." diff --git a/.github/workflows/release-plz.yml b/.github/workflows/release-plz.yml new file mode 100644 index 0000000..53c26a5 --- /dev/null +++ b/.github/workflows/release-plz.yml @@ -0,0 +1,325 @@ +name: release-plz + +# Reusable workflow: release a Rust project with release-plz (https://release-plz.dev) +# using only the job's `GITHUB_TOKEN`. +# This replaces the knope-based `prepare-release.yml` and `rust-cli-release.yml`. +# +# Callers wire it to `push` on the default branch (and optionally +# `workflow_dispatch`). Every push runs the same pipeline: +# +# release `release-plz release`: tags and creates the GitHub release for +# any version in the tree that has no tag yet. That only happens +# when a human has merged a release PR. +# build Only with `bin`: builds the binaries from the new tag. +# publish Only with `bin`: uploads the binaries to the draft release, +# then publishes it. +# release-pr `release-plz release-pr`: opens or updates the release PR for +# whatever is now unreleased. +# +# Nothing here relies on a workflow being triggered by a tag, a release, or a +# push made with `GITHUB_TOKEN`: such events never start runs. The binaries are +# built by this same run instead. +# +# Binaries and the draft release +# ------------------------------ +# With `bin` set, the caller's `release-plz.toml` must set +# `git_release_draft = true`. release-plz then creates the release as a draft, +# which is invisible, and `publish` makes it public only after the assets are +# attached. A release therefore never exists without the binaries that make it +# installable. The workflow refuses to run `release` if the config does not +# say so. +# The tag does exist while the release is still a draft. +# +# If `build` or `publish` fails after `release` succeeded, re-run the failed +# jobs of the same run: a new run finds the tag and releases nothing. Otherwise +# upload the assets and run `gh release edit --draft=false` by hand. +# +# Caller requirements +# ------------------- +# - The calling job must grant at least `contents: write` and +# `pull-requests: write`. Permissions declared here can only narrow what the +# caller grants, never widen it. +# - Settings > Actions > General > "Allow GitHub Actions to create and approve +# pull requests" must be on, or `release-pr` cannot open the PR. +# - Pull requests opened with `GITHUB_TOKEN` get no automatic `pull_request` +# runs; GitHub holds them in `action_required` until someone approves the +# run. Approved runs satisfy required checks. +# - Merge the release PR with the default merge-commit strategy, or set +# `release_always = false`, as explained at +# https://release-plz.dev/docs/usage/release#what-commit-is-released +# +# Recommended `release-plz.toml` for a binary that is not on crates.io: +# +# [workspace] +# git_only = true # version state comes from tags, no cargo publish +# git_release_draft = true # required with `bin` +# git_release_type = "auto" +# +# An optional `token` secret (a PAT or app token) is used instead of +# `GITHUB_TOKEN` when supplied. That is the way to get CI running on the +# release PR automatically; nothing here requires it. +on: + workflow_call: + inputs: + bin: + description: >- + Name of the binary to build and attach to the release, as + `cargo build -p` expects it. Empty means the repository ships no + binaries: no build runs and release-plz publishes the release itself. + type: string + required: false + default: "" + package: + description: >- + Cargo package whose release gets the binaries. Defaults to `bin`. + Only one package per repository can ship assets this way, and other + released packages must not be drafts. + type: string + required: false + default: "" + matrix: + description: Platform matrix; see rust-cli-build.yml for the default. + type: string + required: false + default: "" + toolchain: + type: string + required: false + default: stable + artifact-path: + description: Directory the build artifacts are gathered into before upload. + type: string + required: false + default: release + config: + description: >- + Path to the release-plz config file. Empty means release-plz's own + search: `release-plz.toml`, `.release-plz.toml`, + `.config/release-plz.toml`. + type: string + required: false + default: "" + manifest-path: + description: Path to the Cargo.toml to release. Empty means the root. + type: string + required: false + default: "" + release-plz-version: + description: Version of the release-plz binary to install. + type: string + required: false + default: "0.4.3" + secrets: + token: + description: >- + Optional PAT or app token, used instead of `GITHUB_TOKEN`. The only + reason to pass one is to have the release PR and the tag trigger + other workflows. + required: false + cargo-registry-token: + description: Token for `cargo publish`, only if the config publishes to a registry. + required: false + +# One run at a time per branch, and never cancel: an interrupted release is the +# one case this must not allow. A newer push replaces a queued (not a running) +# run, which is safe because `release` acts on the version in the tree. +concurrency: + group: release-plz-${{ github.repository }}-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + release: + name: release + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: read + outputs: + releases: ${{ steps.release-plz.outputs.releases }} + tag: ${{ steps.pick.outputs.tag }} + steps: + # The release must come from the default branch, never from a feature + # branch that someone dispatched by hand. + - name: Refuse anything but the default branch + env: + REF: ${{ github.ref }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + EVENT: ${{ github.event_name }} + run: | + set -euo pipefail + if [[ "${EVENT}" != "push" && "${EVENT}" != "workflow_dispatch" ]]; then + echo "::error::release-plz.yml handles push and workflow_dispatch only; got ${EVENT}." + exit 1 + fi + if [[ "${REF}" != "refs/heads/${DEFAULT_BRANCH}" ]]; then + echo "::error::Releases come from ${DEFAULT_BRANCH}; this run is on ${REF}. For a release candidate use release-plz-prerelease.yml." + exit 1 + fi + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # release-plz compares against tags and walks history. + fetch-depth: 0 + # release-plz pushes through the API, so the credentials need not stay. + persist-credentials: false + + # Binaries are attached after the release exists, so the release must be + # created as a draft. Checked before anything is released: afterwards it + # would be too late to hide a release that is already public. + - name: Require draft releases when binaries are attached + if: inputs.bin != '' + env: + CONFIG: ${{ inputs.config }} + PACKAGE: ${{ inputs.package || inputs.bin }} + run: | + python3 - <<'PY' + import os, pathlib, sys, tomllib + + candidates = [os.environ["CONFIG"]] if os.environ["CONFIG"] else [ + "release-plz.toml", ".release-plz.toml", ".config/release-plz.toml"] + path = next((p for p in candidates if pathlib.Path(p).is_file()), None) + if path is None: + print("::error::No release-plz config found. With `bin` set it must set git_release_draft = true.") + sys.exit(1) + with open(path, "rb") as f: + config = tomllib.load(f) + + package = os.environ["PACKAGE"] + draft = config.get("workspace", {}).get("git_release_draft", False) + for entry in config.get("package", []): + if entry.get("name") == package and "git_release_draft" in entry: + draft = entry["git_release_draft"] + if draft is not True: + print(f"::error::{path}: git_release_draft must be true for package '{package}', " + "so the release stays hidden until its binaries are attached.") + sys.exit(1) + print(f"{path}: git_release_draft is true for '{package}'") + PY + + - uses: rigetti/qcs-gha-infrastructure/actions/setup-rust@v0.4.10 + with: + toolchain: ${{ inputs.toolchain }} + cache: "false" + + - name: Run release-plz release + id: release-plz + uses: release-plz/action@66b324fd6c72d05d2a08568492ecdd62bfb8a62b # v0.5.148 + with: + command: release + version: ${{ inputs.release-plz-version }} + config: ${{ inputs.config }} + manifest_path: ${{ inputs.manifest-path }} + env: + GITHUB_TOKEN: ${{ secrets.token || github.token }} + CARGO_REGISTRY_TOKEN: ${{ secrets.cargo-registry-token }} + + # The tag of the package that ships binaries, or empty if it was not + # released by this run. + - name: Pick the tag to attach binaries to + id: pick + if: inputs.bin != '' + env: + RELEASES: ${{ steps.release-plz.outputs.releases }} + PACKAGE: ${{ inputs.package || inputs.bin }} + run: | + set -euo pipefail + tag="$(jq -r --arg p "${PACKAGE}" '[.[] | select(.package_name == $p)][0].tag // ""' <<<"${RELEASES:-[]}")" + echo "tag=${tag}" >> "${GITHUB_OUTPUT}" + if [[ -n "${tag}" ]]; then + echo "Released ${tag}; binaries follow." + else + echo "Nothing to release for ${PACKAGE}." + fi + + build: + needs: release + if: needs.release.outputs.tag != '' + # The tag, not the pushed commit: the binaries are built from exactly what + # was tagged. + uses: rigetti/qcs-gha-infrastructure/.github/workflows/rust-cli-build.yml@v0.4.10 + with: + bin: ${{ inputs.bin }} + matrix: ${{ inputs.matrix }} + toolchain: ${{ inputs.toolchain }} + ref: refs/tags/${{ needs.release.outputs.tag }} + + publish: + name: publish + needs: [release, build] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + path: ${{ inputs.artifact-path }} + merge-multiple: true + + # Upload, check, then publish: the draft turns public only once every + # expected binary is attached. + - name: Attach the binaries and publish the release + env: + GH_TOKEN: ${{ secrets.token || github.token }} + REPO: ${{ github.repository }} + TAG: ${{ needs.release.outputs.tag }} + ARTIFACT_PATH: ${{ inputs.artifact-path }} + BIN: ${{ inputs.bin }} + run: | + set -euo pipefail + ls -l "${ARTIFACT_PATH}" + if ! compgen -G "${ARTIFACT_PATH}/${BIN}-*" >/dev/null; then + echo "::error::No ${BIN}-* binaries in ${ARTIFACT_PATH}; leaving ${TAG} as a draft." + exit 1 + fi + + is_draft="$(gh release view "${TAG}" --repo "${REPO}" --json isDraft -q .isDraft)" + if [[ "${is_draft}" != "true" ]]; then + echo "::error::Release ${TAG} is already public; git_release_draft was not honored." + exit 1 + fi + + gh release upload "${TAG}" "${ARTIFACT_PATH}"/* --repo "${REPO}" --clobber + + count="$(gh release view "${TAG}" --repo "${REPO}" --json assets -q '[.assets[].name | select(startswith(env.BIN + "-"))] | length')" + expected="$(find "${ARTIFACT_PATH}" -type f | wc -l | tr -d ' ')" + if [[ "${count}" -lt "${expected}" ]]; then + echo "::error::${TAG} has ${count} binaries attached, expected ${expected}; leaving it as a draft." + exit 1 + fi + + gh release edit "${TAG}" --repo "${REPO}" --draft=false + echo "Published ${TAG} with ${count} binaries." + + release-pr: + name: release-pr + # After `release`, so the tag of a just-merged release PR exists and no + # second PR is opened for that version. Not after `build`: opening the next + # PR does not need the binaries. + needs: release + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - uses: rigetti/qcs-gha-infrastructure/actions/setup-rust@v0.4.10 + with: + toolchain: ${{ inputs.toolchain }} + cache: "false" + + - name: Run release-plz release-pr + uses: release-plz/action@66b324fd6c72d05d2a08568492ecdd62bfb8a62b # v0.5.148 + with: + command: release-pr + version: ${{ inputs.release-plz-version }} + config: ${{ inputs.config }} + manifest_path: ${{ inputs.manifest-path }} + env: + GITHUB_TOKEN: ${{ secrets.token || github.token }} diff --git a/.github/workflows/rust-cli-build.yml b/.github/workflows/rust-cli-build.yml index 5c16039..2c62d58 100644 --- a/.github/workflows/rust-cli-build.yml +++ b/.github/workflows/rust-cli-build.yml @@ -33,6 +33,15 @@ on: type: string required: false default: "" + version: + description: >- + Version to build as, e.g. `1.2.3-rc.0`. Empty builds the version in + Cargo.toml. Set by `release-plz-prerelease.yml`, which builds a + version nothing has committed; the edit stays in the runner's + checkout. + type: string + required: false + default: "" permissions: contents: read @@ -47,7 +56,7 @@ jobs: ${{ fromJSON(inputs.matrix != '' && inputs.matrix || '[ {"runs-on": "ubuntu-latest", "target": "x86_64-unknown-linux-musl"}, {"runs-on": "ubuntu-24.04-arm", "target": "aarch64-unknown-linux-musl"}, - {"runs-on": "macos-14", "target": "aarch64-apple-darwin"}, + {"runs-on": "macos-latest", "target": "aarch64-apple-darwin"}, {"runs-on": "windows-latest", "target": "x86_64-pc-windows-msvc"} ]') }} runs-on: ${{ matrix.runs-on }} @@ -69,6 +78,25 @@ jobs: # cold miss, and CARGO_TARGET_DIR is outside the cached path anyway. cache: "false" + - name: Set the version + if: inputs.version != '' + uses: taiki-e/install-action@68a389c788b2cd04016ce8c80ee23b80d2ab3d68 # v2 + with: + tool: release-plz + + # `set-version` also edits the changelog and fails when there is none, so + # it gets a config that turns that off. The edit is never committed. + - name: Apply the version + if: inputs.version != '' + shell: bash + env: + VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + printf '[workspace]\nchangelog_update = false\n' > "${RUNNER_TEMP}/set-version.toml" + release-plz set-version --config "${RUNNER_TEMP}/set-version.toml" "${VERSION}" + git diff --stat + - name: Install musl toolchain if: endsWith(matrix.target, '-linux-musl') shell: bash diff --git a/.github/workflows/rust-cli-prerelease.yml b/.github/workflows/rust-cli-prerelease.yml index bc8bf84..d14affe 100644 --- a/.github/workflows/rust-cli-prerelease.yml +++ b/.github/workflows/rust-cli-prerelease.yml @@ -1,5 +1,11 @@ name: Rust CLI prerelease +# DEPRECATED: use release-plz-prerelease.yml instead. +# This knope-based workflow still works for callers pinned to a tag, and now +# also with `GITHUB_TOKEN` alone, but it will be removed in a future breaking +# release. +# See the README's "Migrating from knope to release-plz". + # Reusable workflow: cut a release candidate from the branch it is dispatched # on — bump, build, publish — for a repository that ships binaries. # @@ -57,7 +63,8 @@ on: token: description: >- Token used to push the prerelease commit and publish the release. - required: true + Optional: the stages fall back to the job's own token. + required: false jobs: draft: diff --git a/.github/workflows/rust-cli-release.yml b/.github/workflows/rust-cli-release.yml index 43cc28a..0c9a632 100644 --- a/.github/workflows/rust-cli-release.yml +++ b/.github/workflows/rust-cli-release.yml @@ -1,5 +1,11 @@ name: Rust CLI release +# DEPRECATED: use release-plz.yml (set `bin`) instead. +# This knope-based workflow needs a PAT or app token to work at all: pushes made +# with `GITHUB_TOKEN` start no workflow runs. It still works for callers pinned +# to a tag and will be removed in a future breaking release. +# See the README's "Migrating from knope to release-plz". + # Reusable workflow: the second half of a split release for a repository that # ships binaries. Builds the CLI for every platform, then runs knope's Release # step over those artifacts. diff --git a/README.md b/README.md index 10bae4f..e687b2a 100644 --- a/README.md +++ b/README.md @@ -85,15 +85,129 @@ jobs: matrix: | [ {"runs-on": "ubuntu-latest", "target": "x86_64-unknown-linux-musl"}, - {"runs-on": "macos-14", "target": "aarch64-apple-darwin"} + {"runs-on": "macos-latest", "target": "aarch64-apple-darwin"} ] ``` +`version` (optional) builds the binary as that version, e.g. `1.3.0-rc.0`, +by running `release-plz set-version` in the runner's checkout. +Nothing is committed. +`release-plz-prerelease.yml` uses this. + `musl-tools` is installed and `CC_` set automatically for musl targets. `CARGO_TARGET_DIR` is moved under `RUNNER_TEMP` so deep dependency trees do not overrun Windows' path limit. -### `rust-cli-release.yml` and `rust-cli-prerelease.yml` +### `release-plz.yml` + +Releases with [release-plz](https://release-plz.dev) using only `GITHUB_TOKEN`: +no PAT and no GitHub App. +Replaces `prepare-release.yml`, `rust-cli-release.yml`, and `publish-release.yml`. +Wire it to pushes on the default branch. + +```yaml +# .github/workflows/release-plz.yml +name: release-plz +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + release-plz: + permissions: + contents: write + pull-requests: write + uses: rigetti/qcs-gha-infrastructure/.github/workflows/release-plz.yml@main + with: + bin: my-cli # omit if the repository ships no binaries + # secrets: + # token: ${{ secrets.RELEASE_TOKEN }} # optional; see below +``` + +```toml +# release-plz.toml +[workspace] +git_only = true # versions come from tags; no `cargo publish` +git_release_draft = true # required when `bin` is set +git_release_type = "auto" # prerelease versions become GitHub prereleases +``` + +Every push runs, in order: + +| Job | What it does | +|---|---| +| `release` | `release-plz release`: tags and creates the GitHub release if the version in the tree has no tag yet. That only happens after someone merges a release PR. | +| `build` | With `bin`: builds the binaries from the new tag, on every platform. | +| `publish` | With `bin`: uploads the binaries to the draft release, checks they are all there, then publishes it. | +| `release-pr` | `release-plz release-pr`: opens or updates the release PR for what is now unreleased. | + +A human merges the release PR; that push releases. +Nothing relies on a workflow triggered by a tag, a release, or a `GITHUB_TOKEN` +push, because none of those start runs. + +With `bin` set, the release is a draft until its binaries are attached, +so it is never visible without them. +The workflow refuses to release unless `git_release_draft = true` is configured. +The tag exists while the release is still a draft. +If `build` or `publish` fails, re-run the failed jobs of that run: +a fresh run sees the tag and releases nothing. + +**Requirements.** + +- The calling job grants at least `contents: write` and `pull-requests: write`. + A called workflow's `permissions` can only narrow the caller's. +- Settings > Actions > General > "Allow GitHub Actions to create and approve + pull requests" is on. +- Pull requests opened with `GITHUB_TOKEN` get no automatic `pull_request` runs. + GitHub holds them as `action_required` until someone approves the run, + and an approved run satisfies required checks. + A dispatched CI run does not attach to the pull request. + Passing the optional `token` secret (a PAT or app token) avoids this, + but nothing here needs it. +- Merge release PRs with the default merge-commit strategy, + or set `release_always = false`. + See [what commit is released](https://release-plz.dev/docs/usage/release#what-commit-is-released). +- Only one package per repository can ship binaries (`package`, default `bin`). + +### `release-plz-prerelease.yml` + +Publishes a release candidate from the branch it is dispatched on. +Replaces `rust-cli-prerelease.yml`. +release-plz has no prerelease flow, so this does not use it to release. +It builds the branch's binaries as the requested version +(`release-plz set-version`, applied in the runner only), +then creates a draft release, attaches the binaries, and publishes it as a prerelease. + +```yaml +# .github/workflows/prerelease.yml +name: Prerelease +on: + workflow_dispatch: + inputs: + version: + description: "Prerelease version, e.g. 1.3.0-rc.0" + required: true + +jobs: + prerelease: + permissions: + contents: write + uses: rigetti/qcs-gha-infrastructure/.github/workflows/release-plz-prerelease.yml@main + with: + bin: my-cli + version: ${{ inputs.version }} +``` + +Differences from the knope flow: +nothing is committed or pushed, +so the tag `v` points at the branch head with its old `Cargo.toml` version +(the binaries report the new one); +the version is explicit, and nothing computes the next `rc.N`; +no changelog is written. +The default branch is refused. + +### `rust-cli-release.yml` and `rust-cli-prerelease.yml` (deprecated) The whole **release-with-assets** pattern, for a repository that ships binaries. Most callers want these two rather than the pieces they compose @@ -220,7 +334,7 @@ scope once approved. The reviewer is the whole control. Read the diff — build scripts, proc macros, `Cargo.toml` patches, and test fixtures included — before approving, and pin the environment's reviewers to maintainers only. -### `prepare-release.yml` +### `prepare-release.yml` (deprecated) The automatic half of a knope release. Nothing to configure, and no way to run it by hand: @@ -256,7 +370,7 @@ jobs: downstream — a binary build, say. Pushes made with `GITHUB_TOKEN` do not start new workflow runs. -### `publish-release.yml` +### `publish-release.yml` (deprecated) The second half of a **split release**, for repositories that attach build artifacts. The caller's `draft-release` knope workflow prepares and pushes the @@ -311,7 +425,7 @@ their `knope-workflow` input: knope-workflow: draft-release ``` -### `prepare-prerelease.yml` +### `prepare-prerelease.yml` (deprecated) The manual half: cuts a release candidate from the branch it is dispatched on, so it can be installed and tested before merging. Wire it to @@ -343,7 +457,7 @@ Run workflow dropdown with no way to restrict the list, which is why this is a workflow-level check rather than configuration. `default-branch` (default `main`) names the branch to refuse. -### `actions/reset-changelogs` +### `actions/reset-changelogs` (deprecated) Restores every changelog named in `knope.toml` to its state on the default branch. Used by `prepare-release.yml` and `prepare-prerelease.yml` before knope @@ -366,6 +480,29 @@ its own. A changelog that does not exist on the default branch is deleted rather than restored: it is new in this branch, so its correct prior state is absent. +## Migrating from knope to release-plz + +The knope workflows (`prepare-release.yml`, `prepare-prerelease.yml`, +`publish-release.yml`, `rust-cli-release.yml`, `rust-cli-prerelease.yml`, +and `actions/reset-changelogs`) are **deprecated**. +They are unchanged for callers pinned to a tag, so nothing breaks on upgrade. +Their removal will be a separate breaking release. +(`prepare-prerelease.yml` and `rust-cli-prerelease.yml` now accept a missing +`token` and fall back to `GITHUB_TOKEN`; that change is not breaking.) + +To migrate a caller: + +1. Add `release-plz.toml` (see `release-plz.yml` above) and delete `knope.toml`. + Changelog entries come from Conventional Commits, as before. +2. Replace the knope caller workflows with the `release-plz.yml` caller, + and the prerelease caller with `release-plz-prerelease.yml`. +3. Turn on "Allow GitHub Actions to create and approve pull requests". +4. Release flow changes: pushes to the default branch no longer release. + They open or update a release PR; merging it releases. +5. Make the release PR's checks pass: approve its `action_required` run, + or pass a PAT or app token as `token`. +6. Delete `RELEASE_TOKEN` once nothing else uses it. + ## Releasing knope, driven by this repository's own `prepare-release.yml` — the same workflow diff --git a/agent-choices.md b/agent-choices.md new file mode 100644 index 0000000..83d7a75 --- /dev/null +++ b/agent-choices.md @@ -0,0 +1,97 @@ +# Agent choices + +Assumptions and decisions made while adding release-plz support. +Decided by the user, not relitigated: replace knope with release-plz, and use `GITHUB_TOKEN` only (no PAT, no GitHub App). + +## Versioning and compatibility + +- Consumers pin tags such as `@v0.4.10`, and removing or changing inputs breaks them. + So the knope workflows are **kept and marked deprecated** (header comment, README headings, migration section). + Their removal is a follow-up breaking release. +- New workflows are additive: `release-plz.yml`, `release-plz-prerelease.yml`. + `rust-cli-build.yml` gains an optional `version` input (default empty), which is not breaking. +- The macOS runner fix (`macos-14` to `macos-latest`) changes the default build matrix of an existing workflow. + It is a `fix:` because `macos-14` cannot run at all; callers who pass their own `matrix` are unaffected. + I also changed this repository's own `setup-rust` CI job, which used the same label. +- This repository's own release (`release.yml` calling `prepare-release.yml`, driven by `knope.toml`) is **unchanged**. + Moving it needs a decision about how internal `@vX.Y.Z` pins get rewritten (release-plz has no `versioned_files`). + The new workflows are registered in `knope.toml` so their internal pins keep being rewritten, and the CI lint check stays satisfied. + +## Design of `release-plz.yml` + +- One pipeline per push: `release` (tag and draft release), `build`, `publish`, then `release-pr`. + No tag or release triggered workflow is used, because `GITHUB_TOKEN` events start no runs. +- Draft invariant preserved: a release is public only after its binaries are attached. + release-plz creates the draft (`git_release_draft = true` in the caller's `release-plz.toml`), `gh release upload` attaches, `gh release edit --draft=false` publishes. + The workflow checks the config before releasing and fails otherwise, and checks the release is still a draft and the asset count before publishing. + Option names (`git_release_draft`, `git_release_type`, `git_only`, `release_always`) are from the current docs; the config parser rejects unknown keys, and I confirmed locally that these parse. +- `build` checks out `refs/tags/`, so binaries are built from exactly the tagged commit. +- `release-pr` needs `release` but not `build`, so the next release PR is not delayed by the build. + Running it after `release` avoids opening a second PR for a version whose tag does not exist yet. +- `concurrency` with `cancel-in-progress: false`, per branch. + A queued run can be replaced by a newer one; that is safe because `release` acts on the version in the tree. +- Default branch only: the workflow refuses other refs, since GitHub offers every branch for `workflow_dispatch`. +- Token fallback (`secrets.token || github.token`) is applied inside the called workflow. + Job-level `permissions` declare the minimum; callers must grant at least `contents: write` and `pull-requests: write`. +- `git_only = true` is recommended for binaries not published on crates.io. + Version state then comes from tags, which matches existing knope tags (`v0.1.x`). +- Only one package per repository ships binaries (`package`, default `bin`). + Releases of other packages in a workspace are not undrafted by this workflow, so they must not be drafts. +- `release-plz` action pinned to the commit of `v0.5.148` (peeled from the annotated tag). + Its default binary version is `0.4.3`, mirrored by the `release-plz-version` input. + The action itself pins `cargo-semver-checks` and `cargo-binstall`. +- Recovery after a partial failure: re-run failed jobs of the same run. + A new run finds the tag and releases nothing, leaving a draft. + The sibling `todo-curator` PR adds a `workflow_dispatch` `tag` input to resume; not done here. + Open question whether to align. + +## Design of `release-plz-prerelease.yml` + +- release-plz has no prerelease mode. + Its release PR computes the next stable version, and `release` releases the version in the tree. +- Minimal, `GITHUB_TOKEN`-only flow: validate an explicit SemVer prerelease `version`, build with `release-plz set-version` applied in the runner only, create a draft prerelease, attach the binaries, publish. +- `set-version` fails without a `CHANGELOG.md`, so `rust-cli-build.yml` passes a temporary config with `changelog_update = false`. + Verified locally in a scratch crate: exit 0, `Cargo.toml` updated, no changelog created. +- Nothing is pushed. + The tag is created at the dispatched branch's head commit, which still has the old version in `Cargo.toml`. + This cannot leak a prerelease version into the default branch, and needs no push permission on the branch. +- The version is explicit. + Computing the next `rc.N` (as the sibling PR does with `release-plz update`) is a possible follow-up. +- Open: `git_only` version detection may see `v1.3.0-rc.0` tags. + A tag on a branch that is not the default branch should not affect `release-plz` on the default branch, but I did not test this. + +## Deprecated knope workflows + +- `prepare-prerelease.yml` had `token` as `required: true`, no `|| github.token` fallback, and a top-level `contents: read` that capped the token. + Fixed (token optional with fallback, `contents: write`), because it is not breaking and makes the deprecated flow usable without a PAT. + `rust-cli-prerelease.yml` makes `token` optional to match. + Untested: this needs a real run on a branch that accepts pushes from `github-actions`. +- `prepare-release.yml` still requires a real token on `push`, because knope pushes a commit to the default branch and `GITHUB_TOKEN` cannot trigger the build. + Left as is: it is deprecated. + +## Validation + +- `actionlint` is not installed and downloading it was blocked, so it did not run. +- All workflow files parse with `yq`. +- Every `run:` block of the new and changed workflows passes `shellcheck` with `${{ }}` replaced by a placeholder. +- The draft-config check script was run against fixtures: draft true, missing, overridden by a package entry, set only on the package, and no config file. +- `release-plz set-version` and `update`, and config parsing (including rejection of unknown keys), were run locally with release-plz 0.4.3. +- Not run: any workflow in GitHub Actions. + No release, draft release, tag, or prerelease was created, so `release-plz release` with a draft, the tag existing before `build`, `gh release upload/edit`, and `gh release create --prerelease` are untested. + The repository has no fixture repository that consumes these workflows. + +## Manual steps for humans + +- In each calling repository, turn on "Allow GitHub Actions to create and approve pull requests". +- Approve the held `pull_request` runs of release PRs (`action_required`), or supply the optional `token` secret. +- Check how the first run behaves on a throwaway repository before moving a real one. +- After this merges and releases, pin callers to the new tag. + +## Open design questions + +- Align the prerelease design with `todo-curator` (computed `rc.N` versus explicit version)? +- Add the `tag` dispatch input for resuming a failed publish? +- Use `--latest` on `gh release edit` for stable releases (the sibling PR does)? + GitHub's default `make_latest` for an update is expected to do the right thing, but this was not verified. +- Move this repository's own release to release-plz, and how to handle its internal pins? +- Schedule the removal of the knope workflows. diff --git a/knope.toml b/knope.toml index 55fbd9b..bf61efa 100644 --- a/knope.toml +++ b/knope.toml @@ -13,6 +13,8 @@ versioned_files = [ { path = ".github/workflows/prepare-prerelease.yml", regex = 'qcs-gha-infrastructure/[^@]+@v(?\d+\.\d+\.\d+.*)' }, { path = ".github/workflows/prepare-release.yml", regex = 'qcs-gha-infrastructure/[^@]+@v(?\d+\.\d+\.\d+.*)' }, { path = ".github/workflows/rust-ci.yml", regex = 'qcs-gha-infrastructure/[^@]+@v(?\d+\.\d+\.\d+.*)' }, + { path = ".github/workflows/release-plz.yml", regex = 'qcs-gha-infrastructure/[^@]+@v(?\d+\.\d+\.\d+.*)' }, + { path = ".github/workflows/release-plz-prerelease.yml", regex = 'qcs-gha-infrastructure/[^@]+@v(?\d+\.\d+\.\d+.*)' }, { path = ".github/workflows/rust-cli-build.yml", regex = 'qcs-gha-infrastructure/[^@]+@v(?\d+\.\d+\.\d+.*)' }, { path = ".github/workflows/rust-cli-prerelease.yml", regex = 'qcs-gha-infrastructure/[^@]+@v(?\d+\.\d+\.\d+.*)' }, { path = ".github/workflows/rust-cli-release.yml", regex = 'qcs-gha-infrastructure/[^@]+@v(?\d+\.\d+\.\d+.*)' },