diff --git a/.github/goreleaser.yml b/.github/goreleaser.yml index 6c0ae33e8..c36fae2d4 100644 --- a/.github/goreleaser.yml +++ b/.github/goreleaser.yml @@ -77,8 +77,10 @@ release: name: silo draft: true prerelease: false - mode: append + replace_existing_draft: true replace_existing_artifacts: false + mode: replace + # Draft replacement matches the release name; keep it identical to the tag. name_template: "{{ .Tag }}" changelog: diff --git a/.github/workflows/finalize-release.yml b/.github/workflows/finalize-release.yml index 92f63d558..5f4950ed3 100644 --- a/.github/workflows/finalize-release.yml +++ b/.github/workflows/finalize-release.yml @@ -23,7 +23,7 @@ permissions: artifact-metadata: write concurrency: - group: finalize-release + group: release-${{ inputs.tag }} cancel-in-progress: false jobs: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c4b9aaae0..e3350283a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,5 +1,10 @@ name: Release +# Retry contract: an absent or single unfinalized Draft may be rebuilt from +# scratch; a published release or a Draft carrying finalize's GPG-derived +# provenance marker is terminal for this lane. The per-tag lock serializes +# workflows, but a maintainer must not publish the Draft while this job runs. + on: push: tags: @@ -16,6 +21,10 @@ permissions: attestations: write artifact-metadata: write +concurrency: + group: release-${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }} + cancel-in-progress: false + jobs: release: runs-on: ubuntu-latest @@ -78,6 +87,15 @@ jobs: echo "Invalid release tag format: ${TAG}" >&2 exit 1 fi + if ! TAG_COMMIT="$(git rev-parse "${TAG}^{commit}" 2>/dev/null)"; then + echo "Release tag ${TAG} does not resolve to a commit" >&2 + exit 1 + fi + HEAD_COMMIT="$(git rev-parse HEAD)" + if [ "${TAG_COMMIT}" != "${HEAD_COMMIT}" ]; then + echo "Release tag ${TAG} resolves to ${TAG_COMMIT}, checkout is ${HEAD_COMMIT}" >&2 + exit 1 + fi VERSION_HYPHEN="${TAG#RELEASE.}" PKG_VERSION="$(echo "${VERSION_HYPHEN}" | sed -E 's/^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2})-([0-9]{2})-([0-9]{2})Z$/\1\2\3\4\5\6.0.0/')" VERSION_COLON="$(echo "${VERSION_HYPHEN}" | sed -E 's/T([0-9]{2})-([0-9]{2})-([0-9]{2})Z$/T\1:\2:\3Z/')" @@ -93,6 +111,13 @@ jobs: echo "Package version: ${PKG_VERSION}" echo "LDFLAGS: ${LDFLAGS}" + - name: Check existing release state + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + buildscripts/check-release-state.sh "${RELEASE_TAG}" + # Both installer actions are pinned to immutable commits. The explicit # tool versions keep the release format reproducible across workflow # reruns while the installers verify the downloaded executables. @@ -113,6 +138,7 @@ jobs: args: release --clean --skip=validate --config .github/goreleaser.yml env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GORELEASER_CURRENT_TAG: ${{ env.RELEASE_TAG }} LDFLAGS: ${{ env.LDFLAGS }} PKG_VERSION: ${{ env.PKG_VERSION }} @@ -189,6 +215,14 @@ jobs: test -s "${BUNDLE_PATH}" cp "${BUNDLE_PATH}" "dist/silo_${PKG_VERSION}_provenance.sigstore.json" + - name: Confirm unfinalized Draft release state + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REQUIRE_DRAFT: "true" + run: | + set -euo pipefail + buildscripts/check-release-state.sh "${RELEASE_TAG}" + - name: Upload nFPM packages to Draft release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test-release.yml b/.github/workflows/test-release.yml index e717fcde6..2ab3c2ad3 100644 --- a/.github/workflows/test-release.yml +++ b/.github/workflows/test-release.yml @@ -23,6 +23,8 @@ on: - "buildscripts/minio-upgrade.sh" - "buildscripts/sign-release-rpms.sh" - "buildscripts/verify-build-provenance.sh" + - "buildscripts/check-release-state.sh" + - "buildscripts/check-release-state_test.sh" - "buildscripts/verify-rebrand.sh" - "buildscripts/verify-helm-migration.sh" - "buildscripts/helm-migration-guard/**" @@ -479,6 +481,8 @@ jobs: bash -n buildscripts/minio-upgrade.sh bash -n buildscripts/sign-release-rpms.sh bash -n buildscripts/verify-build-provenance.sh + bash -n buildscripts/check-release-state.sh + bash -n buildscripts/check-release-state_test.sh bash -n buildscripts/verify-rebrand.sh bash -n buildscripts/verify-helm-migration.sh sh -n buildscripts/package/postinstall.sh @@ -493,9 +497,12 @@ jobs: test -x buildscripts/package-release.sh test -x buildscripts/sign-release-rpms.sh test -x buildscripts/verify-build-provenance.sh + test -x buildscripts/check-release-state.sh + test -x buildscripts/check-release-state_test.sh test -x buildscripts/verify-rebrand.sh test -x buildscripts/verify-helm-migration.sh test -x buildscripts/package/postinstall.sh test -x buildscripts/package/preremove.sh test -x buildscripts/package/lifecycle_test.sh test -x dockerscripts/docker-entrypoint_test.sh + buildscripts/check-release-state_test.sh diff --git a/buildscripts/check-release-state.sh b/buildscripts/check-release-state.sh new file mode 100755 index 000000000..85dfb8ced --- /dev/null +++ b/buildscripts/check-release-state.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash + +# Fail closed before a release job can replace published or finalized assets. +# An ordinary Draft is retry state; a finalized Draft contains GPG-derived +# materials and must never be replaced by the build lane. + +set -euo pipefail + +release_tag="${1:-}" +fixture="${2:-}" +repository="${GITHUB_REPOSITORY:-pgsty/silo}" +require_draft="${REQUIRE_DRAFT:-false}" + +if ! command -v jq >/dev/null 2>&1; then + echo "jq is required to inspect GitHub release state" >&2 + exit 1 +fi + +if [[ ! "${release_tag}" =~ ^RELEASE\.[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}-[0-9]{2}-[0-9]{2}Z$ ]]; then + echo "Invalid release tag format: ${release_tag:-}" >&2 + exit 1 +fi + +if [ -n "${fixture}" ]; then + release_json="$(<"${fixture}")" +else + error_file="$(mktemp)" + trap 'rm -f "${error_file}"' EXIT + if ! release_json="$( + gh api --paginate "repos/${repository}/releases?per_page=100" --jq '.[]' 2>"${error_file}" | + jq --arg tag "${release_tag}" -s '[.[] | select(.tag_name == $tag)]' + )"; then + cat "${error_file}" >&2 + exit 1 + fi +fi + +if ! jq -e 'type == "array" and all(.[]; type == "object" and (.tag_name | type == "string") and (.draft | type == "boolean"))' \ + <<<"${release_json}" >/dev/null 2>&1; then + echo "Invalid release state response for ${release_tag}" >&2 + exit 1 +fi + +if ! jq -e --arg tag "${release_tag}" 'all(.[]; .tag_name == $tag)' \ + <<<"${release_json}" >/dev/null 2>&1; then + echo "Release state returned a tag other than ${release_tag}" >&2 + exit 1 +fi + +release_count="$(jq 'length' <<<"${release_json}")" +if [ "${release_count}" -eq 0 ]; then + if [ "${require_draft}" = "true" ]; then + echo "Expected one Draft release for ${release_tag}, found none" >&2 + exit 1 + fi + echo "No existing release for ${release_tag}." + exit 0 +fi + +if [ "${release_count}" -ne 1 ]; then + echo "Refusing to choose among ${release_count} releases for ${release_tag}; clean duplicate Drafts first" >&2 + exit 1 +fi + +if [ "$(jq -r '.[0].draft' <<<"${release_json}")" != "true" ]; then + echo "Refusing to overwrite published release ${release_tag}" >&2 + exit 1 +fi + +finalize_markers="$(jq '[.[0].assets[]? | select(.name | endswith("_packages_provenance.sigstore.json"))] | length' <<<"${release_json}")" +if [ "${finalize_markers}" -ne 0 ]; then + echo "Refusing to replace finalized Draft ${release_tag}" >&2 + exit 1 +fi + +echo "Existing unfinalized Draft ${release_tag} will be replaced from scratch." diff --git a/buildscripts/check-release-state_test.sh b/buildscripts/check-release-state_test.sh new file mode 100755 index 000000000..6e2b63779 --- /dev/null +++ b/buildscripts/check-release-state_test.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +checker="${script_dir}/check-release-state.sh" +tag="RELEASE.2026-08-29T00-00-00Z" +fixture="$(mktemp)" +stdout_file="$(mktemp)" +stderr_file="$(mktemp)" +trap 'rm -f "${fixture}" "${stdout_file}" "${stderr_file}"' EXIT + +expect_success() { + if ! "${checker}" "$@" >"${stdout_file}" 2>"${stderr_file}"; then + cat "${stderr_file}" >&2 + return 1 + fi +} + +expect_failure() { + if "${checker}" "$@" >"${stdout_file}" 2>"${stderr_file}"; then + echo "Expected release-state check to fail: $*" >&2 + return 1 + fi +} + +printf '[]\n' >"${fixture}" +expect_success "${tag}" "${fixture}" +grep -qF "No existing release for ${tag}." "${stdout_file}" + +if REQUIRE_DRAFT=true "${checker}" "${tag}" "${fixture}" >"${stdout_file}" 2>"${stderr_file}"; then + echo "Expected required-Draft check to fail when no release exists" >&2 + exit 1 +fi +grep -qF "Expected one Draft release for ${tag}, found none" "${stderr_file}" + +printf '[{"tag_name":"%s","draft":true,"assets":[]}]\n' "${tag}" >"${fixture}" +expect_success "${tag}" "${fixture}" +grep -qF "Existing unfinalized Draft ${tag} will be replaced from scratch." "${stdout_file}" +if ! REQUIRE_DRAFT=true "${checker}" "${tag}" "${fixture}" >"${stdout_file}" 2>"${stderr_file}"; then + cat "${stderr_file}" >&2 + exit 1 +fi + +printf '[{"tag_name":"%s","draft":true,"assets":[{"name":"silo_20260829000000.0.0_packages_provenance.sigstore.json"}]}]\n' "${tag}" >"${fixture}" +expect_failure "${tag}" "${fixture}" +grep -qF "Refusing to replace finalized Draft ${tag}" "${stderr_file}" + +printf '[{"tag_name":"%s","draft":false}]\n' "${tag}" >"${fixture}" +expect_failure "${tag}" "${fixture}" +grep -qF "Refusing to overwrite published release ${tag}" "${stderr_file}" + +printf '[{"tag_name":"%s","draft":true},{"tag_name":"%s","draft":true}]\n' "${tag}" "${tag}" >"${fixture}" +expect_failure "${tag}" "${fixture}" +grep -qF "Refusing to choose among 2 releases" "${stderr_file}" + +printf '[{"tag_name":"RELEASE.2026-08-28T00-00-00Z","draft":true}]\n' >"${fixture}" +expect_failure "${tag}" "${fixture}" +grep -qF "other than ${tag}" "${stderr_file}" + +printf '{not-json}\n' >"${fixture}" +expect_failure "${tag}" "${fixture}" +grep -qF "Invalid release state response for ${tag}" "${stderr_file}" + +expect_failure "not-a-release-tag" "${fixture}" +grep -qF "Invalid release tag format" "${stderr_file}" + +echo "release-state decision tests passed"