From f21dad89687892f14aac23b8c03402c8b64cce56 Mon Sep 17 00:00:00 2001 From: Min Zhang Date: Thu, 9 Jul 2026 11:49:50 -0400 Subject: [PATCH 1/5] OSAC-2185: gate publish-charts on image build success via workflow_run Trigger publish-charts.yaml via workflow_run on the sibling image workflow completing instead of independently on push:tags:v*. A new guard job checks the image build concluded successfully for the same tag before any chart publishing proceeds, and fails loudly if it did not. Assisted-by: Cursor --- .github/workflows/publish-charts.yaml | 41 +++++++++++++++++++++------ 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/.github/workflows/publish-charts.yaml b/.github/workflows/publish-charts.yaml index c0c32b2d..82668286 100644 --- a/.github/workflows/publish-charts.yaml +++ b/.github/workflows/publish-charts.yaml @@ -1,14 +1,33 @@ 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 + 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 + run: | + if [[ "${{ github.event.workflow_run.conclusion }}" != "success" ]]; then + echo "::error::Image build for tag ${{ github.event.workflow_run.head_branch }} did not succeed (conclusion: ${{ github.event.workflow_run.conclusion }}). Refusing to publish chart." + exit 1 + fi + echo "Image build succeeded for tag ${{ github.event.workflow_run.head_branch }}" + publish-ui-chart: name: Publish UI chart + needs: guard runs-on: ubuntu-latest permissions: contents: write @@ -16,16 +35,19 @@ jobs: steps: - uses: actions/checkout@v4 with: + ref: ${{ needs.guard.outputs.sha }} fetch-depth: 0 persist-credentials: false - id: run + env: + TAG: ${{ needs.guard.outputs.tag }} run: | # Login to the registry: export registry="ghcr.io" echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login --username "${{ github.actor }}" --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}" @@ -43,12 +65,13 @@ jobs: - name: Create GitHub Release run: | - gh release create "${GITHUB_REF_NAME}" \ + gh release create "${TAG}" \ --repo "${{ github.repository }}" \ - --title "osac-ui ${GITHUB_REF_NAME}" \ + --title "osac-ui ${TAG}" \ --generate-notes \ - || gh release edit "${GITHUB_REF_NAME}" \ + || gh release edit "${TAG}" \ --repo "${{ github.repository }}" \ - --title "osac-ui ${GITHUB_REF_NAME}" + --title "osac-ui ${TAG}" env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ needs.guard.outputs.tag }} From 1aed9cd718bfd8d653a6b6254746086ac6bc5d20 Mon Sep 17 00:00:00 2001 From: Min Zhang Date: Tue, 14 Jul 2026 18:59:47 -0400 Subject: [PATCH 2/5] OSAC-2185: address CodeRabbit review feedback - Scope guard job to permissions: {} (was inheriting default token scope) - Pass workflow_run.head_branch/conclusion through env instead of direct ${{ }} interpolation in shell (script-injection hardening) - Validate the tag against a proper semver grammar in guard, not just startsWith(..., 'v') - Pin actions/checkout to a full SHA (v4.2.2) instead of the mutable v4 tag - Verify the tag still resolves to the guarded commit immediately before creating the GitHub Release, guarding against a force-push/retag race Note: not adding Sigstore/cosign chart signing here - that's a separate, larger effort beyond this ticket's scope, flagged for a follow-up if desired. Assisted-by: Cursor --- .github/workflows/publish-charts.yaml | 30 +++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/.github/workflows/publish-charts.yaml b/.github/workflows/publish-charts.yaml index 82668286..b636d814 100644 --- a/.github/workflows/publish-charts.yaml +++ b/.github/workflows/publish-charts.yaml @@ -10,6 +10,8 @@ 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') @@ -18,12 +20,20 @@ jobs: 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: | - if [[ "${{ github.event.workflow_run.conclusion }}" != "success" ]]; then - echo "::error::Image build for tag ${{ github.event.workflow_run.head_branch }} did not succeed (conclusion: ${{ github.event.workflow_run.conclusion }}). Refusing to publish chart." + semver_re='^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z]+(-[0-9A-Za-z]+)*(\.[0-9A-Za-z]+(-[0-9A-Za-z]+)*)*)?(\+[0-9A-Za-z]+(-[0-9A-Za-z]+)*(\.[0-9A-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 ${{ github.event.workflow_run.head_branch }}" + echo "Image build succeeded for tag $HEAD_BRANCH" publish-ui-chart: name: Publish UI chart @@ -33,7 +43,7 @@ jobs: 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 @@ -63,6 +73,18 @@ 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 }} + run: | + CURRENT_SHA="$(gh api "repos/${{ github.repository }}/git/refs/tags/${TAG}" --jq .object.sha)" + 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 create a release." + exit 1 + fi + - name: Create GitHub Release run: | gh release create "${TAG}" \ From c0f34b3d8235d83c7dd41ca15eff79ff6d067423 Mon Sep 17 00:00:00 2001 From: Min Zhang Date: Tue, 14 Jul 2026 19:29:37 -0400 Subject: [PATCH 3/5] OSAC-2185: address round 2 of CodeRabbit review feedback - Extract the tag-vs-guarded-commit verification into a shared .github/scripts/verify-tag-matches-sha.sh instead of duplicating the bash logic at each checkpoint - Dereference annotated tags before comparing SHAs (the ref lookup returns the tag-object SHA, not the commit SHA, for annotated tags) - Add --verify-tag to gh release create as an extra safety net - Reject SemVer tags with leading zeros (e.g. v01.2.3, v1.2.3-01) The early (post-checkout) and late (pre-release) verification checkpoints were already both present from the previous round; this just removes the duplicated bash between them. Assisted-by: Cursor --- .github/scripts/verify-tag-matches-sha.sh | 21 +++++++++++++++++++++ .github/workflows/publish-charts.yaml | 20 +++++++++++++------- 2 files changed, 34 insertions(+), 7 deletions(-) create mode 100755 .github/scripts/verify-tag-matches-sha.sh diff --git a/.github/scripts/verify-tag-matches-sha.sh b/.github/scripts/verify-tag-matches-sha.sh new file mode 100755 index 00000000..f24bf426 --- /dev/null +++ b/.github/scripts/verify-tag-matches-sha.sh @@ -0,0 +1,21 @@ +#!/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 + +read -r current_type current_sha <<< "$(gh api "repos/${REPO}/git/refs/tags/${TAG}" --jq '[.object.type, .object.sha] | @tsv')" +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 b636d814..e5a5bbe6 100644 --- a/.github/workflows/publish-charts.yaml +++ b/.github/workflows/publish-charts.yaml @@ -24,7 +24,7 @@ jobs: CONCLUSION: ${{ github.event.workflow_run.conclusion }} HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} run: | - semver_re='^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z]+(-[0-9A-Za-z]+)*(\.[0-9A-Za-z]+(-[0-9A-Za-z]+)*)*)?(\+[0-9A-Za-z]+(-[0-9A-Za-z]+)*(\.[0-9A-Za-z]+(-[0-9A-Za-z]+)*)*)?$' + 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-]*))*)?(\+[0-9A-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 @@ -48,6 +48,15 @@ jobs: 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 }} @@ -78,12 +87,8 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAG: ${{ needs.guard.outputs.tag }} GUARDED_SHA: ${{ needs.guard.outputs.sha }} - run: | - CURRENT_SHA="$(gh api "repos/${{ github.repository }}/git/refs/tags/${TAG}" --jq .object.sha)" - 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 create a release." - exit 1 - fi + REPO: ${{ github.repository }} + run: .github/scripts/verify-tag-matches-sha.sh - name: Create GitHub Release run: | @@ -91,6 +96,7 @@ jobs: --repo "${{ github.repository }}" \ --title "osac-ui ${TAG}" \ --generate-notes \ + --verify-tag \ || gh release edit "${TAG}" \ --repo "${{ github.repository }}" \ --title "osac-ui ${TAG}" From c0c5e3048edf8caae1b7117913e6f3fd2fb2a3f8 Mon Sep 17 00:00:00 2001 From: Min Zhang Date: Tue, 14 Jul 2026 22:02:49 -0400 Subject: [PATCH 4/5] OSAC-2185: address round 3 of CodeRabbit review feedback - Reject SemVer tags with build metadata (+...) - Docker/OCI tags cannot contain '+', so a tag like v1.2.3+build.1 would pass the old regex but produce an unusable image reference downstream - Route github.actor/secrets.GITHUB_TOKEN/github.repository through env in the chart-packaging step instead of splicing them directly into the run: shell (script-injection hardening, missed in this step during the earlier rounds even though it was applied elsewhere) - Same for the repo slug in the Create GitHub Release step Assisted-by: Cursor --- .github/workflows/publish-charts.yaml | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/.github/workflows/publish-charts.yaml b/.github/workflows/publish-charts.yaml index e5a5bbe6..5839d0b0 100644 --- a/.github/workflows/publish-charts.yaml +++ b/.github/workflows/publish-charts.yaml @@ -24,7 +24,9 @@ jobs: CONCLUSION: ${{ github.event.workflow_run.conclusion }} HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} run: | - 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-]*))*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$' + # 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 @@ -60,17 +62,20 @@ jobs: - 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 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: @@ -93,13 +98,14 @@ jobs: - name: Create GitHub Release run: | gh release create "${TAG}" \ - --repo "${{ github.repository }}" \ + --repo "${REPO}" \ --title "osac-ui ${TAG}" \ --generate-notes \ --verify-tag \ || gh release edit "${TAG}" \ - --repo "${{ github.repository }}" \ + --repo "${REPO}" \ --title "osac-ui ${TAG}" env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAG: ${{ needs.guard.outputs.tag }} + REPO: ${{ github.repository }} From 9ed2244a8373914172a94a4bf38e039ce486ed8f Mon Sep 17 00:00:00 2001 From: Min Zhang Date: Tue, 14 Jul 2026 22:42:34 -0400 Subject: [PATCH 5/5] OSAC-2185: address round 4 of CodeRabbit review feedback - In the shared verify-tag-matches-sha.sh: capture the gh api response in a variable before parsing with read (a failing API call under set -e was previously masked by read's own exit status, falling through to a generic tag-mismatch error instead of surfacing the real failure) - Switch to the officially documented singular git/ref/{ref} endpoint for retrieval instead of the plural git/refs/{ref} form (only documented for PATCH/DELETE) - both return identical data today, but only the documented one is guaranteed to keep doing so Assisted-by: Cursor --- .github/scripts/verify-tag-matches-sha.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/scripts/verify-tag-matches-sha.sh b/.github/scripts/verify-tag-matches-sha.sh index f24bf426..6610ff39 100755 --- a/.github/scripts/verify-tag-matches-sha.sh +++ b/.github/scripts/verify-tag-matches-sha.sh @@ -7,7 +7,8 @@ # Required env vars: GH_TOKEN, REPO, TAG, GUARDED_SHA set -euo pipefail -read -r current_type current_sha <<< "$(gh api "repos/${REPO}/git/refs/tags/${TAG}" --jq '[.object.type, .object.sha] | @tsv')" +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)"