From 438d7dbb99b69a01b6c8e31340c0985ba06fe1ee Mon Sep 17 00:00:00 2001 From: Markus Waldheim Date: Mon, 3 Aug 2026 12:47:18 +0200 Subject: [PATCH 1/3] feat(release): harden template delivery workflows Pin release and validation actions, validate immutable OCI promotion, fix registry dispatch authentication, keep template sync DCO-compliant, and remove the unsupported setup-go prerelease input. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Markus Waldheim --- .github/workflows/ci.yml | 30 +- .github/workflows/release.yml | 624 +++++++++++++++++++++++++- .github/workflows/security.yml | 12 +- .github/workflows/semrel-release.yaml | 120 +++-- .github/workflows/sync-template.yml | 35 +- cmd/plugin/main.go | 4 +- 6 files changed, 743 insertions(+), 82 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 273702f..e6e67d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,10 @@ on: permissions: contents: read +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: ci: name: Build, test, lint, and scan @@ -21,43 +25,44 @@ jobs: steps: - name: Check out repository - uses: actions/checkout@v7 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: ${{ matrix.go == '1.27rc1' && '1.27.0-rc.1' || format('{0}.x', matrix.go) }} - allow-prereleases: true cache: true - name: Download dependencies run: go mod download - name: Run golangci-lint - continue-on-error: true - uses: golangci/golangci-lint-action@v9 + if: matrix.go != '1.27rc1' + uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 with: version: v2.12.2 - name: Run tests - run: go test -v -coverprofile=coverage.txt ./... + run: go test -race -v -coverprofile=coverage.txt ./... - name: Show coverage summary run: go tool cover -func=coverage.txt | tail -1 - name: Upload coverage report if: matrix.go == '1.26' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: coverage-report path: coverage.txt retention-days: 30 + if-no-files-found: error - name: Build plugin binary run: go build -v ./cmd/plugin - name: Run Trivy filesystem scan - uses: aquasecurity/trivy-action@v0.36.0 + if: matrix.go == '1.26' + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: scan-type: fs scan-ref: . @@ -65,3 +70,12 @@ jobs: exit-code: '1' ignore-unfixed: true severity: CRITICAL,HIGH + + actionlint: + name: Validate GitHub Actions + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - name: Run actionlint + uses: reviewdog/action-actionlint@6fb7acc99f4a1008869fa8a0f09cfca740837d9d # v1.72.0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a78e125..3addaed 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,14 +5,113 @@ on: tags: - 'v*.*.*' workflow_dispatch: + inputs: + version: + description: Release version (SemVer 2.0, optional v; may be omitted on a tag ref) + required: false + type: string permissions: - contents: write + contents: read jobs: + version: + name: Validate release version + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + version_short: ${{ steps.version.outputs.version_short }} + image_version: ${{ steps.version.outputs.image_version }} + exact_key: ${{ steps.version.outputs.exact_key }} + tag: ${{ steps.version.outputs.tag }} + stable: ${{ steps.version.outputs.stable }} + image: ${{ steps.version.outputs.image }} + build_date: ${{ steps.source.outputs.build_date }} + source_sha: ${{ steps.source.outputs.source_sha }} + steps: + - name: Resolve and validate version + id: version + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + INPUT_VERSION: ${{ inputs.version }} + REF_NAME: ${{ github.ref_name }} + REF_TYPE: ${{ github.ref_type }} + run: | + set -euo pipefail + if [[ "${EVENT_NAME}" == "push" ]]; then + candidate="${REF_NAME}" + elif [[ -n "${INPUT_VERSION}" ]]; then + candidate="${INPUT_VERSION}" + elif [[ "${REF_TYPE}" == "tag" ]]; then + candidate="${REF_NAME}" + else + printf 'A version is required unless workflow_dispatch uses a tag ref.\n' >&2 + exit 1 + fi + + if [[ "${candidate}" == *$'\r'* || "${candidate}" == *$'\n'* ]]; then + printf 'Version must not contain CR or LF characters.\n' >&2 + exit 1 + fi + + semver_re='^v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$' + if [[ ! "${candidate}" =~ ${semver_re} ]]; then + printf 'Version must be valid SemVer 2.0 with an optional leading v.\n' >&2 + exit 1 + fi + + version_short="${candidate#v}" + without_build="${version_short%%+*}" + if [[ "${without_build}" == *-* ]]; then + prerelease="${without_build#*-}" + IFS='.' read -r -a identifiers <<< "${prerelease}" + for identifier in "${identifiers[@]}"; do + if [[ "${identifier}" =~ ^0[0-9]+$ ]]; then + printf 'Numeric prerelease identifiers must not contain leading zeros.\n' >&2 + exit 1 + fi + done + fi + + stable=true + if [[ "${without_build}" == *-* ]]; then + stable=false + fi + { + printf 'version=v%s\n' "${version_short}" + printf 'version_short=%s\n' "${version_short}" + # SemVer forbids underscores, so replacing '+' is collision-free and Docker-safe. + printf 'image_version=%s\n' "${version_short//+/_}" + # Actions concurrency groups are case-insensitive; hash the case-sensitive exact SemVer. + printf 'exact_key=%s\n' "$(printf '%s' "${version_short}" | sha256sum | cut -d' ' -f1)" + printf 'tag=v%s\n' "${version_short}" + printf 'stable=%s\n' "${stable}" + printf 'image=ghcr.io/%s\n' "${GITHUB_REPOSITORY,,}" + } >> "${GITHUB_OUTPUT}" + + - name: Check out exact release source + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ steps.version.outputs.tag }} + + - name: Resolve reproducible source metadata + id: source + shell: bash + run: | + set -euo pipefail + source_sha=$(git rev-parse HEAD) + source_epoch=$(git show -s --format=%ct HEAD) + [[ "${source_sha}" =~ ^[0-9a-f]{40}$ ]] + [[ "${source_epoch}" =~ ^[0-9]+$ ]] + { + printf 'source_sha=%s\n' "${source_sha}" + printf 'build_date=%s\n' "$(date -u -d "@${source_epoch}" +%Y-%m-%dT%H:%M:%SZ)" + } >> "${GITHUB_OUTPUT}" + build: - if: ${{ startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' }} name: Build ${{ matrix.goos }}-${{ matrix.goarch }} + needs: version runs-on: ubuntu-latest strategy: fail-fast: false @@ -38,10 +137,12 @@ jobs: ext: '.exe' steps: - name: Check out repository - uses: actions/checkout@v7 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ needs.version.outputs.source_sha }} - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version-file: go.mod cache: true @@ -57,41 +158,320 @@ jobs: mkdir -p dist binary="plugin-${GOOS}-${GOARCH}${{ matrix.ext }}" go build -trimpath -ldflags='-s -w' -o "dist/${binary}" ./cmd/plugin - sha256sum "dist/${binary}" > "dist/${binary}.sha256" + (cd dist && sha256sum "${binary}" > "${binary}.sha256") - name: Upload release artifacts - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: release-${{ matrix.goos }}-${{ matrix.goarch }} path: dist/* if-no-files-found: error + docker-validate: + name: Validate image (${{ matrix.platform.arch }}) + needs: version + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + platform: + - name: linux/amd64 + arch: amd64 + - name: linux/arm64 + arch: arm64 + steps: + - name: Check out repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ needs.version.outputs.source_sha }} + + - name: Set up QEMU + uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0 + with: + platforms: arm64 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + + - name: Build immutable OCI validation archive + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: . + platforms: ${{ matrix.platform.name }} + outputs: type=oci,dest=${{ runner.temp }}/plugin-image-${{ matrix.platform.arch }}.tar + tags: validation:${{ matrix.platform.arch }} + labels: | + org.opencontainers.image.created=${{ needs.version.outputs.build_date }} + org.opencontainers.image.revision=${{ needs.version.outputs.source_sha }} + org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} + org.opencontainers.image.version=${{ needs.version.outputs.version }} + build-args: | + VERSION=${{ needs.version.outputs.version }} + BUILD_DATE=${{ needs.version.outputs.build_date }} + VCS_REF=${{ needs.version.outputs.source_sha }} + cache-from: type=gha,scope=validation-${{ matrix.platform.arch }} + cache-to: type=gha,mode=max,scope=validation-${{ matrix.platform.arch }} + provenance: true + sbom: true + + - name: Record validation archive checksum + shell: bash + env: + ARCH: ${{ matrix.platform.arch }} + run: | + set -euo pipefail + cd "${RUNNER_TEMP}" + sha256sum "plugin-image-${ARCH}.tar" > "plugin-image-${ARCH}.tar.sha256" + + - name: Extract immutable OCI layout for scanning + id: extract + shell: bash + env: + ARCH: ${{ matrix.platform.arch }} + run: | + set -euo pipefail + scan_dir=$(mktemp -d "${RUNNER_TEMP}/plugin-image-${ARCH}-layout.XXXXXX") + tar -xf "${RUNNER_TEMP}/plugin-image-${ARCH}.tar" -C "${scan_dir}" + test -f "${scan_dir}/index.json" + test -f "${scan_dir}/oci-layout" + printf 'path=%s\n' "${scan_dir}" >> "${GITHUB_OUTPUT}" + + - name: Scan immutable extracted OCI layout + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + env: + TRIVY_PLATFORM: ${{ matrix.platform.name }} + with: + input: ${{ steps.extract.outputs.path }} + format: table + exit-code: '1' + ignore-unfixed: true + severity: CRITICAL,HIGH + + - name: Upload scanned OCI validation archive + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: plugin-image-${{ matrix.platform.arch }} + path: | + ${{ runner.temp }}/plugin-image-${{ matrix.platform.arch }}.tar + ${{ runner.temp }}/plugin-image-${{ matrix.platform.arch }}.tar.sha256 + if-no-files-found: error + compression-level: 0 + retention-days: 1 + + docker: + name: Publish validated multiarch image + needs: [version, build, docker-validate] + runs-on: ubuntu-latest + concurrency: + group: exact-image-${{ needs.version.outputs.exact_key }} + cancel-in-progress: false + permissions: + contents: read + packages: write + id-token: write + attestations: write + steps: + - name: Download scanned OCI validation archives + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: plugin-image-* + path: ${{ runner.temp }}/docker-images + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + + - name: Install ORAS + uses: oras-project/setup-oras@1d808f7d7f6995cc68b7bf507bfe5c5446e1dc9d # v2.0.1 + with: + version: 1.3.1 + + - name: Install cosign + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@06fb636fac595d6fb4b28a5dfcb21a6f5091859c # v4.5.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Calculate final image tags + id: meta + uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0 + with: + images: ${{ needs.version.outputs.image }} + flavor: latest=false + tags: | + type=raw,value=${{ needs.version.outputs.image_version }} + + - name: Promote scanned platform images + id: promote + shell: bash + env: + ARCHIVE_DIR: ${{ runner.temp }}/docker-images + IMAGE: ${{ needs.version.outputs.image }} + run: | + set -euo pipefail + cd "${ARCHIVE_DIR}" + test "$(find . -maxdepth 1 -type f -name 'plugin-image-*.tar' | wc -l)" -eq 2 + test "$(find . -maxdepth 1 -type f -name 'plugin-image-*.tar.sha256' | wc -l)" -eq 2 + sha256sum --check -- plugin-image-*.tar.sha256 + for arch in amd64 arm64; do + archive="plugin-image-${arch}.tar" + source_digest=$(oras resolve --oci-layout "${archive}:${arch}") + platform_digest=$(oras resolve --platform "linux/${arch}" --oci-layout "${archive}:${arch}") + [[ "${source_digest}" =~ ^sha256:[0-9a-f]{64}$ ]] + [[ "${platform_digest}" =~ ^sha256:[0-9a-f]{64}$ ]] + # Registry deletion is manifest-scoped, so retain this run-scoped tag to protect the final index children. + staging_tag="scan-${GITHUB_SHA:0:12}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${arch}" + oras cp --from-oci-layout "${archive}:${arch}" "${IMAGE}:${staging_tag}" + pushed_digest=$(oras resolve "${IMAGE}:${staging_tag}") + pushed_platform_digest=$(oras resolve --platform "linux/${arch}" "${IMAGE}:${staging_tag}") + [[ "${pushed_digest}" == "${source_digest}" ]] + [[ "${pushed_platform_digest}" == "${platform_digest}" ]] + printf '%s=%s\n' "${arch}" "${pushed_digest}" >> "${GITHUB_OUTPUT}" + printf '%s_image=%s\n' "${arch}" "${platform_digest}" >> "${GITHUB_OUTPUT}" + done + + - name: Create final multiarch manifest from scanned digests + id: manifest + shell: bash + env: + AMD64_DIGEST: ${{ steps.promote.outputs.amd64 }} + AMD64_IMAGE_DIGEST: ${{ steps.promote.outputs.amd64_image }} + ARM64_DIGEST: ${{ steps.promote.outputs.arm64 }} + ARM64_IMAGE_DIGEST: ${{ steps.promote.outputs.arm64_image }} + IMAGE: ${{ needs.version.outputs.image }} + TAGS: ${{ steps.meta.outputs.tags }} + run: | + set -euo pipefail + resolve_registry_ref() { + local ref=$1 + local attempts=4 + local attempt output status + for ((attempt = 1; attempt <= attempts; attempt++)); do + if output=$(oras resolve "${ref}" 2>&1); then + if [[ ! "${output}" =~ ^sha256:[0-9a-f]{64}$ ]]; then + printf 'Registry returned an invalid digest for %s: %s\n' "${ref}" "${output}" >&2 + return 1 + fi + printf '%s\n' "${output}" + return 0 + else + status=$? + fi + if ((attempt < attempts)); then + sleep "$((attempt * 2))" + fi + done + if grep -Eiq '(^|[^0-9])(401|403|408|409|425|429|5[0-9][0-9])([^0-9]|$)|unauthorized|denied|forbidden|too many requests|timed? out|connection|tls|eof|server error|service unavailable|temporar' <<< "${output}"; then + printf 'Registry authorization or transient failure resolving %s after %d attempts (exit %d): %s\n' \ + "${ref}" "${attempts}" "${status}" "${output}" >&2 + return 1 + fi + if grep -Eiq '(^|[^0-9])404([^0-9]|$)|manifest[ _]unknown|name[ _]unknown|(^|[^[:alpha:]])not found([^[:alpha:]]|$)' <<< "${output}"; then + return 4 + fi + printf 'Non-definitive registry failure resolving %s after %d attempts (exit %d): %s\n' \ + "${ref}" "${attempts}" "${status}" "${output}" >&2 + return 1 + } + [[ "${AMD64_DIGEST}" =~ ^sha256:[0-9a-f]{64}$ ]] + [[ "${AMD64_IMAGE_DIGEST}" =~ ^sha256:[0-9a-f]{64}$ ]] + [[ "${ARM64_DIGEST}" =~ ^sha256:[0-9a-f]{64}$ ]] + [[ "${ARM64_IMAGE_DIGEST}" =~ ^sha256:[0-9a-f]{64}$ ]] + mapfile -t final_tags < <(printf '%s\n' "${TAGS}" | sed '/^[[:space:]]*$/d') + ((${#final_tags[@]} == 1)) + exact_tag="${final_tags[0]}" + [[ "${exact_tag}" == "${IMAGE}:"* ]] + candidate_tag="${IMAGE}:manifest-${GITHUB_SHA:0:12}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + docker buildx imagetools create \ + --tag "${candidate_tag}" \ + "${IMAGE}@${AMD64_DIGEST}" \ + "${IMAGE}@${ARM64_DIGEST}" + if ! digest=$(resolve_registry_ref "${candidate_tag}"); then + printf 'Candidate manifest %s could not be resolved; refusing publication.\n' "${candidate_tag}" >&2 + exit 1 + fi + if existing_digest=$(resolve_registry_ref "${exact_tag}"); then + if [[ "${existing_digest}" != "${digest}" ]]; then + printf 'Refusing to mutate immutable exact tag %s (%s != %s).\n' \ + "${exact_tag}" "${existing_digest}" "${digest}" >&2 + exit 1 + fi + else + resolve_status=$? + if ((resolve_status != 4)); then + exit "${resolve_status}" + fi + oras tag "${IMAGE}@${digest}" "${exact_tag#"${IMAGE}:"}" + fi + if ! published_digest=$(resolve_registry_ref "${exact_tag}"); then + printf 'Exact tag %s could not be verified after publication.\n' "${exact_tag}" >&2 + exit 1 + fi + if [[ "${published_digest}" != "${digest}" ]]; then + printf 'Refusing exact-tag conflict for %s (%s != %s).\n' \ + "${exact_tag}" "${published_digest}" "${digest}" >&2 + exit 1 + fi + docker buildx imagetools inspect --raw "${exact_tag}" | + jq -e \ + --arg amd64 "${AMD64_IMAGE_DIGEST}" \ + --arg arm64 "${ARM64_IMAGE_DIGEST}" \ + 'any(.manifests[]; .digest == $amd64 and .platform.os == "linux" and .platform.architecture == "amd64") and + any(.manifests[]; .digest == $arm64 and .platform.os == "linux" and .platform.architecture == "arm64")' \ + >/dev/null + printf 'digest=%s\n' "${digest}" >> "${GITHUB_OUTPUT}" + + - name: Sign published image digest + shell: bash + env: + COSIGN_EXPERIMENTAL: '1' + DIGEST: ${{ steps.manifest.outputs.digest }} + IMAGE: ${{ needs.version.outputs.image }} + run: cosign sign --yes "${IMAGE}@${DIGEST}" + publish: - if: ${{ startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' }} - name: Publish GitHub Release - needs: build + name: Publish GitHub release and notify registry + needs: [version, build, docker] runs-on: ubuntu-latest + permissions: + contents: write steps: - name: Download release artifacts - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: + pattern: release-* path: dist merge-multiple: true - - name: Generate checksum manifest + - name: Validate artifacts and generate checksum manifest shell: bash run: | set -euo pipefail - find dist -maxdepth 1 -type f ! -name '*.sha256' -printf '%f\n' | sort | while read -r file; do - sha256sum "dist/${file}" - done > dist/checksums.txt + test "$(find dist -maxdepth 1 -type f ! -name '*.sha256' | wc -l)" -eq 6 + test "$(find dist -maxdepth 1 -type f -name '*.sha256' | wc -l)" -eq 6 + (cd dist && sha256sum --check -- *.sha256) + ( + cd dist + find . -maxdepth 1 -type f ! -name '*.sha256' ! -name 'checksums.txt' -printf '%f\n' | sort | while read -r file; do + sha256sum "${file}" + done + ) > "${RUNNER_TEMP}/checksums.txt" + mv "${RUNNER_TEMP}/checksums.txt" dist/checksums.txt - - name: Create GitHub Release - uses: softprops/action-gh-release@v3 + - name: Create GitHub release + uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1 with: + tag_name: ${{ needs.version.outputs.tag }} + target_commitish: ${{ needs.version.outputs.source_sha }} files: dist/* generate_release_notes: true - name: ${{ github.ref_name }} + name: ${{ needs.version.outputs.tag }} + prerelease: ${{ needs.version.outputs.stable != 'true' }} + make_latest: ${{ needs.version.outputs.stable == 'true' }} - name: Trigger registry sync shell: bash @@ -100,10 +480,10 @@ jobs: REGISTRY_SYNC_WEBHOOK: ${{ secrets.REGISTRY_SYNC_WEBHOOK }} PLUGIN_OWNER: ${{ github.repository_owner }} PLUGIN_REPO: ${{ github.event.repository.name }} - PLUGIN_TAG: ${{ github.ref_name }} + PLUGIN_TAG: ${{ needs.version.outputs.tag }} run: | set -euo pipefail - if [ -n "${REGISTRY_SYNC_TOKEN}" ]; then + if [[ -n "${REGISTRY_SYNC_TOKEN}" ]]; then payload=$(printf '{"event_type":"sync-plugins","client_payload":{"source_owner":"%s","source_repo":"%s","tag":"%s"}}' "${PLUGIN_OWNER}" "${PLUGIN_REPO}" "${PLUGIN_TAG}") curl --fail --silent --show-error \ -X POST \ @@ -112,7 +492,7 @@ jobs: -H "X-GitHub-Api-Version: 2022-11-28" \ https://api.github.com/repos/SemRels/semrel-registry/dispatches \ -d "${payload}" - elif [ -n "${REGISTRY_SYNC_WEBHOOK}" ]; then + elif [[ -n "${REGISTRY_SYNC_WEBHOOK}" ]]; then payload=$(printf '{"repository":"%s","owner":"%s","tag":"%s"}' "${PLUGIN_REPO}" "${PLUGIN_OWNER}" "${PLUGIN_TAG}") curl --fail --silent --show-error \ -X POST \ @@ -122,3 +502,207 @@ jobs: else echo "No registry sync secret configured; skipping dispatch." fi + + reconcile: + name: Reconcile mutable image tags + needs: [version, docker, publish] + if: needs.version.outputs.stable == 'true' + runs-on: ubuntu-latest + concurrency: + group: mutable-image-tags + cancel-in-progress: false + permissions: + contents: read + packages: write + steps: + - name: Install ORAS + uses: oras-project/setup-oras@1d808f7d7f6995cc68b7bf507bfe5c5446e1dc9d # v2.0.1 + with: + version: 1.3.1 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@06fb636fac595d6fb4b28a5dfcb21a6f5091859c # v4.5.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Select globally greatest published stable versions + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + IMAGE: ${{ needs.version.outputs.image }} + run: | + set -euo pipefail + release_tags="${RUNNER_TEMP}/published-release-tags.txt" + resolved_images="${RUNNER_TEMP}/resolved-exact-images.tsv" + desired="${RUNNER_TEMP}/desired-mutable-tags.tsv" + gh api --paginate --jq \ + '.[] | select(.draft == false and .published_at != null) | .tag_name' \ + "repos/${GITHUB_REPOSITORY}/releases?per_page=100" > "${release_tags}" + : > "${resolved_images}" + resolve_registry_ref() { + local ref=$1 + local attempts=4 + local attempt output status + for ((attempt = 1; attempt <= attempts; attempt++)); do + if output=$(oras resolve "${ref}" 2>&1); then + if [[ ! "${output}" =~ ^sha256:[0-9a-f]{64}$ ]]; then + printf 'Registry returned an invalid digest for %s: %s\n' "${ref}" "${output}" >&2 + return 1 + fi + printf '%s\n' "${output}" + return 0 + else + status=$? + fi + if ((attempt < attempts)); then + sleep "$((attempt * 2))" + fi + done + if grep -Eiq '(^|[^0-9])(401|403|408|409|425|429|5[0-9][0-9])([^0-9]|$)|unauthorized|denied|forbidden|too many requests|timed? out|connection|tls|eof|server error|service unavailable|temporar' <<< "${output}"; then + printf 'Registry authorization or transient failure resolving %s after %d attempts (exit %d): %s\n' \ + "${ref}" "${attempts}" "${status}" "${output}" >&2 + return 1 + fi + if grep -Eiq '(^|[^0-9])404([^0-9]|$)|manifest[ _]unknown|name[ _]unknown|(^|[^[:alpha:]])not found([^[:alpha:]]|$)' <<< "${output}"; then + return 4 + fi + printf 'Non-definitive registry failure resolving %s after %d attempts (exit %d): %s\n' \ + "${ref}" "${attempts}" "${status}" "${output}" >&2 + return 1 + } + semver_re='^v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$' + while IFS= read -r published_tag; do + [[ "${published_tag}" != *$'\r'* && "${published_tag}" != *$'\n'* ]] || continue + [[ "${published_tag}" =~ ${semver_re} ]] || continue + normalized="${published_tag#v}" + without_build="${normalized%%+*}" + [[ "${without_build}" != *-* ]] || continue + exact="${normalized//+/_}" + if digest=$(resolve_registry_ref "${IMAGE}:${exact}"); then + printf '%s\t%s\n' "${exact}" "${digest}" >> "${resolved_images}" + else + resolve_status=$? + if ((resolve_status != 4)); then + printf 'Aborting reconcile before alias mutation because %s:%s was not definitively absent.\n' \ + "${IMAGE}" "${exact}" >&2 + exit "${resolve_status}" + fi + fi + done < "${release_tags}" + python3 - "${release_tags}" "${resolved_images}" "${desired}" <<'PY' + import re + import sys + from collections import defaultdict + + release_path, resolved_path, output_path = sys.argv[1:] + semver = re.compile( + r"^v?(?P0|[1-9][0-9]*)\." + r"(?P0|[1-9][0-9]*)\." + r"(?P0|[1-9][0-9]*)" + r"(?:-(?P
[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?"
+              r"(?:\+(?P[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$"
+          )
+          image_digests = {}
+          for raw in open(resolved_path, encoding="utf-8"):
+              exact, digest = raw.rstrip("\n").split("\t")
+              if not re.fullmatch(r"sha256:[0-9a-f]{64}", digest):
+                  raise SystemExit(f"invalid resolved digest for {exact}")
+              image_digests[exact] = digest
+          candidates = {}
+          for raw in open(release_path, encoding="utf-8"):
+              raw = raw.rstrip("\n")
+              match = semver.fullmatch(raw)
+              if not match:
+                  continue
+              prerelease = match.group("pre")
+              if prerelease:
+                  if any(part.isdigit() and len(part) > 1 and part[0] == "0"
+                         for part in prerelease.split(".")):
+                      continue
+                  continue
+              normalized = raw[1:] if raw.startswith("v") else raw
+              exact = normalized.replace("+", "_")
+              if exact not in image_digests:
+                  continue
+              candidates[normalized] = (
+                  int(match.group("major")),
+                  int(match.group("minor")),
+                  int(match.group("patch")),
+                  normalized,
+                  exact,
+                  image_digests[exact],
+              )
+
+          if not candidates:
+              raise SystemExit("no published stable SemVer release has an exact image tag")
+
+          # Build metadata is absent from the precedence tuple. The normalized
+          # full version is the stable ASCII tie-break for equal precedence.
+          def winner(values):
+              return max(values, key=lambda item: (item[0], item[1], item[2], item[3]))
+
+          values = list(candidates.values())
+          by_major = defaultdict(list)
+          by_minor = defaultdict(list)
+          for item in values:
+              by_major[item[0]].append(item)
+              by_minor[(item[0], item[1])].append(item)
+
+          desired = [("latest", winner(values))]
+          desired.extend(
+              (str(major), winner(group))
+              for major, group in sorted(by_major.items())
+          )
+          desired.extend(
+              (f"{major}.{minor}", winner(group))
+              for (major, minor), group in sorted(by_minor.items())
+          )
+          with open(output_path, "w", encoding="utf-8", newline="\n") as output:
+              for alias, item in desired:
+                  output.write(f"{alias}\t{item[4]}\t{item[3]}\t{item[5]}\n")
+          PY
+          test -s "${desired}"
+
+      - name: Point mutable tags at selected immutable digests
+        shell: bash
+        env:
+          IMAGE: ${{ needs.version.outputs.image }}
+        run: |
+          set -euo pipefail
+          resolve_registry_ref() {
+            local ref=$1
+            local attempts=4
+            local attempt output status
+            for ((attempt = 1; attempt <= attempts; attempt++)); do
+              if output=$(oras resolve "${ref}" 2>&1); then
+                if [[ ! "${output}" =~ ^sha256:[0-9a-f]{64}$ ]]; then
+                  printf 'Registry returned an invalid digest for %s: %s\n' "${ref}" "${output}" >&2
+                  return 1
+                fi
+                printf '%s\n' "${output}"
+                return 0
+              else
+                status=$?
+              fi
+              if ((attempt < attempts)); then
+                sleep "$((attempt * 2))"
+              fi
+            done
+            printf 'Unable to verify mutable tag %s after %d attempts (exit %d): %s\n' \
+              "${ref}" "${attempts}" "${status}" "${output}" >&2
+            return 1
+          }
+          desired="${RUNNER_TEMP}/desired-mutable-tags.tsv"
+          while IFS=$'\t' read -r alias exact version digest; do
+            [[ "${alias}" == "latest" || "${alias}" =~ ^(0|[1-9][0-9]*)(\.(0|[1-9][0-9]*))?$ ]]
+            [[ "${exact}" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(_[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$ ]]
+            [[ "${digest}" =~ ^sha256:[0-9a-f]{64}$ ]]
+            oras tag "${IMAGE}@${digest}" "${alias}"
+            if ! alias_digest=$(resolve_registry_ref "${IMAGE}:${alias}"); then
+              exit 1
+            fi
+            [[ "${alias_digest}" == "${digest}" ]]
+            printf '%s -> %s (%s)\n' "${alias}" "${exact}" "${version}" >> "${GITHUB_STEP_SUMMARY}"
+          done < "${desired}"
diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml
index 5d653a3..380e412 100644
--- a/.github/workflows/security.yml
+++ b/.github/workflows/security.yml
@@ -11,6 +11,10 @@ on:
 permissions:
   contents: read
 
+concurrency:
+  group: security-${{ github.workflow }}-${{ github.ref }}
+  cancel-in-progress: true
+
 jobs:
   dependency-review:
     if: ${{ github.event_name == 'pull_request' }}
@@ -18,15 +22,15 @@ jobs:
     runs-on: ubuntu-latest
     steps:
       - name: Check out repository
-        uses: actions/checkout@v7
+        uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
       - name: Review dependency changes
-        uses: actions/dependency-review-action@v5
+        uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
 
   reuse:
     name: REUSE compliance
     runs-on: ubuntu-latest
     steps:
       - name: Check out repository
-        uses: actions/checkout@v7
+        uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
       - name: Run REUSE check
-        uses: fsfe/reuse-action@v6
+        uses: fsfe/reuse-action@676e2d560c9a403aa252096d99fcab3e1132b0f5 # v6.0.0
diff --git a/.github/workflows/semrel-release.yaml b/.github/workflows/semrel-release.yaml
index b8f9a9c..5a16848 100644
--- a/.github/workflows/semrel-release.yaml
+++ b/.github/workflows/semrel-release.yaml
@@ -2,9 +2,7 @@
 # SPDX-FileCopyrightText: 2026 The plugin-template Authors
 #
 # semrel-release — uses semrel to version this plugin.
-# Flow: push to main → semrel creates tag → release.yml builds binaries + publishes release
-#
-# condition-github-actions is built from source until its binary releases are stable.
+# Flow: push to main → semrel creates tag → release.yml validates and publishes it.
 name: Semrel Release
 
 on:
@@ -13,7 +11,9 @@ on:
   workflow_dispatch:
 
 concurrency:
-  group: semrel-release
+  # Serialize version calculation only. Exact release.yml runs have no shared
+  # concurrency group, so a dispatched version can never occupy this slot.
+  group: semrel-orchestrator-${{ github.ref }}
   cancel-in-progress: false
 
 permissions:
@@ -26,7 +26,7 @@ jobs:
     runs-on: ubuntu-latest
     outputs:
       released: ${{ steps.semrel.outputs.released }}
-      tag:      ${{ steps.semrel.outputs.tag }}
+      tag: ${{ steps.semrel.outputs.tag }}
     steps:
       - name: Check out repository
         uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
@@ -35,7 +35,7 @@ jobs:
           token: ${{ secrets.GITHUB_TOKEN }}
 
       - name: Set up Go
-        uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
+        uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
         with:
           go-version: stable
           cache: false
@@ -45,65 +45,105 @@ jobs:
           GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
         run: |
           set -euo pipefail
-          VERSION=$(gh release view --repo SemRels/semrel --json tagName -q .tagName)
-          gh release download "$VERSION" --repo SemRels/semrel \
-            --pattern "semrel_*_linux_amd64.tar.gz" --dir /tmp
-          mkdir -p /tmp/semrel-dl
-          tar -xzf /tmp/semrel_*_linux_amd64.tar.gz -C /tmp/semrel-dl
-          mv /tmp/semrel-dl/semrel /tmp/semrel
-          chmod +x /tmp/semrel
-          echo "semrel $(/tmp/semrel --version 2>/dev/null || /tmp/semrel version 2>/dev/null || echo installed)"
+          version=$(gh release view --repo SemRels/semrel --json tagName -q .tagName)
+          gh release download "${version}" --repo SemRels/semrel \
+            --pattern "semrel_*_linux_amd64.tar.gz" --dir "${RUNNER_TEMP}"
+          mkdir -p "${RUNNER_TEMP}/semrel-dl"
+          tar -xzf "${RUNNER_TEMP}"/semrel_*_linux_amd64.tar.gz -C "${RUNNER_TEMP}/semrel-dl"
+          mv "${RUNNER_TEMP}/semrel-dl/semrel" "${RUNNER_TEMP}/semrel"
+          chmod +x "${RUNNER_TEMP}/semrel"
+          "${RUNNER_TEMP}/semrel" version
 
       - name: Install plugins from source
-        # Build condition-github-actions from source (main branch) until binary
-        # releases use the subprocess interface expected by semrel's plugin runner.
-        # See: semrel/.semrel.yaml comment about plugin sourcing strategy.
         run: |
           set -euo pipefail
-          PLUGIN_DIR="$HOME/.semrel/plugins"
-          mkdir -p "$PLUGIN_DIR"
-          go install github.com/SemRels/condition-github-actions/cmd/plugin@main
-          mv "$(go env GOPATH)/bin/plugin" "$PLUGIN_DIR/semrel-plugin-condition-github-actions"
-          echo "Installed plugins:"
-          ls -la "$PLUGIN_DIR"
+          plugin_dir="${HOME}/.semrel/plugins"
+          mkdir -p "${plugin_dir}"
+          go install github.com/SemRels/condition-github-actions/cmd/plugin@v0.2.2
+          mv "$(go env GOPATH)/bin/plugin" "${plugin_dir}/semrel-plugin-condition-github-actions"
 
       - name: Configure git identity
         run: |
-          git config user.name  "semrel-bot"
+          git config user.name "semrel-bot"
           git config user.email "semrel-bot@users.noreply.github.com"
 
       - name: Run semrel release
         id: semrel
-        run: /tmp/semrel release --github-output
         env:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+        run: '"${RUNNER_TEMP}/semrel" release --github-output'
 
       - name: Push tag to GitHub
         if: steps.semrel.outputs.released == 'true'
+        env:
+          TAG: ${{ steps.semrel.outputs.tag }}
         run: |
-          TAG="${{ steps.semrel.outputs.tag }}"
-          if git ls-remote --tags origin "$TAG" | grep -q "$TAG"; then
-            echo "Tag $TAG already exists on remote — skipping (idempotent)."
+          set -euo pipefail
+          if [[ "${TAG}" == *$'\r'* || "${TAG}" == *$'\n'* ]]; then
+            printf 'Tag must not contain CR or LF characters.\n' >&2
+            exit 1
+          fi
+          semver_re='^v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'
+          [[ "${TAG}" =~ ${semver_re} ]] || {
+            printf 'semrel returned an invalid SemVer tag: %s\n' "${TAG}" >&2
+            exit 1
+          }
+          without_build="${TAG#v}"
+          without_build="${without_build%%+*}"
+          if [[ "${without_build}" == *-* ]]; then
+            IFS='.' read -r -a identifiers <<< "${without_build#*-}"
+            for identifier in "${identifiers[@]}"; do
+              [[ ! "${identifier}" =~ ^0[0-9]+$ ]] || {
+                printf 'Numeric prerelease identifiers must not contain leading zeros.\n' >&2
+                exit 1
+              }
+            done
+          fi
+          if git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/dev/null 2>&1; then
+            echo "Tag ${TAG} already exists on remote; skipping."
           else
-            git push origin "refs/tags/$TAG"
+            git push origin "refs/tags/${TAG}"
           fi
-        env:
-          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
 
-      - name: Trigger binary build
+      - name: Trigger validated binary and image release
         if: steps.semrel.outputs.released == 'true'
-        run: |
-          TAG="${{ steps.semrel.outputs.tag }}"
-          echo "Triggering release.yml for tag $TAG"
-          gh workflow run release.yml --ref "$TAG" --repo "${{ github.repository }}"
         env:
           GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          TAG: ${{ steps.semrel.outputs.tag }}
+        run: |
+          set -euo pipefail
+          if [[ "${TAG}" == *$'\r'* || "${TAG}" == *$'\n'* ]]; then
+            printf 'Tag must not contain CR or LF characters.\n' >&2
+            exit 1
+          fi
+          semver_re='^v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'
+          [[ "${TAG}" =~ ${semver_re} ]] || {
+            printf 'semrel returned an invalid SemVer tag: %s\n' "${TAG}" >&2
+            exit 1
+          }
+          without_build="${TAG#v}"
+          without_build="${without_build%%+*}"
+          if [[ "${without_build}" == *-* ]]; then
+            IFS='.' read -r -a identifiers <<< "${without_build#*-}"
+            for identifier in "${identifiers[@]}"; do
+              [[ ! "${identifier}" =~ ^0[0-9]+$ ]] || {
+                printf 'Numeric prerelease identifiers must not contain leading zeros.\n' >&2
+                exit 1
+              }
+            done
+          fi
+          echo "Triggering release.yml for tag ${TAG}"
+          gh workflow run release.yml --ref "${TAG}" --repo "${GITHUB_REPOSITORY}" \
+            --field "version=${TAG}"
 
       - name: Summary
+        env:
+          BUMP: ${{ steps.semrel.outputs.bump }}
+          RELEASED: ${{ steps.semrel.outputs.released }}
+          TAG: ${{ steps.semrel.outputs.tag }}
         run: |
-          if [ "${{ steps.semrel.outputs.released }}" = "true" ]; then
-            echo "### Released ${{ steps.semrel.outputs.tag }} 🚀" >> $GITHUB_STEP_SUMMARY
-            echo "**Bump:** ${{ steps.semrel.outputs.bump }}" >> $GITHUB_STEP_SUMMARY
+          if [[ "${RELEASED}" == "true" ]]; then
+            printf '### Released %s 🚀\n**Bump:** %s\n' "${TAG}" "${BUMP}" >> "${GITHUB_STEP_SUMMARY}"
           else
-            echo "### No release — no releasable commits found." >> $GITHUB_STEP_SUMMARY
+            echo "### No release — no releasable commits found." >> "${GITHUB_STEP_SUMMARY}"
           fi
diff --git a/.github/workflows/sync-template.yml b/.github/workflows/sync-template.yml
index 7eefdbe..6158dbb 100644
--- a/.github/workflows/sync-template.yml
+++ b/.github/workflows/sync-template.yml
@@ -4,13 +4,16 @@ on:
   push:
     branches: [main]
     paths:
-      - Dockerfile
       - Makefile
       - CODE_OF_CONDUCT.md
       - CONTRIBUTING.md
       - GOVERNANCE.md
       - SECURITY.md
       - MAINTAINERS.md
+      - .github/workflows/ci.yml
+      - .github/workflows/release.yml
+      - .github/workflows/semrel-release.yaml
+      - .github/workflows/security.yml
   workflow_dispatch:
     inputs:
       dry_run:
@@ -25,6 +28,10 @@ on:
 permissions:
   contents: read
 
+concurrency:
+  group: template-sync-${{ github.ref }}
+  cancel-in-progress: true
+
 jobs:
   sync:
     name: "Sync ${{ matrix.plugin }}"
@@ -36,6 +43,8 @@ jobs:
         plugin:
           - analyzer-conventional
           - analyzer-default
+          - condition-bitbucket-pipelines
+          - condition-circleci
           - condition-generic
           - condition-gitea-actions
           - condition-github-actions
@@ -43,18 +52,27 @@ jobs:
           - generator-changelog-html
           - generator-changelog-md
           - generator-release-notes
+          - hook-discord
           - hook-email
           - hook-gitplugin
           - hook-jira
           - hook-matrix
           - hook-slack
           - hook-teams
+          - packager-nfpm
           - provider-bitbucket
           - provider-git
           - provider-gitea
           - provider-github
           - provider-gitlab
+          - publisher-crates
+          - publisher-docker
+          - publisher-generic-http
+          - publisher-npm
+          - publisher-oci
+          - publisher-pypi
           - updater-cargo
+          - updater-composer
           - updater-docker
           - updater-go
           - updater-gradle
@@ -63,17 +81,18 @@ jobs:
           - updater-maven
           - updater-npm
           - updater-nuget
+          - updater-pubspec
           - updater-python
           - updater-terraform
 
     steps:
       - name: Check out template
-        uses: actions/checkout@v7
+        uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
         with:
           path: template
 
       - name: Check out plugin
-        uses: actions/checkout@v7
+        uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
         with:
           repository: "SemRels/${{ matrix.plugin }}"
           token: ${{ secrets.SYNC_TOKEN }}
@@ -96,11 +115,11 @@ jobs:
               cp "template/$f" "plugin/$f"
             fi
           done
-          if [ -f "template/Dockerfile" ] && [ -f "plugin/Dockerfile" ]; then
-            sed "s/plugin-template Authors/$PLUGIN Authors/" "template/Dockerfile" > "plugin/Dockerfile"
-          fi
           if [ "$INCLUDE_WORKFLOWS" = "true" ]; then
-            for wf in ci.yml release.yml security.yml; do
+            for wf in ci.yml release.yml security.yml semrel-release.yaml; do
+              if [ "$wf" = "semrel-release.yaml" ] && [ ! -f "plugin/.github/workflows/release.yml" ]; then
+                continue
+              fi
               if [ -f "template/.github/workflows/$wf" ] && [ -f "plugin/.github/workflows/$wf" ]; then
                 cp "template/.github/workflows/$wf" "plugin/.github/workflows/$wf"
               fi
@@ -138,7 +157,7 @@ jobs:
           fi
           git checkout -b "$BRANCH"
           git add -A
-          git commit -m "chore: sync from plugin-template@${SHORT_SHA} -- $CHANGED"
+          git commit --signoff -m "chore: sync from plugin-template@${SHORT_SHA} -- $CHANGED"
           git push origin "$BRANCH"
           TITLE="chore: sync from plugin-template"
           BODY="Automated sync from SemRels/plugin-template (${SHORT_SHA}). Updated: $CHANGED"
diff --git a/cmd/plugin/main.go b/cmd/plugin/main.go
index d8da7d3..c74c437 100644
--- a/cmd/plugin/main.go
+++ b/cmd/plugin/main.go
@@ -25,10 +25,10 @@ func run(stdout, stderr io.Writer, getenv func(string) string) int {
 		DryRun:  getenv("SEMREL_DRY_RUN") == "true",
 	})
 	if err != nil {
-		fmt.Fprintln(stderr, "plugin-template:", err)
+		_, _ = fmt.Fprintln(stderr, "plugin-template:", err)
 		return 1
 	}
 
-	fmt.Fprintln(stdout, message)
+	_, _ = fmt.Fprintln(stdout, message)
 	return 0
 }

From b08e3745d85b499db28857003f4912f3e6cb63bf Mon Sep 17 00:00:00 2001
From: Markus Waldheim 
Date: Mon, 3 Aug 2026 13:01:56 +0200
Subject: [PATCH 2/3] fix(release): make image publication retries idempotent

Reuse an existing immutable exact image after verifying its revision, version, and platform digests, and let GitHub select the Latest release to prevent older manual reruns from downgrading it.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Markus Waldheim 
---
 .github/workflows/ci.yml           |  2 +
 .github/workflows/release.yml      | 62 +++++++++++++++++++-----------
 scripts/verify-release-workflow.py | 36 +++++++++++++++++
 3 files changed, 78 insertions(+), 22 deletions(-)
 create mode 100644 scripts/verify-release-workflow.py

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index e6e67d8..33d6c2d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -79,3 +79,5 @@ jobs:
         uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
       - name: Run actionlint
         uses: reviewdog/action-actionlint@6fb7acc99f4a1008869fa8a0f09cfca740837d9d # v1.72.0
+      - name: Verify release workflow invariants
+        run: python3 scripts/verify-release-workflow.py
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 3addaed..7a16d65 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -343,7 +343,9 @@ jobs:
           ARM64_DIGEST: ${{ steps.promote.outputs.arm64 }}
           ARM64_IMAGE_DIGEST: ${{ steps.promote.outputs.arm64_image }}
           IMAGE: ${{ needs.version.outputs.image }}
+          SOURCE_SHA: ${{ needs.version.outputs.source_sha }}
           TAGS: ${{ steps.meta.outputs.tags }}
+          VERSION: ${{ needs.version.outputs.version }}
         run: |
           set -euo pipefail
           resolve_registry_ref() {
@@ -377,34 +379,57 @@ jobs:
               "${ref}" "${attempts}" "${status}" "${output}" >&2
             return 1
           }
+          verify_exact_image() {
+            local ref=$1
+            local manifest image_config platform_digest
+            manifest=$(docker buildx imagetools inspect --raw "${ref}")
+            jq -e \
+              --arg amd64 "${AMD64_IMAGE_DIGEST}" \
+              --arg arm64 "${ARM64_IMAGE_DIGEST}" \
+              '([.manifests[] |
+                  select(.platform.os == "linux" and
+                         (.platform.architecture == "amd64" or .platform.architecture == "arm64"))] |
+                length == 2) and
+               any(.manifests[]; .digest == $amd64 and .platform.os == "linux" and .platform.architecture == "amd64") and
+               any(.manifests[]; .digest == $arm64 and .platform.os == "linux" and .platform.architecture == "arm64")' \
+              <<< "${manifest}" >/dev/null
+            for platform_digest in "${AMD64_IMAGE_DIGEST}" "${ARM64_IMAGE_DIGEST}"; do
+              image_config=$(docker buildx imagetools inspect \
+                "${IMAGE}@${platform_digest}" --format '{{json .Image}}')
+              jq -e \
+                --arg revision "${SOURCE_SHA}" \
+                --arg version "${VERSION}" \
+                '.config.Labels["org.opencontainers.image.revision"] == $revision and
+                 .config.Labels["org.opencontainers.image.version"] == $version' \
+                <<< "${image_config}" >/dev/null
+            done
+          }
           [[ "${AMD64_DIGEST}" =~ ^sha256:[0-9a-f]{64}$ ]]
           [[ "${AMD64_IMAGE_DIGEST}" =~ ^sha256:[0-9a-f]{64}$ ]]
           [[ "${ARM64_DIGEST}" =~ ^sha256:[0-9a-f]{64}$ ]]
           [[ "${ARM64_IMAGE_DIGEST}" =~ ^sha256:[0-9a-f]{64}$ ]]
+          [[ "${SOURCE_SHA}" =~ ^[0-9a-f]{40}$ ]]
           mapfile -t final_tags < <(printf '%s\n' "${TAGS}" | sed '/^[[:space:]]*$/d')
           ((${#final_tags[@]} == 1))
           exact_tag="${final_tags[0]}"
           [[ "${exact_tag}" == "${IMAGE}:"* ]]
-          candidate_tag="${IMAGE}:manifest-${GITHUB_SHA:0:12}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
-          docker buildx imagetools create \
-            --tag "${candidate_tag}" \
-            "${IMAGE}@${AMD64_DIGEST}" \
-            "${IMAGE}@${ARM64_DIGEST}"
-          if ! digest=$(resolve_registry_ref "${candidate_tag}"); then
-            printf 'Candidate manifest %s could not be resolved; refusing publication.\n' "${candidate_tag}" >&2
-            exit 1
-          fi
           if existing_digest=$(resolve_registry_ref "${exact_tag}"); then
-            if [[ "${existing_digest}" != "${digest}" ]]; then
-              printf 'Refusing to mutate immutable exact tag %s (%s != %s).\n' \
-                "${exact_tag}" "${existing_digest}" "${digest}" >&2
-              exit 1
-            fi
+            verify_exact_image "${exact_tag}"
+            digest="${existing_digest}"
           else
             resolve_status=$?
             if ((resolve_status != 4)); then
               exit "${resolve_status}"
             fi
+            candidate_tag="${IMAGE}:manifest-${GITHUB_SHA:0:12}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
+            docker buildx imagetools create \
+              --tag "${candidate_tag}" \
+              "${IMAGE}@${AMD64_DIGEST}" \
+              "${IMAGE}@${ARM64_DIGEST}"
+            if ! digest=$(resolve_registry_ref "${candidate_tag}"); then
+              printf 'Candidate manifest %s could not be resolved; refusing publication.\n' "${candidate_tag}" >&2
+              exit 1
+            fi
             oras tag "${IMAGE}@${digest}" "${exact_tag#"${IMAGE}:"}"
           fi
           if ! published_digest=$(resolve_registry_ref "${exact_tag}"); then
@@ -416,13 +441,7 @@ jobs:
               "${exact_tag}" "${published_digest}" "${digest}" >&2
             exit 1
           fi
-          docker buildx imagetools inspect --raw "${exact_tag}" |
-            jq -e \
-              --arg amd64 "${AMD64_IMAGE_DIGEST}" \
-              --arg arm64 "${ARM64_IMAGE_DIGEST}" \
-              'any(.manifests[]; .digest == $amd64 and .platform.os == "linux" and .platform.architecture == "amd64") and
-               any(.manifests[]; .digest == $arm64 and .platform.os == "linux" and .platform.architecture == "arm64")' \
-              >/dev/null
+          verify_exact_image "${exact_tag}"
           printf 'digest=%s\n' "${digest}" >> "${GITHUB_OUTPUT}"
 
       - name: Sign published image digest
@@ -471,7 +490,6 @@ jobs:
           generate_release_notes: true
           name: ${{ needs.version.outputs.tag }}
           prerelease: ${{ needs.version.outputs.stable != 'true' }}
-          make_latest: ${{ needs.version.outputs.stable == 'true' }}
 
       - name: Trigger registry sync
         shell: bash
diff --git a/scripts/verify-release-workflow.py b/scripts/verify-release-workflow.py
new file mode 100644
index 0000000..cca37c5
--- /dev/null
+++ b/scripts/verify-release-workflow.py
@@ -0,0 +1,36 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: 2026 The plugin-template Authors
+
+from pathlib import Path
+
+
+workflow = Path(".github/workflows/release.yml").read_text(encoding="utf-8")
+
+
+def require(fragment: str) -> None:
+    if fragment not in workflow:
+        raise SystemExit(f"release workflow is missing required invariant: {fragment}")
+
+
+require("verify_exact_image()")
+require('digest="${existing_digest}"')
+require('verify_exact_image "${exact_tag}"')
+require('org.opencontainers.image.revision')
+require('org.opencontainers.image.version')
+require('--arg amd64 "${AMD64_IMAGE_DIGEST}"')
+require('--arg arm64 "${ARM64_IMAGE_DIGEST}"')
+
+existing = workflow.index('if existing_digest=$(resolve_registry_ref "${exact_tag}"); then')
+candidate = workflow.index('candidate_tag="${IMAGE}:manifest-', existing)
+missing_tag_branch = workflow.index("else", existing, candidate)
+if not existing < missing_tag_branch < candidate:
+    raise SystemExit("candidate manifest creation must occur only when the exact tag is absent")
+
+release_step = workflow.index("uses: softprops/action-gh-release@")
+next_step = workflow.find("\n      - name:", release_step)
+release_config = workflow[release_step : next_step if next_step >= 0 else None]
+if "make_latest:" in release_config:
+    raise SystemExit("GitHub must select Latest; manual reruns must not force make_latest")
+
+print("release workflow retry and Latest-selection invariants verified")

From af0dd33f88c9bae9c86721b54b28ea63a61d26b5 Mon Sep 17 00:00:00 2001
From: Markus Waldheim 
Date: Mon, 3 Aug 2026 13:04:28 +0200
Subject: [PATCH 3/3] fix(release): resolve tagged OCI validation archives

Use the validation repository and architecture tag embedded in each OCI layout during resolve and copy operations, and enforce that reference shape in the workflow verifier.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Markus Waldheim 
---
 .github/workflows/release.yml      | 6 +++---
 scripts/verify-release-workflow.py | 6 ++++++
 2 files changed, 9 insertions(+), 3 deletions(-)

diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 7a16d65..b6ca6c3 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -319,13 +319,13 @@ jobs:
           sha256sum --check -- plugin-image-*.tar.sha256
           for arch in amd64 arm64; do
             archive="plugin-image-${arch}.tar"
-            source_digest=$(oras resolve --oci-layout "${archive}:${arch}")
-            platform_digest=$(oras resolve --platform "linux/${arch}" --oci-layout "${archive}:${arch}")
+            source_digest=$(oras resolve --oci-layout "${archive}:validation:${arch}")
+            platform_digest=$(oras resolve --platform "linux/${arch}" --oci-layout "${archive}:validation:${arch}")
             [[ "${source_digest}" =~ ^sha256:[0-9a-f]{64}$ ]]
             [[ "${platform_digest}" =~ ^sha256:[0-9a-f]{64}$ ]]
             # Registry deletion is manifest-scoped, so retain this run-scoped tag to protect the final index children.
             staging_tag="scan-${GITHUB_SHA:0:12}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${arch}"
-            oras cp --from-oci-layout "${archive}:${arch}" "${IMAGE}:${staging_tag}"
+            oras cp --from-oci-layout "${archive}:validation:${arch}" "${IMAGE}:${staging_tag}"
             pushed_digest=$(oras resolve "${IMAGE}:${staging_tag}")
             pushed_platform_digest=$(oras resolve --platform "linux/${arch}" "${IMAGE}:${staging_tag}")
             [[ "${pushed_digest}" == "${source_digest}" ]]
diff --git a/scripts/verify-release-workflow.py b/scripts/verify-release-workflow.py
index cca37c5..664aa86 100644
--- a/scripts/verify-release-workflow.py
+++ b/scripts/verify-release-workflow.py
@@ -20,6 +20,12 @@ def require(fragment: str) -> None:
 require('org.opencontainers.image.version')
 require('--arg amd64 "${AMD64_IMAGE_DIGEST}"')
 require('--arg arm64 "${ARM64_IMAGE_DIGEST}"')
+require('oras resolve --oci-layout "${archive}:validation:${arch}"')
+require('oras resolve --platform "linux/${arch}" --oci-layout "${archive}:validation:${arch}"')
+require('oras cp --from-oci-layout "${archive}:validation:${arch}"')
+
+if '"${archive}:${arch}"' in workflow:
+    raise SystemExit("OCI archive references must include the validation repository and architecture tag")
 
 existing = workflow.index('if existing_digest=$(resolve_registry_ref "${exact_tag}"); then')
 candidate = workflow.index('candidate_tag="${IMAGE}:manifest-', existing)