github-actions-maintainer 0.7.11

General-purpose GitHub Actions maintenance toolkit with secure workflow pinning
Documentation
name: Auto Release

concurrency:
  group: auto-release-${{ github.event.workflow_run.head_branch || github.ref_name || 'main' }}
  cancel-in-progress: false
  queue: max

# A release runs in two phases, because a container action cannot pin a runtime
# image built from its own release: docker.yml derives version tags from the git
# tag, so an image for the released version only exists once the tag does.
# Phase 1 commits the version bump and asks docker.yml to publish an image for
# it; docker.yml hands back to phase 2, which pins that image and tags the
# result. The tagged tree and the image's source then differ only in the two
# Dockerfiles, which do not affect the image, so the tag ships a runtime that
# reports its own version. Releasing in one pass is what left v0.7.0 shipping a
# 0.6.2 binary.
on:
  workflow_run:
    # Docker gates phase 1: it proves an image exists for the commit being
    # released, which phase 1 pins so this workflow runs a current binary.
    workflows: ["CI", "Security", "Docker"]
    types: [completed]
    branches: [main]
  schedule:
    - cron: "0 9 * * 1"
  workflow_dispatch:
    inputs:
      version_bump:
        description: Version bump type
        required: true
        default: auto
        type: choice
        options:
          - auto
          - patch
          - minor
          - major
      phase:
        description: Release phase; auto detects a committed but untagged version
        required: true
        default: auto
        type: choice
        options:
          - auto
          - bump
          - tag

permissions:
  contents: read

jobs:
  check:
    name: Check for Release
    runs-on: ${{ vars.RUST_TEMPLATE_RUNNER_UBUNTU || 'ubuntu-latest' }}
    if: >-
      (github.event_name == 'workflow_run' &&
      (github.event.workflow_run.event == 'push' || github.event.workflow_run.event == 'workflow_dispatch') &&
      github.event.workflow_run.conclusion == 'success' &&
      github.event.workflow_run.head_repository.full_name == github.repository) ||
      github.event_name == 'schedule' ||
      github.event_name == 'workflow_dispatch'
    permissions:
      actions: read
      contents: read
    outputs:
      should_release: ${{ steps.decide.outputs.should_release }}
      target_sha: ${{ steps.target.outputs.sha }}
      phase: ${{ steps.target.outputs.phase }}
      version: ${{ steps.target.outputs.version }}
    steps:
      # Everything keys off the branch head rather than the triggering commit: a
      # pending version bump only exists at the head, and the release CLI
      # publishes from the head and skips when it moved mid-run anyway.
      - name: Determine target SHA and phase
        id: target
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          REQUESTED_PHASE: ${{ github.event.inputs.phase || 'auto' }}
        run: |
          set -euo pipefail
          sha="$(gh api "repos/${GITHUB_REPOSITORY}/commits/main" --jq .sha)"
          if [[ ! "${sha}" =~ ^[0-9a-f]{40}$ ]]; then
            echo "invalid head SHA '${sha}'" >&2
            exit 1
          fi
          version="$(
            gh api "repos/${GITHUB_REPOSITORY}/contents/Cargo.toml?ref=${sha}" --jq .content \
              | base64 -d | grep -m1 '^version = ' | sed -E 's/^version = "(.*)"$/\1/'
          )"
          if [[ ! "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
            echo "invalid manifest version '${version}' at ${sha}" >&2
            exit 1
          fi

          # A manifest version with no tag means phase 1 already committed the
          # bump, so this run finishes that release instead of starting another.
          if response="$(gh api --include --silent "repos/${GITHUB_REPOSITORY}/git/ref/tags/v${version}")"; then
            detected=bump
          else
            status="$(awk '/^HTTP\// { status=$2 } END { print status }' <<< "${response}")"
            if [[ "${status}" != 404 ]]; then
              echo "release tag lookup failed with HTTP ${status:-unavailable}; refusing to infer absence" >&2
              exit 1
            fi
            detected=tag
          fi
          phase="${REQUESTED_PHASE}"
          if [[ "${phase}" == "auto" ]]; then
            phase="${detected}"
          fi

          skip=false
          if [[ "${phase}" == tag && "${detected}" == bump ]]; then
            echo "v${version} already exists; the requested tag phase is a no-op"
            skip=true
          fi
          echo "head=${sha} manifest=${version} detected=${detected} phase=${phase}"
          {
            echo "skip=${skip}"
            echo "sha=${sha}"
            echo "version=${version}"
            echo "phase=${phase}"
          } >> "$GITHUB_OUTPUT"

      - name: Check CI status
        if: steps.target.outputs.skip != 'true'
        id: ci_status
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          TARGET_SHA: ${{ steps.target.outputs.sha }}
          PHASE: ${{ steps.target.outputs.phase }}
        run: |
          set -euo pipefail
          # The Docker handoff can dispatch us just before its own run finishes.
          # Wait briefly for that completion, without exempting the tag phase
          # from any gate. A newer failed rerun supersedes an earlier success.
          attempts=1
          if [[ "${PHASE}" == tag ]]; then attempts=21; fi
          for ((attempt=1; attempt<=attempts; attempt++)); do
            main_sha="$(gh api "repos/${GITHUB_REPOSITORY}/commits/main" --jq .sha)"
            if [[ "${main_sha}" != "${TARGET_SHA}" ]]; then
              echo "main advanced; a new run must validate its new head"
              break
            fi
            all_passed=true
            terminal_failure=false
            for workflow in ci security docker; do
              # GITHUB_TOKEN version commits need explicitly dispatched checks.
              # PR and scheduled runs cannot substitute for these main checks.
              # jq variables are intentionally literal, not shell expansions.
              # shellcheck disable=SC2016
              status="$(gh run list --repo "${GITHUB_REPOSITORY}" --workflow="${workflow}.yml" \
                --branch=main --commit="${TARGET_SHA}" --limit=100 \
                --json event,status,conclusion,attempt,startedAt,createdAt,databaseId --jq '
                def epoch:
                  if type == "string" then (try fromdateiso8601 catch null) else null end;
                def validation_time:
                  if (.attempt | type) != "number" then null
                  elif .attempt < 1 or (.attempt | floor) != .attempt then null
                  elif (.createdAt | epoch) == null then null
                  elif .attempt == 1 then (.createdAt | epoch)
                  elif (.startedAt | epoch) == null then null
                  elif (.startedAt | epoch) < (.createdAt | epoch) then null
                  else (.startedAt | epoch) end;
                # Completion order is not validation order. REST startedAt resets
                # on rerun; ordinary runs keep their creation-time ordering.
                if type != "array" then "completed:unverifiable"
                elif length >= 100 then "completed:truncated"
                else [.[] | select(.event == "push" or .event == "workflow_dispatch")] as $eligible
                | if ($eligible | length) == 0 then "missing"
                  # A queued rerun can still expose the previous start time.
                  elif any($eligible[]; .status != "completed") then "in_flight"
                  else [$eligible[] | . + {validation_time: validation_time}] as $timed
                  | if any($timed[]; .validation_time == null) then "completed:unverifiable"
                    else ($timed | map(.validation_time) | max) as $latest
                    | if all($timed[] | select(.validation_time == $latest); .conclusion == "success")
                      then "completed:success" else "completed:failure" end
                    end
                  end
                end')"
              echo "${workflow}=${status}"
              if [[ "${status}" != completed:success ]]; then
                all_passed=false
                if [[ "${status}" == completed:* ]]; then terminal_failure=true; fi
              fi
            done
            if [[ "${all_passed}" == true ]]; then
              echo "all_passed=true" >> "$GITHUB_OUTPUT"
              exit 0
            fi
            if [[ "${terminal_failure}" == true || "${attempt}" == "${attempts}" ]]; then break; fi
            sleep 30
          done
          echo "all_passed=false" >> "$GITHUB_OUTPUT"

      - name: Decide whether to attempt a release
        id: decide
        env:
          ALL_PASSED: ${{ steps.ci_status.outputs.all_passed }}
        run: |
          # The release CLI itself no-ops when no conventional commit warrants
          # a release, so this gate only enforces green CI, Security, and Docker
          # runs — including for manual dispatches.
          if [[ "${ALL_PASSED}" == "true" ]]; then
            echo "should_release=true" >> "$GITHUB_OUTPUT"
          else
            echo "should_release=false" >> "$GITHUB_OUTPUT"
          fi

  release:
    name: Create Release
    needs: check
    if: needs.check.outputs.should_release == 'true'
    runs-on: ${{ vars.RUST_TEMPLATE_RUNNER_UBUNTU || 'ubuntu-latest' }}
    permissions:
      contents: write
      actions: write
      packages: write
    steps:
      - name: Checkout
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
          ref: ${{ needs.check.outputs.target_sha }}

      - name: Select stable Buildx for digest promotion
        uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
        with:
          version: v0.39.0
          driver: docker

      - name: Resolve the runtime image for this commit
        id: runtime
        env:
          TARGET_SHA: ${{ needs.check.outputs.target_sha }}
        run: |
          set -euo pipefail
          repo_path="$(echo "${GITHUB_REPOSITORY}" | tr '[:upper:]' '[:lower:]')"
          short="${TARGET_SHA:0:7}"
          token="$(curl -fsSL "https://ghcr.io/token?scope=repository:${repo_path}:pull&service=ghcr.io" | jq -r .token)"
          # The short-SHA tag is mutable — scheduled rebuilds re-push it — so
          # resolve it to a digest, which is immutable once committed.
          digest="$(
            curl -fsSL -o /dev/null -D - \
              -H "Authorization: Bearer ${token}" \
              -H 'Accept: application/vnd.oci.image.index.v1+json' \
              -H 'Accept: application/vnd.docker.distribution.manifest.list.v2+json' \
              -H 'Accept: application/vnd.docker.distribution.manifest.v2+json' \
              "https://ghcr.io/v2/${repo_path}/manifests/${short}" \
              | tr -d '\r' | awk 'tolower($1) == "docker-content-digest:" { print $2 }'
          )"
          if [[ -z "${digest}" ]]; then
            echo "no published ghcr.io/${repo_path} image tagged ${short}; refusing to release a mismatched runtime" >&2
            exit 1
          fi
          {
            echo "image=ghcr.io/${repo_path}"
            echo "digest=${digest}"
            echo "short=${short}"
          } >> "$GITHUB_OUTPUT"

      # The whole point of the split: prove the image really was built from the
      # bumped manifest before anything is tagged. A mismatch means the image
      # predates the version commit, which is exactly the v0.7.0 failure.
      - name: Verify the image reports the version being tagged
        if: needs.check.outputs.phase == 'tag'
        env:
          IMAGE: ${{ steps.runtime.outputs.image }}
          DIGEST: ${{ steps.runtime.outputs.digest }}
          VERSION: ${{ needs.check.outputs.version }}
        run: |
          set -euo pipefail
          reported="$(docker run --rm "${IMAGE}@${DIGEST}" --version | awk '{ print $NF }')"
          echo "image reports '${reported}', manifest expects '${VERSION}'"
          if [[ "${reported}" != "${VERSION}" ]]; then
            echo "runtime image ${DIGEST} reports ${reported}, not ${VERSION}; refusing to tag a mismatched runtime" >&2
            exit 1
          fi

      # A local container action is built when the step using it runs, so this
      # rewrite also decides which binary the release steps below execute: the
      # tag phase runs the very image it is about to pin, and the bump phase runs
      # the current head's image rather than the previous release's.
      #
      # Only the tag phase stages the rewrite. The bump commit keeps the old pin
      # because the image for its own version does not exist yet — publishing it
      # is what the bump commit triggers.
      - name: Pin runtime images to the resolved digest
        id: runtime_pin
        env:
          IMAGE: ${{ steps.runtime.outputs.image }}
          DIGEST: ${{ steps.runtime.outputs.digest }}
          # The tag phase pins a prospective display tag plus the existing
          # digest. Docker resolves the digest without looking up that tag.
          # Remote version aliases move only after the publisher succeeds.
          REF_TAG: ${{ needs.check.outputs.phase == 'tag' && needs.check.outputs.version || steps.runtime.outputs.short }}
          PIN_FILES: runtime/Dockerfile release/Dockerfile
        run: |
          set -euo pipefail
          for file in ${PIN_FILES}; do
            [[ -f "${file}" ]] || { echo "missing ${file}" >&2; exit 1; }
            sed -i -E "s|^FROM [^[:space:]]+|FROM ${IMAGE}:${REF_TAG}@${DIGEST}|" "${file}"
            grep -m1 '^FROM ' "${file}"
          done
          echo "files=${PIN_FILES// /,}" >> "$GITHUB_OUTPUT"

      - name: Commit the version bump
        id: bump
        if: needs.check.outputs.phase == 'bump'
        uses: ./
        with:
          command: release
          token: ${{ secrets.GITHUB_TOKEN }}
          expected-head: ${{ needs.check.outputs.target_sha }}
          bump: ${{ github.event.inputs.version_bump || 'auto' }}
          phase: bump

      - name: Validate and publish the committed version
        if: needs.check.outputs.phase == 'bump' && steps.bump.outputs.commit != '' && steps.bump.outputs.released != 'true'
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          VERSION: ${{ steps.bump.outputs.version }}
          COMMIT: ${{ steps.bump.outputs.commit }}
        run: |
          set -euo pipefail
          echo "committed ${VERSION} as ${COMMIT}; building its runtime image"
          # main now holds the version commit, and a push by GITHUB_TOKEN starts
          # no workflows. Run the full CI and Security suites as well as Docker;
          # the tag phase requires all three on the exact current main commit.
          # release-prep hands back after image scan, signing, and SBOM finish.
          main_sha="$(gh api "repos/${GITHUB_REPOSITORY}/commits/main" --jq .sha)"
          if [[ "${main_sha}" != "${COMMIT}" ]]; then
            echo "main advanced after the bump; its own checks must finish before release"
            exit 0
          fi
          gh workflow run ci.yml --repo "$GITHUB_REPOSITORY" --ref main
          gh workflow run security.yml --repo "$GITHUB_REPOSITORY" --ref main
          gh workflow run docker.yml --repo "$GITHUB_REPOSITORY" --ref main -f release-prep=true

      - name: Tag and publish the committed version
        id: release
        if: needs.check.outputs.phase == 'tag'
        uses: ./
        with:
          command: release
          token: ${{ secrets.GITHUB_TOKEN }}
          expected-head: ${{ needs.check.outputs.target_sha }}
          update-major-alias: "true"
          phase: tag
          extra-files: ${{ steps.runtime_pin.outputs.files }}

      # Refusals (existing tag, manifest behind latest, or changed head) must
      # never mutate registry aliases. The publisher rechecks the tag after
      # phase detection; only its confirmed release enables this write.
      - name: Tag the verified image with the release version
        if: steps.release.outputs.released == 'true'
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          EXPECTED_IMAGE: ${{ steps.runtime.outputs.image }}
          EXPECTED_DIGEST: ${{ steps.runtime.outputs.digest }}
          VERSION: ${{ steps.release.outputs.version }}
        run: bash scripts/promote_release_image.sh

      # Tags created with GITHUB_TOKEN do not trigger tag-push workflows, so
      # dispatch the tag pipeline explicitly (workflow_dispatch is exempt from
      # that suppression). Only the binaries need building; the runtime image
      # for this version is already published and tagged above.
      - name: Trigger tag pipelines
        if: steps.release.outputs.released == 'true'
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          VERSION: ${{ steps.release.outputs.version }}
          TAG: ${{ steps.release.outputs.tag }}
        run: |
          gh workflow run release.yml --repo "$GITHUB_REPOSITORY" --ref "$TAG" -f version="$VERSION"

      # A runtime image predating the phased release ignores the phase input and
      # publishes the whole release in one run. Nothing is left pending, so fall
      # back to the previous behaviour and dispatch both tag pipelines.
      - name: Trigger tag pipelines for an unphased runtime
        if: needs.check.outputs.phase == 'bump' && steps.bump.outputs.released == 'true'
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          VERSION: ${{ steps.bump.outputs.version }}
          TAG: ${{ steps.bump.outputs.tag }}
        run: |
          echo "::warning::the pinned runtime ignored the bump phase and released ${TAG} in one pass"
          gh workflow run release.yml --repo "$GITHUB_REPOSITORY" --ref "$TAG" -f version="$VERSION"
          gh workflow run docker.yml --repo "$GITHUB_REPOSITORY" --ref "$TAG"