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 attestations: write contents: write id-token: write steps:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 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 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 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
- 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'