diff --git a/.github/scripts/verify-tag-matches-sha.sh b/.github/scripts/verify-tag-matches-sha.sh new file mode 100755 index 00000000..6610ff39 --- /dev/null +++ b/.github/scripts/verify-tag-matches-sha.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Verifies that a git tag still resolves to the guarded commit SHA that was +# validated by this workflow's guard job, protecting against a force-push/ +# retag race between the image build and any subsequent chart-publish or +# release step. +# +# Required env vars: GH_TOKEN, REPO, TAG, GUARDED_SHA +set -euo pipefail + +ref_json="$(gh api "repos/${REPO}/git/ref/tags/${TAG}" --jq '[.object.type, .object.sha] | @tsv')" +read -r current_type current_sha <<< "$ref_json" +if [[ "$current_type" == "tag" ]]; then + # Annotated tag: the ref's object.sha is the tag object, not the commit - peel it. + current_sha="$(gh api "repos/${REPO}/git/tags/${current_sha}" --jq .object.sha)" +fi + +if [[ "$current_sha" != "$GUARDED_SHA" ]]; then + echo "::error::Tag '${TAG}' now points at ${current_sha}, not the guarded commit ${GUARDED_SHA} (force-pushed?). Refusing to proceed." + exit 1 +fi + +echo "Tag '${TAG}' still points at the guarded commit ${GUARDED_SHA}." diff --git a/.github/workflows/publish-charts.yaml b/.github/workflows/publish-charts.yaml index c0c32b2d..5839d0b0 100644 --- a/.github/workflows/publish-charts.yaml +++ b/.github/workflows/publish-charts.yaml @@ -1,35 +1,81 @@ name: Publish charts on: - push: - tags: - - 'v*' + workflow_run: + workflows: ["Publish container image"] + types: [completed] jobs: + guard: + name: Verify image build succeeded + runs-on: ubuntu-latest + # Read-only job: only inspects workflow_run event metadata, no repo/API access needed. + permissions: {} + if: > + github.event.workflow_run.event == 'push' && + startsWith(github.event.workflow_run.head_branch, 'v') + outputs: + tag: ${{ github.event.workflow_run.head_branch }} + sha: ${{ github.event.workflow_run.head_sha }} + steps: + - name: Check image build result + env: + CONCLUSION: ${{ github.event.workflow_run.conclusion }} + HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} + run: | + # No build-metadata (+...) suffix allowed: this tag is used verbatim as a + # container image tag downstream, and Docker/OCI tags cannot contain '+'. + semver_re='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*)|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)(\.((0|[1-9][0-9]*)|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))*)?$' + if ! [[ "$HEAD_BRANCH" =~ $semver_re ]]; then + echo "::error::Tag '$HEAD_BRANCH' is not a valid semver release tag" + exit 1 + fi + if [[ "$CONCLUSION" != "success" ]]; then + echo "::error::Image build for tag $HEAD_BRANCH did not succeed (conclusion: $CONCLUSION). Refusing to publish chart." + exit 1 + fi + echo "Image build succeeded for tag $HEAD_BRANCH" + publish-ui-chart: name: Publish UI chart + needs: guard runs-on: ubuntu-latest permissions: contents: write packages: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: + ref: ${{ needs.guard.outputs.sha }} fetch-depth: 0 persist-credentials: false + + - name: Verify tag still points at the guarded commit + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ needs.guard.outputs.tag }} + GUARDED_SHA: ${{ needs.guard.outputs.sha }} + REPO: ${{ github.repository }} + run: .github/scripts/verify-tag-matches-sha.sh + - id: run + env: + TAG: ${{ needs.guard.outputs.tag }} + REGISTRY_USERNAME: ${{ github.actor }} + REGISTRY_PASSWORD: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} run: | # Login to the registry: export registry="ghcr.io" - echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login --username "${{ github.actor }}" --password-stdin "${registry}" + echo "${REGISTRY_PASSWORD}" | helm registry login --username "${REGISTRY_USERNAME}" --password-stdin "${registry}" - # Get the git version: - export git_version="$(git describe --tags)" + # Get the git version from the tag that triggered the image build: + export git_version="${TAG}" # Update the image reference in the chart: export app_version="${git_version}" - export app_image="${registry}/${{ github.repository }}:${app_version}" + export app_image="${registry}/${REPO}:${app_version}" yq -i '.images.ui = strenv(app_image)' charts/ui/values.yaml # Package the chart: @@ -41,14 +87,25 @@ jobs: export chart_file="${chart_name}-${chart_version}.tgz" helm push "${chart_file}" "oci://${registry}/${{ github.repository_owner }}/charts" + - name: Verify tag still points at the guarded commit + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ needs.guard.outputs.tag }} + GUARDED_SHA: ${{ needs.guard.outputs.sha }} + REPO: ${{ github.repository }} + run: .github/scripts/verify-tag-matches-sha.sh + - name: Create GitHub Release run: | - gh release create "${GITHUB_REF_NAME}" \ - --repo "${{ github.repository }}" \ - --title "osac-ui ${GITHUB_REF_NAME}" \ + gh release create "${TAG}" \ + --repo "${REPO}" \ + --title "osac-ui ${TAG}" \ --generate-notes \ - || gh release edit "${GITHUB_REF_NAME}" \ - --repo "${{ github.repository }}" \ - --title "osac-ui ${GITHUB_REF_NAME}" + --verify-tag \ + || gh release edit "${TAG}" \ + --repo "${REPO}" \ + --title "osac-ui ${TAG}" env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ needs.guard.outputs.tag }} + REPO: ${{ github.repository }}