compose-lens 0.1.0

Loss-aware parsing, processing, validation, and rendering of Compose projects
Documentation
name: Release

on:
  workflow_dispatch:

permissions: {}

concurrency:
  group: release
  cancel-in-progress: false

jobs:
  release:
    name: Publish ComposeLens
    if: github.repository == 'Strukturpiloten/compose-lens'
    environment: release
    runs-on: ubuntu-24.04
    timeout-minutes: 45
    env:
      RELEASE_GITHUB_API_VERSION: "2026-03-10"
    permissions:
      artifact-metadata: write # Record artifact attestations for the packaged crate.
      attestations: write # Sign the packaged crate's provenance attestation.
      contents: write # Create the release tag and GitHub release.
      id-token: write # Authenticate attestations and crates.io trusted publishing.
    steps:
      - name: Check out repository
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          fetch-depth: 0
          persist-credentials: false

      - name: Install configured Rust toolchain
        run: rustup show active-toolchain

      - name: Derive and validate release metadata
        id: metadata
        shell: bash
        env:
          DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
        run: |
          set -euo pipefail

          if [[ "${GITHUB_REF}" != "refs/heads/${DEFAULT_BRANCH}" ]]; then
            echo "Run releases from the default branch (${DEFAULT_BRANCH})." >&2
            exit 1
          fi

          version="$(
            cargo metadata --locked --no-deps --format-version 1 |
              jq -er '.packages[] | select(.name == "compose-lens") | .version'
          )"
          tag="v${version}"
          notes="docs/releases/${version}.md"
          crate="target/package/compose-lens-${version}.crate"

          test -f "${notes}"
          escaped_version="${version//./\\.}"
          if ! grep -Eq "^## \\[${escaped_version}\\] - [0-9]{4}-[0-9]{2}-[0-9]{2}$" CHANGELOG.md; then
            echo "CHANGELOG.md must contain '## [${version}] - YYYY-MM-DD'." >&2
            exit 1
          fi

          printf 'version=%s\n' "${version}" >> "${GITHUB_OUTPUT}"
          printf 'tag=%s\n' "${tag}" >> "${GITHUB_OUTPUT}"
          printf 'notes=%s\n' "${notes}" >> "${GITHUB_OUTPUT}"
          printf 'crate=%s\n' "${crate}" >> "${GITHUB_OUTPUT}"

      - name: Check formatting
        run: cargo fmt --all -- --check
      - name: Check all targets
        run: cargo ci-check
      - name: Check repository policies
        run: cargo ci-policy
      - name: Run Clippy
        run: cargo ci-clippy
      - name: Run tests
        run: cargo ci-test
      - name: Run documentation tests
        run: cargo ci-doctest
      - name: Build documentation
        env:
          RUSTDOCFLAGS: -D warnings
        run: cargo ci-doc
      - name: Audit dependencies, licenses, bans, and sources
        uses: EmbarkStudios/cargo-deny-action@3c6349835b2b7b196a839186cb8b78e02f7b5f25 # v2.1.1
        with:
          command: check
          arguments: --all-features

      - name: Check crates.io publication state
        id: registry
        shell: bash
        env:
          CRATES_IO_USER_AGENT: ComposeLens release workflow (+https://github.com/Strukturpiloten/compose-lens)
          VERSION: ${{ steps.metadata.outputs.version }}
        run: |
          set -euo pipefail
          status="$(
            curl --silent --show-error \
              --user-agent "${CRATES_IO_USER_AGENT}" \
              --retry 3 \
              --retry-all-errors \
              --connect-timeout 10 \
              --max-time 60 \
              --output /dev/null \
              --write-out '%{http_code}' \
              "https://crates.io/api/v1/crates/compose-lens/${VERSION}"
          )"
          case "${status}" in
            200)
              echo "published=true" >> "${GITHUB_OUTPUT}"
              ;;
            404)
              echo "published=false" >> "${GITHUB_OUTPUT}"
              ;;
            *)
              echo "Unexpected crates.io response: HTTP ${status}." >&2
              exit 1
              ;;
          esac

      - name: Build release package and checksum
        shell: bash
        env:
          CRATE: ${{ steps.metadata.outputs.crate }}
        run: |
          set -euo pipefail
          cargo package --locked
          sha256sum "${CRATE}" > "${CRATE}.sha256"

      - name: Attest release package
        uses: actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d # v4.2.1
        with:
          subject-path: ${{ steps.metadata.outputs.crate }}

      - name: Create or verify annotated release tag
        shell: bash
        env:
          GH_TOKEN: ${{ github.token }}
          TAG: ${{ steps.metadata.outputs.tag }}
        run: |
          set -euo pipefail

          if git ls-remote --exit-code origin "refs/tags/${TAG}" >/dev/null 2>&1; then
            git fetch --force origin "refs/tags/${TAG}:refs/tags/${TAG}"
            tagged_commit="$(git rev-list -n 1 "${TAG}")"
            if [[ "${tagged_commit}" != "${GITHUB_SHA}" ]]; then
              echo "${TAG} already points to ${tagged_commit}, not ${GITHUB_SHA}." >&2
              exit 1
            fi
          else
            tag_object_sha="$(
              gh api --method POST "repos/${GITHUB_REPOSITORY}/git/tags" \
                --header "X-GitHub-Api-Version: ${RELEASE_GITHUB_API_VERSION}" \
                -f tag="${TAG}" \
                -f message="ComposeLens ${TAG}" \
                -f object="${GITHUB_SHA}" \
                -f type=commit \
                --jq '.sha'
            )"
            gh api --method POST "repos/${GITHUB_REPOSITORY}/git/refs" \
              --header "X-GitHub-Api-Version: ${RELEASE_GITHUB_API_VERSION}" \
              -f ref="refs/tags/${TAG}" \
              -f sha="${tag_object_sha}"
          fi

      - name: Create or replace draft GitHub release
        id: release
        shell: bash
        env:
          GH_TOKEN: ${{ github.token }}
          NOTES: ${{ steps.metadata.outputs.notes }}
          TAG: ${{ steps.metadata.outputs.tag }}
          VERSION: ${{ steps.metadata.outputs.version }}
        run: |
          set -euo pipefail

          release_name="ComposeLens ${VERSION}"
          releases="$(
            gh api --paginate \
              --header "X-GitHub-Api-Version: ${RELEASE_GITHUB_API_VERSION}" \
              "repos/${GITHUB_REPOSITORY}/releases?per_page=100" |
              jq --slurp '[.[][]]'
          )"
          published_count="$(
            jq --raw-output --arg tag "${TAG}" \
              '[.[] | select(.draft == false and .tag_name == $tag)] | length' \
              <<< "${releases}"
          )"
          if [[ "${published_count}" != "0" ]]; then
            echo "${TAG} is already associated with a published GitHub release." >&2
            exit 1
          fi

          drafts="$(
            jq --arg name "${release_name}" --arg tag "${TAG}" \
              '[.[] |
                select(
                  .draft == true and
                  (.tag_name == $tag or .name == $name)
                )
              ]' \
              <<< "${releases}"
          )"
          draft_count="$(jq --raw-output 'length' <<< "${drafts}")"

          case "${draft_count}" in
            0)
              ;;
            1)
              previous_release_id="$(jq --exit-status --raw-output '.[0].id' <<< "${drafts}")"
              gh api --method DELETE \
                --header "X-GitHub-Api-Version: ${RELEASE_GITHUB_API_VERSION}" \
                "repos/${GITHUB_REPOSITORY}/releases/${previous_release_id}"
              ;;
            *)
              echo "Found ${draft_count} possible workflow drafts for ${TAG}; refusing to choose one." >&2
              exit 1
              ;;
          esac

          release="$(
            jq --null-input \
              --rawfile body "${NOTES}" \
              --arg name "${release_name}" \
              --arg tag "${TAG}" \
              --arg target "${GITHUB_SHA}" \
              '{
                tag_name: $tag,
                target_commitish: $target,
                name: $name,
                body: $body,
                draft: true,
                prerelease: false
              }' |
              gh api --method POST \
                --header "X-GitHub-Api-Version: ${RELEASE_GITHUB_API_VERSION}" \
                "repos/${GITHUB_REPOSITORY}/releases" \
                --input -
          )"
          release_id="$(jq --exit-status --raw-output '.id' <<< "${release}")"
          upload_url="$(
            jq --exit-status --raw-output \
              '.upload_url | sub("\\{\\?name,label\\}$"; "")' \
              <<< "${release}"
          )"
          expected_upload_url="https://uploads.github.com/repos/${GITHUB_REPOSITORY}/releases/${release_id}/assets"
          if [[ "${upload_url}" != "${expected_upload_url}" ]]; then
            echo "GitHub returned an unexpected release asset upload URL." >&2
            exit 1
          fi

          printf 'release_id=%s\n' "${release_id}" >> "${GITHUB_OUTPUT}"
          printf 'upload_url=%s\n' "${upload_url}" >> "${GITHUB_OUTPUT}"

      - name: Upload draft GitHub release assets
        shell: bash
        env:
          CRATE: ${{ steps.metadata.outputs.crate }}
          GH_TOKEN: ${{ github.token }}
          RELEASE_ID: ${{ steps.release.outputs.release_id }}
          UPLOAD_URL: ${{ steps.release.outputs.upload_url }}
        run: |
          set -euo pipefail

          upload_asset() {
            local asset_path="$1"
            local content_type="$2"
            local asset_name="${asset_path##*/}"
            local response

            test -f "${asset_path}"
            if [[ ! "${asset_name}" =~ ^[A-Za-z0-9._-]+$ ]]; then
              echo "Unsafe release asset name: ${asset_name}" >&2
              return 1
            fi

            response="$(
              curl --proto '=https' \
                --tlsv1.2 \
                --fail-with-body \
                --silent \
                --show-error \
                --connect-timeout 10 \
                --max-time 120 \
                --request POST \
                --header "Accept: application/vnd.github+json" \
                --header "Authorization: Bearer ${GH_TOKEN}" \
                --header "X-GitHub-Api-Version: ${RELEASE_GITHUB_API_VERSION}" \
                --header "Content-Type: ${content_type}" \
                --data-binary "@${asset_path}" \
                "${UPLOAD_URL}?name=${asset_name}"
            )"
            jq --exit-status --arg name "${asset_name}" \
              '.name == $name and .state == "uploaded" and (.id | type == "number")' \
              <<< "${response}" >/dev/null
          }

          expected_upload_url="https://uploads.github.com/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets"
          if [[ "${UPLOAD_URL}" != "${expected_upload_url}" ]]; then
            echo "Release asset upload URL does not match the created release." >&2
            exit 1
          fi

          upload_asset "${CRATE}" "application/gzip"
          upload_asset "${CRATE}.sha256" "text/plain"

      - name: Select crates.io authentication
        id: auth-mode
        if: steps.registry.outputs.published != 'true'
        shell: bash
        env:
          BOOTSTRAP_TOKEN: ${{ secrets.CRATES_IO_BOOTSTRAP_TOKEN }}
          VERSION: ${{ steps.metadata.outputs.version }}
        run: |
          set -euo pipefail
          if [[ -n "${BOOTSTRAP_TOKEN}" ]]; then
            if [[ "${VERSION}" != "0.1.0" ]]; then
              echo "The bootstrap token is restricted to the first 0.1.0 publication." >&2
              exit 1
            fi
            echo "bootstrap=true" >> "${GITHUB_OUTPUT}"
          else
            echo "bootstrap=false" >> "${GITHUB_OUTPUT}"
          fi

      - name: Authenticate to crates.io with trusted publishing
        id: crates-auth
        if: >-
          steps.registry.outputs.published != 'true' &&
          steps.auth-mode.outputs.bootstrap != 'true'
        uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18 # v1.0.5

      - name: Publish first crate with bootstrap token
        if: >-
          steps.registry.outputs.published != 'true' &&
          steps.auth-mode.outputs.bootstrap == 'true'
        env:
          CARGO_REGISTRY_TOKEN: ${{ secrets.CRATES_IO_BOOTSTRAP_TOKEN }}
        run: cargo publish --locked

      - name: Publish crate with trusted publishing
        if: >-
          steps.registry.outputs.published != 'true' &&
          steps.auth-mode.outputs.bootstrap != 'true'
        env:
          CARGO_REGISTRY_TOKEN: ${{ steps.crates-auth.outputs.token }}
        run: cargo publish --locked

      - name: Publish immutable GitHub release
        shell: bash
        env:
          GH_TOKEN: ${{ github.token }}
          RELEASE_ID: ${{ steps.release.outputs.release_id }}
        run: |
          set -euo pipefail
          jq --null-input '{draft: false, make_latest: "true"}' |
            gh api --method PATCH \
              --header "X-GitHub-Api-Version: ${RELEASE_GITHUB_API_VERSION}" \
              "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}" \
              --input - \
              --jq 'if .draft == false then .html_url
                    else error("GitHub release remained a draft") end'