From ebd382dc1c37b3a9495bde8a70d430969e5c1ec1 Mon Sep 17 00:00:00 2001 From: Byron Williams Date: Mon, 25 May 2026 19:55:35 -0700 Subject: [PATCH 1/3] fix(ci): SLSA provenance must attest pre-built release artifacts The slsa-provenance.yml workflow was running `uv build` to regenerate dist/ inside the provenance job, then hashing those fresh artifacts. Because Python builds are not bit-for-bit reproducible, the resulting SLSA attestation described files that diverged from what the upstream Semantic Release job actually published to the GitHub Release. The chain of custody was meaningless. Replace the local build with artifact retrieval: - workflow_run trigger: download the `release-dist` GHA artifact from the upstream Semantic Release run (run-id from github.event.workflow_run.id) using actions/download-artifact v8.0.1. - workflow_dispatch trigger: download the wheels and sdists attached to the matching `v` GitHub Release via `gh release download`. The provenance now attests the bytes that were actually shipped, which is the entire point of SLSA L3 supply-chain attestation. Adds a guard that fails the run when dist/ contains neither a wheel nor an sdist so the SLSA generator never emits an empty provenance. Refs: ~/.claude/projects/-home-byron-dev--claude/memory/feedback_slsa_provenance_pattern.md --- .github/workflows/slsa-provenance.yml | 139 +++++++++++++++++--------- 1 file changed, 91 insertions(+), 48 deletions(-) diff --git a/.github/workflows/slsa-provenance.yml b/.github/workflows/slsa-provenance.yml index a8a640e..fbd5d19 100644 --- a/.github/workflows/slsa-provenance.yml +++ b/.github/workflows/slsa-provenance.yml @@ -1,8 +1,21 @@ # SLSA Provenance Generation for RAG Processor # Generates SLSA Level 3 provenance attestations for published packages. # -# This workflow builds the package, generates hashes, and calls the org-level -# SLSA provenance workflow to create cryptographic attestations. +# This workflow downloads pre-built distribution artifacts (never rebuilds +# from source), hashes them, and calls the org-level SLSA provenance +# workflow to create cryptographic attestations. +# +# Why download instead of rebuild? +# SLSA attestation must describe the exact files that were published. If +# the provenance job rebuilt the package locally, the attested hashes +# would diverge from the artifacts attached to the GitHub Release (and +# any future PyPI upload), defeating the supply-chain guarantee. +# +# Artifact sources: +# - workflow_run trigger: downloads the `release-dist` artifact uploaded +# by the upstream "Semantic Release" run that produced this version. +# - workflow_dispatch trigger: downloads the dist files attached to the +# matching GitHub Release (`v` tag) via the GitHub CLI. # # Reference: https://slsa.dev/ name: SLSA Provenance @@ -13,29 +26,28 @@ on: workflows: ["Semantic Release"] types: [completed] branches: [main, master] - # Manual trigger for re-generating provenance + # Manual trigger for re-generating provenance against an existing release workflow_dispatch: inputs: version: - description: 'Version to generate provenance for (e.g., 0.1.0)' + description: 'Released version to generate provenance for (e.g., 0.1.0)' required: true type: string -permissions: - contents: write - id-token: write - actions: read - attestations: write +permissions: {} jobs: # ========================================================================== - # Build and Generate Hashes + # Download Pre-Built Artifacts and Generate Hashes # ========================================================================== - build: - name: Build Package + collect: + name: Collect Release Artifacts runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 15 if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }} + permissions: + contents: read + actions: read outputs: hashes: ${{ steps.hashes.outputs.hashes }} version: ${{ steps.version.outputs.version }} @@ -46,64 +58,95 @@ jobs: with: egress-policy: audit # TODO: switch to block after 2026-06-30 (SLSA L3 hermetic build isolation) - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + # workflow_run path: the upstream Semantic Release job uploads dist/ + # as the `release-dist` artifact (5-day retention). Pull it directly + # so the hashes attest the published bytes, not a fresh local build. + - name: Download release-dist from upstream Semantic Release run + if: ${{ github.event_name == 'workflow_run' }} + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - fetch-depth: 0 - - - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 - with: - python-version: "3.12" + name: release-dist + path: dist/ + github-token: ${{ github.token }} + repository: ${{ github.repository }} + run-id: ${{ github.event.workflow_run.id }} - - name: Install UV - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 - with: - enable-cache: true + # workflow_dispatch path: a release was published earlier and the + # GHA artifact retention may have lapsed. Download the dist files + # that were attached to the GitHub Release for the requested tag. + - name: Download dist from GitHub Release + if: ${{ github.event_name == 'workflow_dispatch' }} + env: + GH_TOKEN: ${{ github.token }} + INPUT_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + if ! [[ "$INPUT_VERSION" =~ ^[A-Za-z0-9._+-]+$ ]]; then + echo "::error::Invalid version input: must match ^[A-Za-z0-9._+-]+$" + exit 1 + fi + TAG="v$INPUT_VERSION" + mkdir -p dist + # Pull wheels and sdists attached to the release. + gh release download "$TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --dir dist \ + --pattern '*.whl' \ + --pattern '*.tar.gz' - name: Determine version id: version env: - INPUT_VERSION: ${{ github.event.inputs.version }} + EVENT_NAME: ${{ github.event_name }} + INPUT_VERSION: ${{ inputs.version }} run: | - if [ -n "$INPUT_VERSION" ]; then + set -euo pipefail + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then VERSION="$INPUT_VERSION" else - VERSION=$(grep -Po '(?<=^version = ")[^"]*' pyproject.toml) + # Extract version from the wheel filename produced upstream. + WHEEL=$(find dist -maxdepth 1 -name '*.whl' -print -quit) + if [ -z "$WHEEL" ]; then + echo "::error::No wheel found in downloaded dist/ artifact" + find dist -maxdepth 1 -type f -printf '%p\n' || true + exit 1 + fi + BASENAME=$(basename "$WHEEL") + VERSION=$(echo "$BASENAME" | awk -F'-' '{print $2}') fi - echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "version=$VERSION" >> "$GITHUB_OUTPUT" - - name: Build package - run: uv build + - name: Verify dist contents + run: | + set -euo pipefail + echo "Files attested by this provenance run:" + find dist -maxdepth 1 -type f -printf '%f %s bytes\n' + # Refuse to attest an empty directory; SLSA generator would + # otherwise emit a meaningless provenance. + WHEEL=$(find dist -maxdepth 1 -name '*.whl' -print -quit) + SDIST=$(find dist -maxdepth 1 -name '*.tar.gz' -print -quit) + if [ -z "$WHEEL" ] && [ -z "$SDIST" ]; then + echo "::error::No wheel or sdist found in dist/; nothing to attest" + exit 1 + fi - name: Generate SHA256 hashes id: hashes + working-directory: dist run: | - cd dist - HASHES=$(sha256sum * | base64 -w0) - echo "hashes=$HASHES" >> $GITHUB_OUTPUT - - - name: Upload build artifacts - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: dist-${{ steps.version.outputs.version }} - path: dist/ - retention-days: 90 - - - name: Generate artifact attestation - uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 - with: - subject-path: 'dist/*' + set -euo pipefail + HASHES=$(sha256sum ./* | base64 -w0) + echo "hashes=$HASHES" >> "$GITHUB_OUTPUT" # ========================================================================== # SLSA Level 3 Provenance (Org-Level Reusable Workflow) # ========================================================================== slsa: name: SLSA Level 3 - needs: [build] + needs: [collect] uses: ByronWilliamsCPA/.github/.github/workflows/python-slsa.yml@961eb17d8e9b7fe0d8bfc5dbe9d23c824484fb11 # main with: - base64-subjects: ${{ needs.build.outputs.hashes }} + base64-subjects: ${{ needs.collect.outputs.hashes }} upload-assets: true permissions: id-token: write From 1f4e9daf1841ac54ac3f68a77eb7be30a0701fed Mon Sep 17 00:00:00 2001 From: Byron Williams Date: Mon, 25 May 2026 20:03:27 -0700 Subject: [PATCH 2/3] fix(ci): inline SLSA generator; org python-slsa.yml is not callable The previous revision still routed through `ByronWilliamsCPA/.github/.github/workflows/python-slsa.yml`, which cannot work as a reusable workflow: 1. The template defines only `on: workflow_dispatch:`, so it has no `workflow_call:` entry-point and `uses:` cannot invoke it. 2. The template itself calls `slsa-framework/slsa-github-generator/ .github/workflows/generator_generic_slsa3.yml`, which is a reusable workflow. GitHub Actions forbids nested reusable workflow calls. 3. The template's own header docs say: "This is a TEMPLATE, not a reusable workflow ... You must copy the 'provenance' job directly into your release workflow." CI on PR #54 still went green because the PR validation gates only exercise workflow file syntax; the actual `workflow_run` -> SLSA provenance flow fires only after a real Semantic Release run and therefore was never exercised on the PR. Switch to the inline pattern used by the working exemplar `ByronWilliamsCPA/homelab-infra` PR #435: - `hash` job downloads the upstream `release-dist` artifact via `actions/download-artifact@v8.0.1` using `github.event.workflow_run.id` (or a `run_id` dispatch input). - Hashes are computed only over `*.whl` and `*.tar.gz` and sorted for determinism before base64 encoding. - A tag resolver picks the tag created by Semantic Release at the head SHA, falling back to the most recent release. - `provenance` job calls the official SLSA generator `slsa-framework/slsa-github-generator/.github/workflows/ generator_generic_slsa3.yml@v2.1.0` directly with `base64-subjects`, `upload-assets: true`, and `upload-tag-name: ${{ needs.hash.outputs.tag }}`. The dispatch input changes from `version` (string) to `run_id` (integer) to match the artifact-download model: provenance is now keyed off a specific Semantic Release run, not a version string. Refs: ByronWilliamsCPA/homelab-infra#435 --- .github/workflows/slsa-provenance.yml | 188 +++++++++++++------------- 1 file changed, 97 insertions(+), 91 deletions(-) diff --git a/.github/workflows/slsa-provenance.yml b/.github/workflows/slsa-provenance.yml index fbd5d19..3cab1ef 100644 --- a/.github/workflows/slsa-provenance.yml +++ b/.github/workflows/slsa-provenance.yml @@ -1,154 +1,160 @@ # SLSA Provenance Generation for RAG Processor # Generates SLSA Level 3 provenance attestations for published packages. # -# This workflow downloads pre-built distribution artifacts (never rebuilds -# from source), hashes them, and calls the org-level SLSA provenance -# workflow to create cryptographic attestations. +# This workflow downloads the dist artifacts produced by the upstream +# Semantic Release run, computes their SHA256 hashes, and calls the +# official SLSA Level 3 generator inline. The org-level python-slsa.yml +# is a TEMPLATE (workflow_dispatch only, not workflow_call), so the +# generator must be invoked directly here. # # Why download instead of rebuild? -# SLSA attestation must describe the exact files that were published. If -# the provenance job rebuilt the package locally, the attested hashes -# would diverge from the artifacts attached to the GitHub Release (and -# any future PyPI upload), defeating the supply-chain guarantee. +# SLSA provenance must attest to the exact bytes that were published. +# Locally rebuilt artifacts can differ from the published bytes due to +# non-deterministic builds, defeating the purpose of the attestation. +# The Semantic Release workflow uploads `release-dist` (dist/*.whl and +# *.tar.gz), so we hash those. # -# Artifact sources: -# - workflow_run trigger: downloads the `release-dist` artifact uploaded -# by the upstream "Semantic Release" run that produced this version. -# - workflow_dispatch trigger: downloads the dist files attached to the -# matching GitHub Release (`v` tag) via the GitHub CLI. +# Why inline instead of `uses: BWCPA/.github/.github/workflows/python-slsa.yml`? +# The org template has `on: workflow_dispatch:` only (no workflow_call), +# and even if it did, the official SLSA generator it calls is itself a +# reusable workflow. GitHub Actions forbids nested reusable workflow +# calls, so the generator must be invoked from this caller directly. # # Reference: https://slsa.dev/ name: SLSA Provenance on: - # Trigger after successful release + # Trigger after successful release run workflow_run: workflows: ["Semantic Release"] types: [completed] branches: [main, master] - # Manual trigger for re-generating provenance against an existing release + # Manual trigger uses a specific Semantic Release run id workflow_dispatch: inputs: - version: - description: 'Released version to generate provenance for (e.g., 0.1.0)' - required: true + run_id: + description: 'Semantic Release run ID whose dist artifact to attest' + required: false type: string -permissions: {} +permissions: + contents: read jobs: # ========================================================================== - # Download Pre-Built Artifacts and Generate Hashes + # Hash the published dist artifacts (download, do not rebuild) # ========================================================================== - collect: - name: Collect Release Artifacts + hash: + name: Hash Published Artifacts runs-on: ubuntu-latest - timeout-minutes: 15 - if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }} + # Only attest successful releases (skip cancelled, skipped, or failed upstream runs) + if: >- + ${{ github.event_name == 'workflow_dispatch' + || github.event.workflow_run.conclusion == 'success' }} permissions: contents: read actions: read outputs: hashes: ${{ steps.hashes.outputs.hashes }} - version: ${{ steps.version.outputs.version }} - + tag: ${{ steps.tag.outputs.tag }} steps: - name: Harden the runner uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit # TODO: switch to block after 2026-06-30 (SLSA L3 hermetic build isolation) - # workflow_run path: the upstream Semantic Release job uploads dist/ - # as the `release-dist` artifact (5-day retention). Pull it directly - # so the hashes attest the published bytes, not a fresh local build. - - name: Download release-dist from upstream Semantic Release run - if: ${{ github.event_name == 'workflow_run' }} + - name: Resolve source run id + id: source_run + env: + DISPATCH_RUN_ID: ${{ github.event.inputs.run_id }} + TRIGGER_RUN_ID: ${{ github.event.workflow_run.id }} + run: | + set -euo pipefail + if [ -n "${DISPATCH_RUN_ID:-}" ]; then + if ! [[ "$DISPATCH_RUN_ID" =~ ^[0-9]+$ ]]; then + echo "::error::run_id must be a positive integer" + exit 1 + fi + RUN_ID="$DISPATCH_RUN_ID" + elif [ -n "${TRIGGER_RUN_ID:-}" ]; then + RUN_ID="$TRIGGER_RUN_ID" + else + echo "::error::No upstream run id available; provide run_id input" + exit 1 + fi + echo "run_id=$RUN_ID" >> "$GITHUB_OUTPUT" + + - name: Download release-dist artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: release-dist - path: dist/ + path: dist + run-id: ${{ steps.source_run.outputs.run_id }} github-token: ${{ github.token }} repository: ${{ github.repository }} - run-id: ${{ github.event.workflow_run.id }} - # workflow_dispatch path: a release was published earlier and the - # GHA artifact retention may have lapsed. Download the dist files - # that were attached to the GitHub Release for the requested tag. - - name: Download dist from GitHub Release - if: ${{ github.event_name == 'workflow_dispatch' }} - env: - GH_TOKEN: ${{ github.token }} - INPUT_VERSION: ${{ inputs.version }} + - name: List downloaded artifacts run: | set -euo pipefail - if ! [[ "$INPUT_VERSION" =~ ^[A-Za-z0-9._+-]+$ ]]; then - echo "::error::Invalid version input: must match ^[A-Za-z0-9._+-]+$" + ls -lh dist/ + if [ -z "$(ls -A dist/ 2>/dev/null)" ]; then + echo "::error::dist/ is empty after download; upstream release-dist artifact missing" exit 1 fi - TAG="v$INPUT_VERSION" - mkdir -p dist - # Pull wheels and sdists attached to the release. - gh release download "$TAG" \ - --repo "$GITHUB_REPOSITORY" \ - --dir dist \ - --pattern '*.whl' \ - --pattern '*.tar.gz' - - name: Determine version - id: version + - name: Resolve release tag + id: tag env: - EVENT_NAME: ${{ github.event_name }} - INPUT_VERSION: ${{ inputs.version }} + HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} + HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + GH_TOKEN: ${{ github.token }} run: | set -euo pipefail - if [ "$EVENT_NAME" = "workflow_dispatch" ]; then - VERSION="$INPUT_VERSION" - else - # Extract version from the wheel filename produced upstream. - WHEEL=$(find dist -maxdepth 1 -name '*.whl' -print -quit) - if [ -z "$WHEEL" ]; then - echo "::error::No wheel found in downloaded dist/ artifact" - find dist -maxdepth 1 -type f -printf '%p\n' || true - exit 1 - fi - BASENAME=$(basename "$WHEEL") - VERSION=$(echo "$BASENAME" | awk -F'-' '{print $2}') + # Prefer the tag created by Semantic Release at the head SHA; + # fall back to the latest published release. + TAG="" + if [ -n "${HEAD_SHA:-}" ]; then + TAG=$(gh api "repos/${GITHUB_REPOSITORY}/releases" \ + --jq ".[] | select(.target_commitish==\"${HEAD_BRANCH:-main}\" or .target_commitish==\"${HEAD_SHA}\") | .tag_name" \ + 2>/dev/null | head -1 || true) fi - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - - - name: Verify dist contents - run: | - set -euo pipefail - echo "Files attested by this provenance run:" - find dist -maxdepth 1 -type f -printf '%f %s bytes\n' - # Refuse to attest an empty directory; SLSA generator would - # otherwise emit a meaningless provenance. - WHEEL=$(find dist -maxdepth 1 -name '*.whl' -print -quit) - SDIST=$(find dist -maxdepth 1 -name '*.tar.gz' -print -quit) - if [ -z "$WHEEL" ] && [ -z "$SDIST" ]; then - echo "::error::No wheel or sdist found in dist/; nothing to attest" - exit 1 + if [ -z "$TAG" ]; then + TAG=$(gh release list --repo "${GITHUB_REPOSITORY}" --limit 1 --json tagName --jq '.[0].tagName' 2>/dev/null || true) + fi + if [ -z "$TAG" ]; then + echo "::warning::Could not resolve release tag; provenance will not attach to a release" fi + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" - name: Generate SHA256 hashes id: hashes - working-directory: dist run: | set -euo pipefail - HASHES=$(sha256sum ./* | base64 -w0) + cd dist + # Hash only release distribution files (wheel, sdist). + # Sort for determinism; base64 -w0 for single-line output. + HASHES=$(sha256sum -- *.whl *.tar.gz 2>/dev/null | sort | base64 -w0) + if [ -z "$HASHES" ]; then + echo "::error::No .whl or .tar.gz files in dist/ to hash" + exit 1 + fi echo "hashes=$HASHES" >> "$GITHUB_OUTPUT" # ========================================================================== - # SLSA Level 3 Provenance (Org-Level Reusable Workflow) + # SLSA Level 3 Provenance (inline; the official generator is itself a + # reusable workflow and GitHub Actions forbids nested reusable calls, + # so this MUST live in the caller workflow, not in a wrapper.) # ========================================================================== - slsa: - name: SLSA Level 3 - needs: [collect] - uses: ByronWilliamsCPA/.github/.github/workflows/python-slsa.yml@961eb17d8e9b7fe0d8bfc5dbe9d23c824484fb11 # main - with: - base64-subjects: ${{ needs.collect.outputs.hashes }} - upload-assets: true + provenance: + name: Generate SLSA Provenance + needs: [hash] permissions: + actions: read id-token: write contents: write - actions: read + uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@f7dd8c54c2067bafc12ca7a55595d5ee9b75204a # v2.1.0 + with: + base64-subjects: ${{ needs.hash.outputs.hashes }} + upload-assets: true + upload-tag-name: ${{ needs.hash.outputs.tag }} + provenance-name: multiple.intoto.jsonl From e8eefc29db987783d29dad62c095248e6303d7c2 Mon Sep 17 00:00:00 2001 From: Byron Williams Date: Mon, 25 May 2026 20:17:12 -0700 Subject: [PATCH 3/3] fix(ci): harden SLSA provenance workflow_dispatch trust boundary Two review-driven hardenings to slsa-provenance.yml: 1. Verify that the supplied run_id belongs to a successful Semantic Release workflow run before downloading and attesting its artifacts. The workflow_run trigger filter already restricts the automated path, but the workflow_dispatch path accepted any positive integer and would attest whatever release-dist artifact existed on that run. A user with Actions:write could mint a fraudulent SLSA attestation describing substitute bytes. 2. Pass HEAD_BRANCH and HEAD_SHA to the jq filter via --arg rather than shell interpolation into the filter string. Not exploitable today (workflow_run constrains both values), but --arg is the standard pattern and removes the entire injection class. Refs PR #54 review (SEC-005 Important, SEC-007 Suggested). Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/slsa-provenance.yml | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/.github/workflows/slsa-provenance.yml b/.github/workflows/slsa-provenance.yml index 3cab1ef..8499f30 100644 --- a/.github/workflows/slsa-provenance.yml +++ b/.github/workflows/slsa-provenance.yml @@ -68,6 +68,7 @@ jobs: env: DISPATCH_RUN_ID: ${{ github.event.inputs.run_id }} TRIGGER_RUN_ID: ${{ github.event.workflow_run.id }} + GH_TOKEN: ${{ github.token }} run: | set -euo pipefail if [ -n "${DISPATCH_RUN_ID:-}" ]; then @@ -82,6 +83,22 @@ jobs: echo "::error::No upstream run id available; provide run_id input" exit 1 fi + # Verify the run actually belongs to the Semantic Release workflow. + # Without this guard, any user with Actions:write could pass a + # run_id from a different workflow that happens to have a + # release-dist artifact and mint a fraudulent SLSA attestation. + # The workflow_run trigger filter already constrains the + # automated path; this closes the workflow_dispatch path. + RUN_NAME=$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${RUN_ID}" --jq '.name') + RUN_CONCLUSION=$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${RUN_ID}" --jq '.conclusion') + if [ "$RUN_NAME" != "Semantic Release" ]; then + echo "::error::Run $RUN_ID is from workflow '$RUN_NAME', not 'Semantic Release'; refusing to attest" + exit 1 + fi + if [ "$RUN_CONCLUSION" != "success" ]; then + echo "::error::Run $RUN_ID conclusion is '$RUN_CONCLUSION', not 'success'; refusing to attest" + exit 1 + fi echo "run_id=$RUN_ID" >> "$GITHUB_OUTPUT" - name: Download release-dist artifact @@ -112,11 +129,15 @@ jobs: set -euo pipefail # Prefer the tag created by Semantic Release at the head SHA; # fall back to the latest published release. + # Inputs flow through jq --arg rather than shell interpolation + # so the jq filter cannot be malformed by an unexpected character + # in head_branch or head_sha. TAG="" if [ -n "${HEAD_SHA:-}" ]; then TAG=$(gh api "repos/${GITHUB_REPOSITORY}/releases" \ - --jq ".[] | select(.target_commitish==\"${HEAD_BRANCH:-main}\" or .target_commitish==\"${HEAD_SHA}\") | .tag_name" \ - 2>/dev/null | head -1 || true) + | jq -r --arg b "${HEAD_BRANCH:-main}" --arg s "${HEAD_SHA}" \ + '.[] | select(.target_commitish==$b or .target_commitish==$s) | .tag_name' \ + 2>/dev/null | head -1 || true) fi if [ -z "$TAG" ]; then TAG=$(gh release list --repo "${GITHUB_REPOSITORY}" --limit 1 --json tagName --jq '.[0].tagName' 2>/dev/null || true)