diff --git a/.github/actions/finalize-release/action.yaml b/.github/actions/finalize-release/action.yaml new file mode 100644 index 0000000..3023dcf --- /dev/null +++ b/.github/actions/finalize-release/action.yaml @@ -0,0 +1,59 @@ +name: Finalize Public Release +description: Validate or finalize a public release PR. + +inputs: + mode: + description: validate or finalize. + required: true + repository-path: + description: Path to the checked out public repository. + required: false + default: . + release-namespace: + description: Release namespace to enforce. + required: false + default: auto + pr-body: + description: Release PR body. + required: true + pr-base-ref: + description: Release PR base branch. + required: true + pr-head-ref: + description: Release PR head branch. + required: true + merge-commit-sha: + description: Merge commit SHA for finalize mode. + required: false + default: "" + +outputs: + namespace: + description: Resolved release namespace. + value: ${{ steps.finalizer.outputs.namespace }} + tag: + description: Resolved release tag. + value: ${{ steps.finalizer.outputs.tag }} + release_branch: + description: Resolved release branch. + value: ${{ steps.finalizer.outputs.release_branch }} + release_kind: + description: Resolved release kind. + value: ${{ steps.finalizer.outputs.release_kind }} + +runs: + using: composite + steps: + - name: Run release finalizer + id: finalizer + shell: bash + env: + RELEASE_NAMESPACE: ${{ inputs.release-namespace }} + PR_BODY: ${{ inputs.pr-body }} + PR_BASE_REF: ${{ inputs.pr-base-ref }} + PR_HEAD_REF: ${{ inputs.pr-head-ref }} + MERGE_COMMIT_SHA: ${{ inputs.merge-commit-sha }} + run: | + set -euo pipefail + cd "${{ inputs.repository-path }}" + bash "${{ github.action_path }}/../../scripts/finalize-release.sh" "${{ inputs.mode }}" diff --git a/.github/scripts/finalize-release.sh b/.github/scripts/finalize-release.sh new file mode 100644 index 0000000..dc03b87 --- /dev/null +++ b/.github/scripts/finalize-release.sh @@ -0,0 +1,257 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: + finalize-release.sh validate + finalize-release.sh finalize + +Environment: + PR_BODY Pull request body containing release metadata. + PR_BASE_REF Pull request base branch. + PR_HEAD_REF Pull request head branch. + MERGE_COMMIT_SHA Required for finalize mode. + RELEASE_NAMESPACE Optional: auto, production, or test. Defaults to auto. +USAGE +} + +die() { + echo "::error::$*" >&2 + exit 1 +} + +note() { + echo "$*" >&2 +} + +require_env() { + local name="$1" + if [[ -z "${!name:-}" ]]; then + die "${name} is required" + fi +} + +extract_body_field() { + local label="$1" + awk -v label="${label}: " ' + index($0, label) == 1 { + sub(label, "") + print + found = 1 + exit + } + END { + if (!found) { + exit 1 + } + } + ' <<< "${PR_BODY}" +} + +remote_ref_exists() { + local ref="$1" + git ls-remote --exit-code origin "${ref}" >/dev/null 2>&1 +} + +remote_ref_sha() { + local ref="$1" + git ls-remote origin "${ref}" | awk 'NR == 1 {print $1}' +} + +ensure_valid_ref() { + local ref="$1" + git check-ref-format "${ref}" >/dev/null 2>&1 || die "Invalid Git ref: ${ref}" +} + +resolve_namespace() { + local requested="${RELEASE_NAMESPACE:-auto}" + + case "${requested}" in + auto) + if [[ "${TAG}" == test/v* || "${RELEASE_BRANCH}" == test-release/* || "${PR_BASE_REF}" == test-main || "${PR_BASE_REF}" == test-release/* ]]; then + NAMESPACE="test" + else + NAMESPACE="production" + fi + ;; + production|test) + NAMESPACE="${requested}" + ;; + *) + die "Invalid RELEASE_NAMESPACE: ${requested}" + ;; + esac + + case "${NAMESPACE}" in + production) + TAG_PREFIX="v" + RELEASE_BRANCH_PREFIX="release/" + MAIN_BRANCH="main" + ;; + test) + TAG_PREFIX="test/v" + RELEASE_BRANCH_PREFIX="test-release/" + MAIN_BRANCH="test-main" + ;; + esac +} + +parse_release_metadata() { + require_env PR_BODY + require_env PR_BASE_REF + require_env PR_HEAD_REF + + [[ "${PR_HEAD_REF}" == sync/copybara-export-* ]] || die "Release finalizer only accepts Copybara export branches" + + TAG="$(extract_body_field "Release tag")" || die "PR body is missing 'Release tag: ...'" + RELEASE_BRANCH="$(extract_body_field "Release branch")" || die "PR body is missing 'Release branch: ...'" + RELEASE_KIND="$(extract_body_field "Release kind" || true)" + RELEASE_KIND="${RELEASE_KIND:-patch}" + + case "${RELEASE_KIND}" in + patch|minor|major) ;; + *) die "Invalid Release kind: ${RELEASE_KIND}" ;; + esac + + resolve_namespace + + [[ "${TAG}" == "${TAG_PREFIX}"* ]] || die "${NAMESPACE} release tags must start with ${TAG_PREFIX}: ${TAG}" + [[ "${RELEASE_BRANCH}" == "${RELEASE_BRANCH_PREFIX}"* ]] || die "${NAMESPACE} release branches must start with ${RELEASE_BRANCH_PREFIX}: ${RELEASE_BRANCH}" + + ensure_valid_ref "refs/tags/${TAG}" + ensure_valid_ref "refs/heads/${RELEASE_BRANCH}" + ensure_valid_ref "refs/heads/${MAIN_BRANCH}" + + VERSION="${TAG:${#TAG_PREFIX}}" + if [[ ! "${VERSION}" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then + die "Public finalization only supports final SemVer tags, got ${TAG}" + fi + + MAJOR="${BASH_REMATCH[1]}" + MINOR="${BASH_REMATCH[2]}" + PATCH="${BASH_REMATCH[3]}" + EXPECTED_RELEASE_BRANCH="${RELEASE_BRANCH_PREFIX}${MAJOR}.${MINOR}" + [[ "${RELEASE_BRANCH}" == "${EXPECTED_RELEASE_BRANCH}" ]] || die "Release branch must be ${EXPECTED_RELEASE_BRANCH}, got ${RELEASE_BRANCH}" + + EFFECTIVE_RELEASE_KIND="${RELEASE_KIND}" + if [[ "${RELEASE_KIND}" == "patch" && "${PATCH}" == "0" ]]; then + EFFECTIVE_RELEASE_KIND="minor" + fi + + case "${EFFECTIVE_RELEASE_KIND}" in + patch) + [[ "${PR_BASE_REF}" == "${RELEASE_BRANCH}" ]] || die "Patch release PRs must target ${RELEASE_BRANCH}, got ${PR_BASE_REF}" + ;; + minor) + [[ "${PATCH}" == "0" ]] || die "Minor release PRs require an X.Y.0 tag, got ${TAG}" + [[ "${PR_BASE_REF}" == "${MAIN_BRANCH}" ]] || die "Minor release PRs must target ${MAIN_BRANCH}, got ${PR_BASE_REF}" + ;; + major) + [[ "${MINOR}" == "0" && "${PATCH}" == "0" ]] || die "Major release PRs require an X.0.0 tag, got ${TAG}" + [[ "${PR_BASE_REF}" == "${MAIN_BRANCH}" ]] || die "Major release PRs must target ${MAIN_BRANCH}, got ${PR_BASE_REF}" + ;; + esac +} + +validate_remote_preconditions() { + if remote_ref_exists "refs/tags/${TAG}"; then + die "Release tag already exists: ${TAG}" + fi + + case "${EFFECTIVE_RELEASE_KIND}" in + patch) + remote_ref_exists "refs/heads/${RELEASE_BRANCH}" || die "Patch release branch does not exist: ${RELEASE_BRANCH}" + ;; + minor|major) + if remote_ref_exists "refs/heads/${RELEASE_BRANCH}"; then + die "Release branch already exists: ${RELEASE_BRANCH}" + fi + ;; + esac +} + +fetch_base_and_target() { + require_env MERGE_COMMIT_SHA + + git fetch --no-tags origin "+refs/heads/${PR_BASE_REF}:refs/remotes/origin/${PR_BASE_REF}" + TARGET_SHA="$(git rev-parse --verify "${MERGE_COMMIT_SHA}^{commit}")" || die "Merge commit does not exist locally: ${MERGE_COMMIT_SHA}" + BASE_SHA="$(git rev-parse --verify "origin/${PR_BASE_REF}^{commit}")" || die "Base branch does not exist locally: ${PR_BASE_REF}" + + if ! git merge-base --is-ancestor "${TARGET_SHA}" "${BASE_SHA}"; then + die "Merge commit ${TARGET_SHA} is not reachable from base branch ${PR_BASE_REF}@${BASE_SHA}" + fi +} + +ensure_release_branch() { + case "${EFFECTIVE_RELEASE_KIND}" in + patch) + remote_ref_exists "refs/heads/${RELEASE_BRANCH}" || die "Patch release branch does not exist: ${RELEASE_BRANCH}" + ;; + minor|major) + local existing_sha + existing_sha="$(remote_ref_sha "refs/heads/${RELEASE_BRANCH}")" + if [[ -n "${existing_sha}" ]]; then + [[ "${existing_sha}" == "${TARGET_SHA}" ]] || die "Release branch ${RELEASE_BRANCH} exists at ${existing_sha}, expected ${TARGET_SHA}" + note "Release branch already exists at ${TARGET_SHA}: ${RELEASE_BRANCH}" + return + fi + + git update-ref "refs/heads/${RELEASE_BRANCH}" "${TARGET_SHA}" + git push origin "refs/heads/${RELEASE_BRANCH}:refs/heads/${RELEASE_BRANCH}" + note "Created release branch ${RELEASE_BRANCH} at ${TARGET_SHA}" + ;; + esac +} + +ensure_release_tag() { + local existing_commit + + if remote_ref_exists "refs/tags/${TAG}"; then + git fetch --no-tags origin "refs/tags/${TAG}:refs/tags/${TAG}" + existing_commit="$(git rev-list -n 1 "${TAG}")" + [[ "${existing_commit}" == "${TARGET_SHA}" ]] || die "Release tag ${TAG} points to ${existing_commit}, expected ${TARGET_SHA}" + note "Release tag already exists at ${TARGET_SHA}: ${TAG}" + return + fi + + git config user.name "${GIT_COMMITTER_NAME:-github-actions[bot]}" + git config user.email "${GIT_COMMITTER_EMAIL:-41898282+github-actions[bot]@users.noreply.github.com}" + git tag -a "${TAG}" "${TARGET_SHA}" -m "Release ${TAG}" + git push origin "refs/tags/${TAG}:refs/tags/${TAG}" + note "Created release tag ${TAG} at ${TARGET_SHA}" +} + +write_outputs() { + [[ -n "${GITHUB_OUTPUT:-}" ]] || return 0 + { + echo "namespace=${NAMESPACE}" + echo "tag=${TAG}" + echo "release_branch=${RELEASE_BRANCH}" + echo "release_kind=${EFFECTIVE_RELEASE_KIND}" + } >> "${GITHUB_OUTPUT}" +} + +main() { + local mode="${1:-}" + case "${mode}" in + validate|finalize) ;; + -h|--help) usage; exit 0 ;; + *) usage; exit 1 ;; + esac + + parse_release_metadata + write_outputs + + if [[ "${mode}" == "validate" ]]; then + validate_remote_preconditions + note "Validated ${NAMESPACE} ${EFFECTIVE_RELEASE_KIND} release ${TAG}" + exit 0 + fi + + fetch_base_and_target + ensure_release_branch + ensure_release_tag +} + +main "$@" diff --git a/.github/workflows/build-docker.yaml b/.github/workflows/build-docker.yaml index 436e512..31bc469 100644 --- a/.github/workflows/build-docker.yaml +++ b/.github/workflows/build-docker.yaml @@ -6,12 +6,21 @@ on: branches: - main - release/* - push: - tags: - - 'v*' + workflow_call: + inputs: + tag: + description: 'Tag to build and publish Docker images for (e.g. v0.6.0)' + required: true + type: string + workflow_dispatch: + inputs: + tag: + description: 'Tag to build and publish Docker images for (e.g. v0.6.0)' + required: true + type: string concurrency: - group: docker-${{ github.ref }} + group: docker-${{ (github.event_name == 'workflow_call' || github.event_name == 'workflow_dispatch') && inputs.tag || github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} env: @@ -21,7 +30,8 @@ env: jobs: build: if: >- - github.event_name == 'push' || + github.event_name == 'workflow_call' || + github.event_name == 'workflow_dispatch' || contains(github.event.pull_request.labels.*.name, 'build-docker') name: Build ${{ matrix.image }} (${{ matrix.platform }}) permissions: @@ -48,13 +58,31 @@ jobs: - name: Checkout uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: + ref: ${{ (github.event_name == 'workflow_call' || github.event_name == 'workflow_dispatch') && format('refs/tags/{0}', inputs.tag) || github.ref }} submodules: recursive - - name: Compute short hash + - name: Resolve image metadata id: vars env: - SHA: ${{ github.sha }} - run: echo "short_hash=${SHA::8}" >> "$GITHUB_OUTPUT" + EVENT_NAME: ${{ github.event_name }} + INPUT_TAG: ${{ inputs.tag }} + run: | + SHA="$(git rev-parse HEAD)" + if [[ "$EVENT_NAME" == "workflow_call" || "$EVENT_NAME" == "workflow_dispatch" ]]; then + RELEASE_TAG="$INPUT_TAG" + else + RELEASE_TAG="" + fi + if [[ -n "$RELEASE_TAG" && ! "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.+-]+)?$ ]]; then + echo "::error::Invalid tag format: '$RELEASE_TAG' (expected e.g. v1.2.3 or v1.2.3-rc.1)" + exit 1 + fi + { + echo "full_hash=${SHA}" + echo "short_hash=${SHA::8}" + echo "git_version=$(git describe --tags --always --dirty 2>/dev/null || echo 'v0.0.0-unknown')" + echo "release_tag=${RELEASE_TAG}" + } >> "$GITHUB_OUTPUT" - name: Set up Docker Buildx uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 @@ -73,8 +101,8 @@ jobs: env: BUILDX_NO_DEFAULT_ATTESTATIONS: 1 GITHUB_TOKEN: ${{ github.token }} - GIT_COMMIT_HASH: ${{ github.sha }} - GIT_VERSION: ${{ github.ref_name }} + GIT_COMMIT_HASH: ${{ steps.vars.outputs.full_hash }} + GIT_VERSION: ${{ steps.vars.outputs.git_version }} GIT_SHORT_HASH: ${{ steps.vars.outputs.short_hash }} - name: Trivy vulnerability scan @@ -95,13 +123,13 @@ jobs: path: trivy-results.sarif - name: Login to Cloudsmith - if: ${{ github.event_name == 'push' }} + if: ${{ github.event_name == 'workflow_call' || github.event_name == 'workflow_dispatch' }} uses: ./.github/actions/cloudsmith-login with: registry: ${{ env.REGISTRY }} - name: Push image by digest - if: ${{ github.event_name == 'push' }} + if: ${{ github.event_name == 'workflow_call' || github.event_name == 'workflow_dispatch' }} env: IMAGE: ${{ env.REGISTRY }}/${{ env.REGISTRY_NAMESPACE }}/${{ matrix.image }} BAKE_METADATA: ${{ steps.build.outputs.metadata }} @@ -124,7 +152,7 @@ jobs: echo "${REGISTRY_DIGEST}" > "/tmp/digests/${MATRIX_IMAGE}/${PLATFORM_SLUG}" - name: Upload digest - if: ${{ github.event_name == 'push' }} + if: ${{ github.event_name == 'workflow_call' || github.event_name == 'workflow_dispatch' }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: digest-${{ matrix.image }}-${{ matrix.arch }} @@ -132,7 +160,7 @@ jobs: if-no-files-found: error manifest: - if: ${{ github.event_name == 'push' }} + if: ${{ github.event_name == 'workflow_call' || github.event_name == 'workflow_dispatch' }} name: Manifest ${{ matrix.image }} permissions: contents: read @@ -171,7 +199,7 @@ jobs: id: manifest env: IMAGE: ${{ env.REGISTRY }}/${{ env.REGISTRY_NAMESPACE }}/${{ matrix.image }} - RELEASE_TAG: ${{ github.ref_name }} + RELEASE_TAG: ${{ inputs.tag }} run: | VERSION="${RELEASE_TAG#v}" TAG="${IMAGE}:${VERSION}" diff --git a/.github/workflows/finalize-release.yaml b/.github/workflows/finalize-release.yaml new file mode 100644 index 0000000..3fd18df --- /dev/null +++ b/.github/workflows/finalize-release.yaml @@ -0,0 +1,102 @@ +name: Finalize Public Release + +on: + pull_request_target: + types: [opened, edited, synchronize, reopened, labeled, unlabeled, closed] + branches: + - main + - test-main + - release/** + - test-release/** + +concurrency: + group: public-release-finalizer-${{ github.event.pull_request.number }} + cancel-in-progress: false + +permissions: + contents: read + pull-requests: read + +jobs: + validate-release-pr: + name: Validate release metadata + if: >- + github.event.action != 'closed' && + contains(github.event.pull_request.labels.*.name, 'release') && + github.event.pull_request.head.repo.full_name == github.repository && + startsWith(github.event.pull_request.head.ref, 'sync/copybara-export-') + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Checkout base branch + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.pull_request.base.ref }} + fetch-depth: 0 + + - name: Validate release metadata + uses: ./.github/actions/finalize-release + with: + mode: validate + release-namespace: auto + pr-body: ${{ github.event.pull_request.body }} + pr-base-ref: ${{ github.event.pull_request.base.ref }} + pr-head-ref: ${{ github.event.pull_request.head.ref }} + + finalize-release: + name: Create release branch and tag + if: >- + github.event.pull_request.merged == true && + contains(github.event.pull_request.labels.*.name, 'release') && + github.event.pull_request.head.repo.full_name == github.repository && + startsWith(github.event.pull_request.head.ref, 'sync/copybara-export-') + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: write + pull-requests: read + outputs: + namespace: ${{ steps.finalize.outputs.namespace }} + tag: ${{ steps.finalize.outputs.tag }} + release_branch: ${{ steps.finalize.outputs.release_branch }} + release_kind: ${{ steps.finalize.outputs.release_kind }} + steps: + - name: Checkout base branch + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.pull_request.base.ref }} + fetch-depth: 0 + + - name: Create release branch and tag + id: finalize + uses: ./.github/actions/finalize-release + with: + mode: finalize + release-namespace: auto + pr-body: ${{ github.event.pull_request.body }} + pr-base-ref: ${{ github.event.pull_request.base.ref }} + pr-head-ref: ${{ github.event.pull_request.head.ref }} + merge-commit-sha: ${{ github.event.pull_request.merge_commit_sha }} + + release-binaries: + name: Release binaries + needs: finalize-release + if: needs.finalize-release.outputs.namespace == 'production' + uses: ./.github/workflows/release-binaries.yaml + with: + tag: ${{ needs.finalize-release.outputs.tag }} + secrets: inherit + permissions: + contents: write + + release-docker: + name: Release Docker images + needs: finalize-release + if: needs.finalize-release.outputs.namespace == 'production' + uses: ./.github/workflows/build-docker.yaml + with: + tag: ${{ needs.finalize-release.outputs.tag }} + permissions: + attestations: write + contents: read + id-token: write diff --git a/.github/workflows/release-binaries.yaml b/.github/workflows/release-binaries.yaml index 80128ed..22c2c4c 100644 --- a/.github/workflows/release-binaries.yaml +++ b/.github/workflows/release-binaries.yaml @@ -1,9 +1,12 @@ name: Release Binaries on: - push: - tags: - - 'v*' + workflow_call: + inputs: + tag: + description: 'Tag to build and release (e.g. v0.6.0)' + required: true + type: string workflow_dispatch: inputs: tag: @@ -44,17 +47,9 @@ jobs: - name: Resolve artifact version id: version env: - EVENT_NAME: ${{ github.event_name }} INPUT_TAG: ${{ inputs.tag }} run: | - if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then - VERSION="$INPUT_TAG" - elif [[ "$GITHUB_REF" == refs/tags/* ]]; then - VERSION="${GITHUB_REF#refs/tags/}" - else - echo "::error::Unsupported release trigger: $EVENT_NAME $GITHUB_REF" - exit 1 - fi + VERSION="$INPUT_TAG" if [[ ! "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.+-]+)?$ ]]; then echo "::error::Invalid tag format: '$VERSION' (expected e.g. v1.2.3 or v1.2.3-rc.1)" exit 1 @@ -64,7 +59,7 @@ jobs: - name: Checkout uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: - ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }} + ref: ${{ format('refs/tags/{0}', inputs.tag) }} submodules: recursive - name: Install Linux system dependencies @@ -165,7 +160,6 @@ jobs: release: name: Create Release needs: sign - if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: @@ -174,17 +168,9 @@ jobs: - name: Resolve tag id: tag env: - EVENT_NAME: ${{ github.event_name }} INPUT_TAG: ${{ inputs.tag }} run: | - if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then - TAG="$INPUT_TAG" - elif [[ "$GITHUB_REF" == refs/tags/* ]]; then - TAG="${GITHUB_REF#refs/tags/}" - else - echo "::error::Unsupported release trigger: $EVENT_NAME $GITHUB_REF" - exit 1 - fi + TAG="$INPUT_TAG" if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.+-]+)?$ ]]; then echo "::error::Invalid tag format: '$TAG' (expected e.g. v1.2.3 or v1.2.3-rc.1)" exit 1