runewarp 0.1.0

Runewarp is an ingress tunneling tool for exposing local services without moving TLS termination to the edge. Clients connect out over QUIC, so you can publish services without putting your backend directly on the Internet or leaking your public IP.
Documentation
name: Release

on:
  push:
    tags:
      - "v*"
  workflow_dispatch:
    inputs:
      release_tag:
        description: Stable release tag to rehearse, in vX.Y.Z form
        required: true
        type: string

permissions:
  contents: read
  checks: read

concurrency:
  group: release-${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref }}
  cancel-in-progress: false

jobs:
  gate:
    name: Release gate
    runs-on: ubuntu-latest
    timeout-minutes: 15
    outputs:
      release_tag: ${{ steps.release-metadata.outputs.release_tag }}
      release_version: ${{ steps.release-metadata.outputs.release_version }}
      image_repository: ${{ steps.release-metadata.outputs.image_repository }}
      primary_image_ref: ${{ steps.release-metadata.outputs.primary_image_ref }}
      docker_tags: ${{ steps.release-metadata.outputs.docker_tags }}
    steps:
      - name: Check out the repository
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          fetch-depth: 0
          persist-credentials: false

      - name: Resolve release metadata
        id: release-metadata
        env:
          EVENT_NAME: ${{ github.event_name }}
          PUSH_TAG: ${{ github.ref_name }}
          WORKFLOW_TAG: ${{ inputs.release_tag }}
        run: |
          if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then
            release_tag="$WORKFLOW_TAG"
          else
            release_tag="$PUSH_TAG"
          fi

          [[ -n "$release_tag" ]] || {
            printf 'error: release tag input is required\n' >&2
            exit 1
          }

          release_version="${release_tag#v}"
          image_repository="docker.io/runewarp/runewarp"
          primary_image_ref="${image_repository}:${release_version}"
          minor_series="${release_version%.*}"
          major_series="${release_version%%.*}"

          {
            printf 'RELEASE_TAG=%s\n' "$release_tag"
            printf 'RELEASE_VERSION=%s\n' "$release_version"
            printf 'IMAGE_REPOSITORY=%s\n' "$image_repository"
            printf 'PRIMARY_IMAGE_REF=%s\n' "$primary_image_ref"
          } >> "$GITHUB_ENV"

          {
            printf 'release_tag=%s\n' "$release_tag"
            printf 'release_version=%s\n' "$release_version"
            printf 'image_repository=%s\n' "$image_repository"
            printf 'primary_image_ref=%s\n' "$primary_image_ref"
            printf 'docker_tags<<EOF\n'
            printf '%s:%s\n' "$image_repository" "$release_version"
            printf '%s:%s\n' "$image_repository" "$minor_series"
            printf '%s:%s\n' "$image_repository" "$major_series"
            printf '%s:latest\n' "$image_repository"
            printf 'EOF\n'
          } >> "$GITHUB_OUTPUT"

      - name: Validate rehearsal release gate
        if: github.event_name == 'workflow_dispatch'
        run: ./scripts/validate-release-gates.sh rehearsal --tag "$RELEASE_TAG"

      - name: Validate signed release tag
        if: github.event_name == 'push'
        run: ./scripts/validate-release-gates.sh tag --tag "$RELEASE_TAG" --allowed-signers-file "$PWD/.github/release-allowed-signers"

      - name: Verify prior green CI
        if: github.event_name == 'push'
        env:
          GITHUB_TOKEN: ${{ github.token }}
          REPOSITORY: ${{ github.repository }}
          COMMIT_SHA: ${{ github.sha }}
        run: |
          python - <<'PY'
          import json
          import os
          import sys
          import urllib.error
          import urllib.request

          repository = os.environ["REPOSITORY"]
          commit_sha = os.environ["COMMIT_SHA"]
          token = os.environ["GITHUB_TOKEN"]

          request = urllib.request.Request(
              f"https://api.github.com/repos/{repository}/commits/{commit_sha}/check-runs?check_name=CI&filter=latest",
              headers={
                  "Accept": "application/vnd.github+json",
                  "Authorization": f"Bearer {token}",
                  "X-GitHub-Api-Version": "2022-11-28",
              },
          )

          try:
              with urllib.request.urlopen(request) as response:
                  payload = json.load(response)
          except urllib.error.HTTPError as error:
              print(
                  f"error: failed to query GitHub check runs for commit {commit_sha}: "
                  f"HTTP {error.code} {error.reason}",
                  file=sys.stderr,
              )
              sys.exit(1)
          except urllib.error.URLError as error:
              print(
                  f"error: failed to reach GitHub while checking CI for commit {commit_sha}: "
                  f"{error.reason}",
                  file=sys.stderr,
              )
              sys.exit(1)

          matches = [
              check_run
              for check_run in payload.get("check_runs", [])
              if check_run.get("name") == "CI"
          ]

          if not matches:
              print(
                  f"error: commit {commit_sha} does not have an aggregate CI check run",
                  file=sys.stderr,
              )
              sys.exit(1)

          if not any(check_run.get("conclusion") == "success" for check_run in matches):
              print(
                  f"error: aggregate CI check for commit {commit_sha} is not successful",
                  file=sys.stderr,
              )
              sys.exit(1)
          PY

      - name: Render release notes preview
        run: ./scripts/render-release-notes.sh --version "${RELEASE_TAG#v}" > /tmp/release-notes.md

      - name: Summarize release gate
        env:
          EVENT_NAME: ${{ github.event_name }}
        run: |
          if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then
            mode="rehearsal"
            publish_note="Rehearsal skips Docker Hub pushes, Sigstore signing, crates.io publication, and GitHub Release creation."
          else
            mode="tag gate"
            publish_note="Real release runs crates.io publication first, then Docker Hub publication, then finalizes the GitHub Release."
          fi

          {
            printf '## Release workflow\n\n'
            printf -- '- Mode: %s\n' "$mode"
            printf -- "- Release tag: \`%s\`\n" "$RELEASE_TAG"
            printf -- "- Release version: \`%s\`\n" "$RELEASE_VERSION"
            printf -- "- Source ref: \`%s\`\n" "${GITHUB_REF}"
            printf -- "- Docker tags: \`%s\`, \`%s\`, \`%s\`, \`latest\`\n" "$RELEASE_VERSION" "${RELEASE_VERSION%.*}" "${RELEASE_VERSION%%.*}"
            printf -- '- Publish status: %s\n' "$publish_note"
          } >> "$GITHUB_STEP_SUMMARY"

  crate-release:
    name: Publish crates.io release
    if: github.event_name == 'push'
    needs:
      - gate
    runs-on: ubuntu-latest
    timeout-minutes: 45
    environment: release
    permissions:
      contents: read
    steps:
      - name: Check out the repository
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false

      - name: Install Rust toolchain
        run: |
          rustup set profile minimal
          rustup toolchain install stable
          rustup default stable

      - name: Publish crate to crates.io
        env:
          CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
          CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse
          CARGO_HTTP_MULTIPLEXING: "false"
          CARGO_NET_RETRY: "5"
        run: cargo publish --locked --manifest-path Cargo.toml

      - name: Verify published crate install surface
        run: |
          ./scripts/validate-install-surfaces.sh registry-install \
            --crate-name runewarp \
            --bin-name runewarp \
            --expected-version "${{ needs.gate.outputs.release_version }}" \
            --retry-attempts 10 \
            --retry-delay-seconds 30

  docker-release:
    name: Publish Docker Hub release
    if: github.event_name == 'push'
    needs:
      - gate
      - crate-release
    runs-on: ubuntu-latest
    timeout-minutes: 60
    environment: release
    permissions:
      contents: read
      id-token: write
    steps:
      - name: Check out the repository
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false

      - name: Set up QEMU
        uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0

      - name: Log in to Docker Hub
        uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
        with:
          username: ${{ secrets.DOCKER_USERNAME }}
          password: ${{ secrets.DOCKER_TOKEN }}

      - name: Enforce Docker version tag immutability
        run: |
          ./scripts/validate-install-surfaces.sh docker-registry-tag-absent \
            --image-ref "${{ needs.gate.outputs.primary_image_ref }}"

      - name: Build and push multi-arch release image
        id: build-image
        uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
        with:
          context: .
          file: ./Dockerfile
          platforms: linux/amd64,linux/arm64
          push: true
          provenance: mode=max
          tags: ${{ needs.gate.outputs.docker_tags }}

      - name: Install cosign
        uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3.9.1

      - name: Sign released image
        env:
          IMAGE_REPOSITORY: ${{ needs.gate.outputs.image_repository }}
          IMAGE_DIGEST: ${{ steps.build-image.outputs.digest }}
        run: |
          cosign sign --yes "${IMAGE_REPOSITORY}@${IMAGE_DIGEST}"

      - name: Verify published Docker image
        run: |
          ./scripts/validate-install-surfaces.sh docker-registry-image \
            --image-ref "${{ needs.gate.outputs.primary_image_ref }}" \
            --expected-version "${{ needs.gate.outputs.release_version }}" \
            --retry-attempts 10 \
            --retry-delay-seconds 15

  github-release:
    name: Finalize GitHub release
    if: github.event_name == 'push'
    needs:
      - gate
      - docker-release
      - crate-release
    runs-on: ubuntu-latest
    timeout-minutes: 15
    permissions:
      contents: write
    steps:
      - name: Check out the repository
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false

      - name: Render release notes
        run: ./scripts/render-release-notes.sh --version "${{ needs.gate.outputs.release_version }}" > /tmp/release-notes.md

      - name: Create GitHub release
        env:
          GITHUB_TOKEN: ${{ github.token }}
          RELEASE_TAG: ${{ needs.gate.outputs.release_tag }}
          RELEASE_VERSION: ${{ needs.gate.outputs.release_version }}
          REPOSITORY: ${{ github.repository }}
        run: |
          gh release create "$RELEASE_TAG" \
            --repo "$REPOSITORY" \
            --verify-tag \
            --latest \
            --title "Runewarp $RELEASE_VERSION" \
            --notes-file /tmp/release-notes.md