release-kit 0.9.13

A canonical release workflow: a technology-agnostic method, per-technology bindings, and the rk CLI that lands and serves them.
Documentation
# The whole release pipeline: there is no registry, so publishing is tagging
# and the tarball attached to the release page is the distribution.
#
# Both jobs run on every push to the trunk, and to a RK_LINE_PREFIX* line when a
# project keeps older lines. The release-request job drives git-cliff to
# maintain the one release pull request — VERSION plus the changelog — against
# the pushed branch, and merging that request is the release: the bump push is
# what makes tag-and-attach build the tarball with the project's build command,
# attest it, and create the tag and the release together, last. On a RK_LINE_PREFIX* line the same pair backports a patch and tags
# it there.
name: release

permissions: {}

on:
  push:
    branches: [RK_TRUNK_BRANCH, 'RK_LINE_PREFIX**']
# RK_GATE_TRIGGER_BEGIN
# RK_GATE_TRIGGER_END

jobs:
  # The release request. git-cliff maintains no pull request on its own, so
  # this job computes the bump, rewrites VERSION and the changelog on a
  # request branch, and opens or refreshes the pull request. Force-pushing the
  # branch is the refresh: the request always reflects the pushed branch's tip.
  release-request:
    if: github.repository_owner == 'OWNER' # RK_PUSH_GUARD
    runs-on: ubuntu-latest
    permissions:
      contents: read
    concurrency:
      group: release-request-${{ github.ref }}
      cancel-in-progress: false
    steps:
      # The mint is scoped, not blanket: without a permission-* input the token
      # carries every permission the installation holds. This half pushes the
      # bump branch and opens, refreshes, and arms the request, so it takes
      # contents and pull-requests.
      - uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3
        id: app-token
        with:
          # client-id takes the client ID or the numeric App ID; GitHub accepts either as the JWT issuer: https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-json-web-token-jwt-for-a-github-app
          client-id: ${{ secrets.RELEASE_BOT_APP_ID }}
          private-key: ${{ secrets.RELEASE_BOT_APP_PRIVATE_KEY }}
          permission-contents: write
          permission-pull-requests: write
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
        with:
          fetch-depth: 0
          # zizmor: ignore[artipacked] the persisted credential is what pushes
          # the request branch below; persist-credentials: false breaks it.
          token: ${{ steps.app-token.outputs.token }}
      - name: install git-cliff
        run: pipx install git-cliff==2.13.1
      - name: maintain the release request
        env:
          GH_TOKEN: ${{ steps.app-token.outputs.token }}
          APP_SLUG: ${{ steps.app-token.outputs.app-slug }}
          REF: ${{ github.ref_name }}
          RELEASE_STYLE: RK_STYLE
        run: |
          set -eu
          current="$(cat VERSION)"
          next="$(git cliff --bumped-version | sed 's/^v//')"
          if [ "$next" = "$current" ]; then
            echo "no release-worthy commits since v$current; no request to maintain."
            exit 0
          fi
          if [ -n "$(git ls-remote --tags origin "refs/tags/v$next")" ]; then
            echo "v$next is already tagged; no request to maintain."
            exit 0
          fi
          branch="chore/release-v$next"
          git switch -c "$branch"
          printf '%s\n' "$next" > VERSION
          git cliff --bump -o CHANGELOG.md
          uid="$(gh api "/users/${APP_SLUG}[bot]" -q .id)"
          git config user.name "${APP_SLUG}[bot]"
          git config user.email "${uid}+${APP_SLUG}[bot]@users.noreply.github.com"
          git add VERSION CHANGELOG.md
          git commit -m "chore(release): v$next"
          git push -f origin "$branch"
          open="$(gh pr list --base "$REF" --head "$branch" --state open --json number -q 'length')"
          if [ "$open" -eq 0 ]; then
            gh pr create --base "$REF" --head "$branch" \
              --title "chore(release): v$next" \
              --body "Bumps VERSION to $next and rewrites the changelog. Merging this request is the release: the bump push tags v$next and attaches the tarball."
          fi
          # RK_GATE_ARM_BEGIN
          # The trunk style's standing arm, re-applied on every refresh; the
          # app token is what keeps the eventual merge able to start this
          # workflow's release half, and the lines style leaves the merge a
          # human's.
          if [ "$RELEASE_STYLE" != "trunk" ]; then
            echo "the lines style merges each release by hand; not arming."
            exit 0
          fi
          if [ "$REF" != "RK_TRUNK_BRANCH" ]; then
            echo "a line's request is never armed; its candidate is what a human validated."
            exit 0
          fi
          gh pr merge "$branch" --auto RK_MERGE_FLAG --delete-branch
          echo "armed the release request for v$next."
          # RK_GATE_ARM_END

  # The release itself. Only the push that lands the bot's own bump releases —
  # the VERSION comparison against the parent commit is the guard — so an
  # ordinary work merge tags nothing. Build the tarball as a pure function of
  # the tree, attest it, and only then create the tag and the release together
  # with its checksum: nothing is publicly reachable before its attestation
  # exists, and a failed build or attestation leaves no tag behind.
  tag-and-attach:
    if: github.repository_owner == 'OWNER' # RK_PUSH_GUARD
    runs-on: ubuntu-latest
    permissions:
      contents: read
      # Minting the build-provenance attestation: the OIDC token identifies
      # this run to Sigstore, and the other two write the attestation back.
      id-token: write
      attestations: write
      artifact-metadata: write
    steps:
      # This half creates the release, whose publication creates the tag, and
      # opens no pull request, so contents alone carries it. A target whose tag ruleset
      # blocks creation needs administration here too; the convention's ruleset
      # restricts deletion and update only.
      - uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3
        id: app-token
        with:
          client-id: ${{ secrets.RELEASE_BOT_APP_ID }}
          private-key: ${{ secrets.RELEASE_BOT_APP_PRIVATE_KEY }}
          permission-contents: write
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
        with:
          fetch-depth: 0
          persist-credentials: false
      - name: install git-cliff
        run: pipx install git-cliff==2.13.1
      - name: install the build packages
        run: sudo apt-get update -qq && sudo apt-get install -y --no-install-recommends RK_BUILD_PACKAGES
      - name: build
        id: build
        env:
          GH_TOKEN: ${{ steps.app-token.outputs.token }}
          GH_REPO: ${{ github.repository }}
          SHA: ${{ github.sha }}
        run: |
          set -eu
          version="$(cat VERSION)"
          previous="$(git show "$SHA^:VERSION" 2>/dev/null || echo "")"
          if [ "$previous" = "$version" ]; then
            echo "this commit does not bump the version; nothing to release."
            exit 0
          fi
          # The commit that introduces VERSION is the landing, not a release.
          if [ -z "$previous" ]; then
            echo "this commit introduces VERSION; nothing to release."
            exit 0
          fi
          # The baseline is the unreleased state and never a release.
          if [ "$version" = "0.0.0" ]; then
            echo "0.0.0 is the unreleased baseline; nothing to release."
            exit 0
          fi
          # A version the forge already published at another commit is an
          # un-bump back to it, not a release. The forge decides, not a
          # version order: a draft, or a release at this very commit,
          # continues so attach can finish it.
          if released="$(gh release view "v$version" --json isDraft -q .isDraft 2>/dev/null)" && [ "$released" = "false" ]; then
            at="$(gh api "repos/$GH_REPO/commits/v$version" -q .sha 2>/dev/null || true)"
            if [ -n "$at" ] && [ "$at" != "$SHA" ]; then
              echo "v$version is already released at $at; nothing to release."
              exit 0
            fi
          fi
          RK_BUILD_COMMAND
          # The version is named, because no tag exists yet for git-cliff to read.
          git cliff --unreleased --tag "v$version" -o notes.md
          echo "version=$version" >> "$GITHUB_OUTPUT"
          echo "built=true" >> "$GITHUB_OUTPUT"

      # Provenance, minted before anything is publishable. The run that built
      # the tarball signs it, so a consumer can check which workflow and commit
      # produced it instead of trusting the release page the checksum also sits
      # on. Attesting by hand is not possible: only the building run holds the
      # identity that signs. The order is the rule, not a detail: the
      # attestation exists before the release page does, so no public release
      # ever points at an unattested tarball, not even between the steps of a
      # failed run. A rerun rebuilds the identical tarball from the same
      # commit and re-attests it before touching the page.
      - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4
        if: steps.build.outputs.built == 'true'
        with:
          subject-path: dist/*.tar.gz

      # The tag is created here and nowhere else: gh release create with
      # assets makes a draft, uploads each asset, and publishes it, and the
      # publication creates the tag at --target. A draft left by a failed
      # upload is finished rather than duplicated. A tag at another commit
      # stops the job: the method never moves a protected tag.
      - name: attach
        if: steps.build.outputs.built == 'true'
        env:
          GH_TOKEN: ${{ steps.app-token.outputs.token }}
          GH_REPO: ${{ github.repository }}
          SHA: ${{ github.sha }}
          VERSION: ${{ steps.build.outputs.version }}
        run: |
          set -eu
          # Three states, never two: a tag that exists, a tag the forge
          # answers 404 for, and a read that failed, which refuses, because
          # a tag nobody could read may name another commit.
          err="$(mktemp)"
          tagged=""
          if gh api "repos/$GH_REPO/git/ref/tags/v$VERSION" >/dev/null 2>"$err"; then
            if ! tagged="$(gh api "repos/$GH_REPO/commits/v$VERSION" -q .sha)" || [ -z "$tagged" ]; then
              echo "the commit v$VERSION tags could not be read; refusing to publish." >&2
              exit 1
            fi
          elif ! grep -q "HTTP 404" "$err"; then
            echo "the tag v$VERSION could not be read: $(cat "$err"); refusing to publish." >&2
            exit 1
          fi
          if [ -n "$tagged" ] && [ "$tagged" != "$SHA" ]; then
            echo "v$VERSION already tags $tagged, not this run's $SHA; recover through the section The tag exists and the release does not (rk method recovery)." >&2
            exit 1
          fi
          if draft="$(gh release view "v$VERSION" --json isDraft -q .isDraft 2>/dev/null)"; then
            if [ "$draft" = "true" ]; then
              gh release upload "v$VERSION" dist/*.tar.gz dist/*.sha256 --clobber
              # A draft keeps whatever target it was made with, and its
              # publication creates the tag there: point it at this run's
              # commit first, which the guard above proved the tag names
              # where one exists.
              gh release edit "v$VERSION" --target "$SHA" --draft=false
              echo "finished the draft release v$VERSION."
            else
              echo "the release page already exists; leaving it as it is."
            fi
            exit 0
          fi
          gh release create "v$VERSION" dist/*.tar.gz dist/*.sha256 \
            --target "$SHA" --title "v$VERSION" --notes-file notes.md
          echo "tagged and released v$VERSION at $SHA."
# RK_GATE_JOB_BEGIN
# RK_GATE_JOB_END