release-kit 0.3.13

A canonical release workflow: a technology-agnostic method, per-technology bindings, and the rk CLI that lands and serves them.
Documentation
# The release automation and the crates.io trusted publisher for GitLab CI.
#
# Both jobs run on every push to the trunk, and to a RK_LINE_PREFIX* line when a
# project keeps older lines. release-plz maintains the one release merge
# request against the pushed branch, and merging it is the release: the bump
# push is what makes the release job tag the commit, create the GitLab
# release, and publish to crates.io over OIDC. Only the bot's own bump
# releases, which release_always = false recognizes by its release-plz-*
# source branch.
#
# Every job authenticates with RELEASE_BOT_TOKEN, the project access token
# the setup stores as a masked CI variable: a push made with the default CI
# job token starts no pipeline, which would silently skip the release half,
# and a merge armed under it would land a bump that releases nothing.
# Trusted publishing covers GitLab.com only; a self-hosted instance cannot
# take the OIDC path and falls back to a token, which the method's
# invariants exist to remove.

include:
  - local: .gitlab/ci/mr-title.yml

stages:
  - release
  - test

# Merge request pipelines run the title gate; branch pipelines on the trunk
# and the release lines run the release jobs, whose own rules never match a
# merge request event.
workflow:
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
    - if: '$CI_PROJECT_ROOT_NAMESPACE == "OWNER" && ($CI_COMMIT_BRANCH == "RK_TRUNK_BRANCH" || $CI_COMMIT_BRANCH =~ /^RK_LINE_PREFIX_RE/)'

# The project's own jobs, in a pipeline of their own. This forge
# reverse-deep-merges an include, so a file the project owns would
# contribute every key the jobs above leave out, whatever the include order;
# a child pipeline is the only shape that isolates. Declare jobs in
# .gitlab/ci/project.yml and they run here, with their own stages and their
# own nested includes, reaching nothing this file declares. strategy: mirror
# carries the child's status into this pipeline, so a failing project job
# blocks the merge; it needs GitLab 18.2 or newer, which is this
# convention's minimum version. The exists: guard keeps this job out of the
# pipeline altogether while the file is absent, so no empty downstream
# pipeline appears. A job in that file needs no merge_request_event rule:
# this rule already scopes it, and inside a child pipeline
# CI_PIPELINE_SOURCE reads parent_pipeline; use CI_MERGE_REQUEST_ID where a
# rule is wanted. The forge document carries the rest.
project-jobs:
  stage: test
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
      exists:
        - .gitlab/ci/project.yml
  trigger:
    include:
      - local: .gitlab/ci/project.yml
    strategy: mirror

.release-plz:
  stage: release
  image: rust:1
  variables:
    GIT_DEPTH: "0"
  before_script:
    - curl -fsSL "https://github.com/release-plz/release-plz/releases/download/release-plz-v0.3.160/release-plz-x86_64-unknown-linux-gnu.tar.gz" | tar -xz -C /usr/local/bin

# The release request: the version bump and the changelog, as one merge
# request against the pushed branch. Force-push refresh is unavailable on
# this forge, so release-plz closes an outdated request and opens a fresh one
# when new work lands; the changelog correction window is correspondingly
# narrower.
release-request:
  extends: .release-plz
  rules:
    - if: '$CI_COMMIT_BRANCH == "RK_TRUNK_BRANCH" || $CI_COMMIT_BRANCH =~ /^RK_LINE_PREFIX_RE/'
  resource_group: release-request
  variables:
    RELEASE_STYLE: RK_STYLE
  script:
    - release-plz release-pr --forge gitlab --git-token "$RELEASE_BOT_TOKEN"
    - |
      set -eu
      # The trunk style's standing arm. It dies with the request it was made
      # on — this forge refreshes by closing the outdated request and opening
      # a fresh one — so it is re-applied on every run, and it authenticates
      # as the bot for the same reason every job here does: a merge under the
      # job token would land a bump that releases nothing. The bot's own
      # authorship is the filter, so no other open request can be armed by
      # mistake, and an arm the forge cannot take yet — the request's first
      # pipeline not started — waits for the next push rather than failing
      # the job; the lines style merges by hand and arms nothing.
      if [ "$RELEASE_STYLE" != "trunk" ]; then
        echo "the lines style merges each release by hand; not arming."
        exit 0
      fi
      if [ "$CI_COMMIT_BRANCH" != "RK_TRUNK_BRANCH" ]; then
        echo "a line's request is never armed; its candidate is what a human validated."
        exit 0
      fi
      api() { curl -fsS --header "PRIVATE-TOKEN: $RELEASE_BOT_TOKEN" "$@"; }
      project="$CI_API_V4_URL/projects/$CI_PROJECT_ID"
      bot="$(api "$CI_API_V4_URL/user" | grep -o '"username":"[^"]*"' | head -n 1 | cut -d '"' -f 4)"
      iid="$(api "$project/merge_requests?state=opened&target_branch=$CI_COMMIT_BRANCH&author_username=$bot&per_page=1" | grep -o '"iid":[0-9]*' | head -n 1 | cut -d : -f 2)"
      if [ -z "$iid" ]; then
        echo "no release request to arm."
        exit 0
      fi
      if api -X PUT "$project/merge_requests/$iid/merge?auto_merge=true&merge_when_pipeline_succeeds=true&squash=true&should_remove_source_branch=true" >/dev/null 2>&1; then
        echo "armed merge request !$iid."
      else
        echo "could not arm !$iid yet — its pipeline has not started; the next trunk push re-arms."
      fi

# The release itself: tag, GitLab release, and the crates.io publish over
# OIDC. The job's ID token is exchanged for a short-lived registry token;
# nothing long-lived exists on the publish path.
release:
  extends: .release-plz
  rules:
    - if: '$CI_COMMIT_BRANCH == "RK_TRUNK_BRANCH" || $CI_COMMIT_BRANCH =~ /^RK_LINE_PREFIX_RE/'
  resource_group: release
  id_tokens:
    CRATES_IO_ID_TOKEN:
      aud: crates.io
  script:
    - |
      set -eu
      response="$(curl -fsS -X PUT "https://crates.io/api/v1/trusted_publishing/tokens" \
        -H 'Content-Type: application/json' \
        -d "{\"jwt\":\"$CRATES_IO_ID_TOKEN\"}")"
      CARGO_REGISTRY_TOKEN="$(printf '%s' "$response" | sed -n 's/.*"token":"\([^"]*\)".*/\1/p')"
      export CARGO_REGISTRY_TOKEN
      release-plz release --forge gitlab --git-token "$RELEASE_BOT_TOKEN"
      curl -fsS -X DELETE "https://crates.io/api/v1/trusted_publishing/tokens" \
        -H "Authorization: Bearer $CARGO_REGISTRY_TOKEN" || true