From 1b0183816a68eba51f3e06c2d6dfc07355454ca8 Mon Sep 17 00:00:00 2001 From: joey-huckabee <138994589+joey-huckabee@users.noreply.github.com> Date: Fri, 11 Sep 2026 22:54:10 -0500 Subject: [PATCH 1/4] feat: add release supply chain --- .github/workflows/release.yml | 518 +++++++++++++++++++++++++ .github/workflows/update-locks.yml | 2 + .github/workflows/update-monitor.yml | 54 +++ CHANGELOG.md | 4 + Containerfile | 20 +- artifacts/assurance-tool-versions.json | 4 + docs/CI.md | 18 +- docs/MAINTENANCE.md | 14 + docs/RELEASE.md | 134 +++++++ docs/ROADMAP.md | 24 +- renovate.json | 54 +++ scripts/build-offline.sh | 14 + scripts/check-postgresql-update.py | 65 ++++ scripts/release.py | 234 +++++++++++ scripts/validate-release-tag.sh | 4 + tests/test_release.py | 150 +++++++ tests/test_release_workflow.py | 48 +++ 17 files changed, 1345 insertions(+), 16 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/update-monitor.yml create mode 100644 artifacts/assurance-tool-versions.json create mode 100644 docs/RELEASE.md create mode 100644 renovate.json create mode 100644 scripts/check-postgresql-update.py create mode 100644 scripts/release.py create mode 100644 scripts/validate-release-tag.sh create mode 100644 tests/test_release.py create mode 100644 tests/test_release_workflow.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..e3d1dcb --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,518 @@ +name: Release image + +on: + push: + tags: + - "v*" + +permissions: read-all + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +env: + IMAGE: ghcr.io/datopsis/postgresql-ubi + +jobs: + validate: + name: validate release identity + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + outputs: + tag: ${{ steps.release.outputs.tag }} + release_version: ${{ steps.release.outputs.release_version }} + revision: ${{ steps.release.outputs.revision }} + commit_tag: ${{ steps.release.outputs.commit_tag }} + release_date: ${{ steps.release.outputs.release_date }} + steps: + - name: Check out complete history + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Fetch protected main without credentials + run: git fetch --no-tags origin main:refs/remotes/origin/main + + - name: Validate tag, locks, changelog, date, sequence, and ancestry + id: release + env: + RELEASE_TAG: ${{ github.ref_name }} + run: >- + python3 scripts/release.py "${RELEASE_TAG}" + --require-annotated --require-main --require-current-date + --require-next-sequence --github-output "${GITHUB_OUTPUT}" + + - name: Fail closed if a GitHub Release exists or cannot be checked + env: + API_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ steps.release.outputs.tag }} + run: | + status="$(curl --silent --show-error --output release-response.json \ + --write-out '%{http_code}' \ + --header 'Accept: application/vnd.github+json' \ + --header "Authorization: Bearer ${API_TOKEN}" \ + --header 'X-GitHub-Api-Version: 2022-11-28' \ + "https://api.github.com/repos/${GITHUB_REPOSITORY}/releases/tags/${RELEASE_TAG}")" + case "${status}" in + 404) ;; + 200) echo "release tag was already used" >&2; exit 1 ;; + *) echo "release reuse check failed with HTTP ${status}" >&2; exit 1 ;; + esac + + build: + name: build and inspect (${{ matrix.architecture }}) + needs: validate + strategy: + fail-fast: false + matrix: + include: + - architecture: amd64 + runner: ubuntu-24.04 + platform: linux/amd64 + machine: x86_64 + - architecture: arm64 + runner: ubuntu-24.04-arm + platform: linux/arm64 + machine: aarch64 + runs-on: ${{ matrix.runner }} + timeout-minutes: 60 + permissions: + contents: read + env: + ARCHITECTURE: ${{ matrix.architecture }} + LOCAL_IMAGE: localhost/postgresql-ubi:release-${{ matrix.architecture }} + OCI_ARCHIVE: candidate-${{ matrix.architecture }}.tar + SBOM_FILE: image-${{ matrix.architecture }}.spdx.json + PROVENANCE_FILE: provenance-${{ matrix.architecture }}.json + TRIVY_ALL: trivy-all-${{ matrix.architecture }}.json + GRYPE_ALL: grype-all-${{ matrix.architecture }}.json + steps: + - name: Check out exact tagged commit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Buildx + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + + - name: Confirm native runner architecture + env: + EXPECTED_MACHINE: ${{ matrix.machine }} + run: test "$(uname -m)" = "${EXPECTED_MACHINE}" + + - name: Acquire locked inputs and build the runtime-test image + env: + BUILD_METADATA_FILE: ${{ env.PROVENANCE_FILE }} + CONTAINER_RUNTIME: docker + IMAGE: ${{ env.LOCAL_IMAGE }} + RELEASE_CREATED: ${{ needs.validate.outputs.release_date }}T00:00:00Z + RELEASE_REVISION: ${{ needs.validate.outputs.revision }} + RELEASE_SOURCE: https://github.com/${{ github.repository }} + RELEASE_VERSION: ${{ needs.validate.outputs.release_version }} + run: bash scripts/build-offline.sh + + - name: Run the complete restricted runtime suite + env: + CONTAINER_RUNTIME: docker + IMAGE: ${{ env.LOCAL_IMAGE }} + run: | + bash tests/smoke.sh + bash tests/runtime-security.sh + bash tests/storage-lifecycle.sh + bash tests/resource-limits.sh + bash tests/minor-update.sh + bash tests/tls.sh + + - name: Export the exact native OCI candidate with attestations + env: + CREATED: ${{ needs.validate.outputs.release_date }}T00:00:00Z + RELEASE_REVISION: ${{ needs.validate.outputs.revision }} + RELEASE_TAG: ${{ needs.validate.outputs.tag }} + RELEASE_VERSION: ${{ needs.validate.outputs.release_version }} + run: | + set -Eeuo pipefail + lock="artifacts/locks/${ARCHITECTURE}.json" + lock_sha="$(sha256sum "${lock}" | cut -d' ' -f1)" + postgresql_signer="$(jq -er '[.packages[] | select(.name | startswith("postgresql")) | .signing_key_fingerprint] | unique | if length == 1 then .[0] else error("ambiguous PostgreSQL signer") end' "${lock}")" + builder="$(jq -er '.base_images.builder.reference' "${lock}")" + runtime="$(jq -er '.base_images.runtime.reference' "${lock}")" + SOURCE_DATE_EPOCH="$(git show -s --format=%ct HEAD)" docker buildx build \ + --file Containerfile --platform "${{ matrix.platform }}" \ + --network=none --pull=false --no-cache \ + --tag "candidate:${RELEASE_TAG}" \ + --build-arg UBI_MINIMAL_IMAGE="${builder}" \ + --build-arg UBI_MICRO_IMAGE="${runtime}" \ + --build-arg ARTIFACT_LOCK_SHA256="${lock_sha}" \ + --build-arg POSTGRESQL_SIGNING_KEY_FINGERPRINT="${postgresql_signer}" \ + --build-arg RELEASE_VERSION="${RELEASE_VERSION}" \ + --build-arg RELEASE_REVISION="${RELEASE_REVISION}" \ + --build-arg RELEASE_CREATED="${CREATED}" \ + --build-arg RELEASE_SOURCE="https://github.com/${GITHUB_REPOSITORY}" \ + --provenance=false --sbom=false \ + --output "type=oci,dest=${OCI_ARCHIVE},name=${RELEASE_TAG},rewrite-timestamp=true" . + test -s "${OCI_ARCHIVE}" + + - name: Generate downloadable architecture SPDX SBOM + uses: anchore/sbom-action@3ad7283483fc7af8ff2b4ea19663c2d5ca935e26 # v0.24.2 + with: + image: oci-archive:${{ env.OCI_ARCHIVE }} + format: spdx-json + output-file: ${{ env.SBOM_FILE }} + syft-version: v1.51.1 + upload-artifact: false + upload-release-assets: false + + - name: Enforce Trivy release gate + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + input: ${{ env.OCI_ARCHIVE }} + format: table + exit-code: "1" + ignore-unfixed: true + severity: CRITICAL,HIGH + + - name: Record every Trivy finding + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + input: ${{ env.OCI_ARCHIVE }} + format: json + output: ${{ env.TRIVY_ALL }} + exit-code: "0" + ignore-unfixed: false + severity: UNKNOWN,LOW,MEDIUM,HIGH,CRITICAL + + - name: Enforce Grype release gate + uses: anchore/scan-action@27805bf3b4e84b4a5c980df22ed233c00390a439 # v7.4.2 + with: + sbom: ${{ env.SBOM_FILE }} + output-format: sarif + output-file: grype-${{ matrix.architecture }}.sarif + severity-cutoff: high + only-fixed: true + fail-build: true + cache-db: true + grype-version: v0.118.0 + + - name: Record every Grype finding + uses: anchore/scan-action@27805bf3b4e84b4a5c980df22ed233c00390a439 # v7.4.2 + with: + sbom: ${{ env.SBOM_FILE }} + output-format: json + output-file: ${{ env.GRYPE_ALL }} + severity-cutoff: negligible + only-fixed: false + fail-build: false + cache-db: true + grype-version: v0.118.0 + + - name: Record OCI descriptor and image configuration + run: | + tar -xOf "${OCI_ARCHIVE}" index.json > "manifest-${ARCHITECTURE}.json" + docker image inspect "${LOCAL_IMAGE}" > "image-config-${ARCHITECTURE}.json" + sha256sum "${OCI_ARCHIVE}" > "archive-${ARCHITECTURE}.sha256" + cp "artifacts/locks/${ARCHITECTURE}.json" "artifact-lock-${ARCHITECTURE}.json" + + - name: Retain candidate OCI archive for promotion + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-image-${{ github.sha }}-${{ matrix.architecture }} + path: ${{ env.OCI_ARCHIVE }} + if-no-files-found: error + retention-days: 7 + + - name: Retain complete architecture evidence + if: ${{ always() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-evidence-${{ github.sha }}-${{ matrix.architecture }} + path: | + ${{ env.SBOM_FILE }} + ${{ env.PROVENANCE_FILE }} + ${{ env.TRIVY_ALL }} + ${{ env.GRYPE_ALL }} + grype-${{ matrix.architecture }}.sarif + manifest-${{ matrix.architecture }}.json + image-config-${{ matrix.architecture }}.json + archive-${{ matrix.architecture }}.sha256 + artifact-lock-${{ matrix.architecture }}.json + if-no-files-found: error + retention-days: 90 + + publish: + name: approve and publish immutable manifest + needs: [validate, build] + runs-on: ubuntu-24.04 + timeout-minutes: 30 + environment: release + permissions: + contents: read + packages: write + outputs: + digest: ${{ steps.manifest.outputs.digest }} + amd64_digest: ${{ steps.push.outputs.amd64_digest }} + arm64_digest: ${{ steps.push.outputs.arm64_digest }} + steps: + - name: Download exact native OCI candidates + uses: actions/download-artifact@608db3e4b26c69590a326f08f5a1c4b9b4d36179 # v8.0.1 + with: + pattern: release-image-${{ github.sha }}-* + path: candidates + merge-multiple: true + + - name: Install ORAS + uses: oras-project/setup-oras@1d808f7d7f6995cc68b7bf507bfe5c5446e1dc9d # v2.0.1 + with: + version: 1.3.3 + + - name: Authenticate to GHCR + env: + GHCR_TOKEN: ${{ github.token }} + run: echo "${GHCR_TOKEN}" | oras login ghcr.io --username "${GITHUB_ACTOR}" --password-stdin + + - name: Push architecture manifests by digest without staging tags + id: push + env: + RELEASE_TAG: ${{ needs.validate.outputs.tag }} + run: | + set -Eeuo pipefail + for architecture in amd64 arm64; do + archive="candidates/candidate-${architecture}.tar" + descriptor="$(oras manifest fetch --descriptor --from-oci-layout "${archive}:${RELEASE_TAG}")" + digest="$(jq -er .digest <<<"${descriptor}")" + [[ "${digest}" =~ ^sha256:[0-9a-f]{64}$ ]] + oras cp --recursive --from-oci-layout \ + "${archive}:${RELEASE_TAG}" "${IMAGE}@${digest}" + echo "${architecture}_digest=${digest}" >> "${GITHUB_OUTPUT}" + done + + - name: Create only the immutable release and commit tags + id: manifest + env: + AMD64_DIGEST: ${{ steps.push.outputs.amd64_digest }} + ARM64_DIGEST: ${{ steps.push.outputs.arm64_digest }} + COMMIT_TAG: ${{ needs.validate.outputs.commit_tag }} + RELEASE_TAG: ${{ needs.validate.outputs.tag }} + run: | + docker buildx imagetools create \ + --tag "${IMAGE}:${RELEASE_TAG}" --tag "${IMAGE}:${COMMIT_TAG}" \ + --metadata-file manifest-metadata.json \ + "${IMAGE}@${AMD64_DIGEST}" "${IMAGE}@${ARM64_DIGEST}" + digest="$(jq -er '."containerimage.digest"' manifest-metadata.json)" + [[ "${digest}" =~ ^sha256:[0-9a-f]{64}$ ]] + echo "digest=${digest}" >> "${GITHUB_OUTPUT}" + + scan: + name: scan published digest + needs: [validate, publish] + runs-on: ubuntu-24.04 + timeout-minutes: 30 + permissions: + contents: read + packages: read + security-events: write + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Authenticate to GHCR + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} + + - name: Verify exactly one AMD64 and one ARM64 manifest + env: + DIGEST: ${{ needs.publish.outputs.digest }} + run: | + docker buildx imagetools inspect --raw "${IMAGE}@${DIGEST}" > manifest.json + jq -e ' + [.manifests[] | select(.platform.os == "linux") | .platform.architecture] + | sort == ["amd64", "arm64"] + ' manifest.json + + - name: Enforce published-digest Trivy gate + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + image-ref: ${{ env.IMAGE }}@${{ needs.publish.outputs.digest }} + format: table + exit-code: "1" + ignore-unfixed: true + severity: CRITICAL,HIGH + + - name: Record every published-digest Trivy finding + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + image-ref: ${{ env.IMAGE }}@${{ needs.publish.outputs.digest }} + format: json + output: trivy-index-all.json + exit-code: "0" + ignore-unfixed: false + severity: UNKNOWN,LOW,MEDIUM,HIGH,CRITICAL + + - name: Generate manifest SPDX SBOM + uses: anchore/sbom-action@3ad7283483fc7af8ff2b4ea19663c2d5ca935e26 # v0.24.2 + with: + image: ${{ env.IMAGE }}@${{ needs.publish.outputs.digest }} + format: spdx-json + output-file: image.spdx.json + syft-version: v1.51.1 + upload-artifact: false + upload-release-assets: false + + - name: Enforce published-digest Grype gate + uses: anchore/scan-action@27805bf3b4e84b4a5c980df22ed233c00390a439 # v7.4.2 + with: + sbom: image.spdx.json + output-format: sarif + output-file: grype-index.sarif + severity-cutoff: high + only-fixed: true + fail-build: true + cache-db: true + grype-version: v0.118.0 + + - name: Record all published-digest Grype findings + uses: anchore/scan-action@27805bf3b4e84b4a5c980df22ed233c00390a439 # v7.4.2 + with: + sbom: image.spdx.json + output-format: json + output-file: grype-index-all.json + severity-cutoff: negligible + only-fixed: false + fail-build: false + cache-db: true + grype-version: v0.118.0 + + - name: Upload code-scanning evidence + uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 + with: + sarif_file: grype-index.sarif + category: grype-release-image + + - name: Retain manifest evidence + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-evidence-${{ github.sha }}-manifest + path: | + manifest.json + image.spdx.json + trivy-index-all.json + grype-index.sarif + grype-index-all.json + if-no-files-found: error + retention-days: 90 + + sign: + name: attest, sign, and independently verify + needs: [validate, publish, scan] + runs-on: ubuntu-24.04 + timeout-minutes: 20 + permissions: + contents: read + id-token: write + packages: write + steps: + - name: Download complete evidence + uses: actions/download-artifact@608db3e4b26c69590a326f08f5a1c4b9b4d36179 # v8.0.1 + with: + pattern: release-evidence-${{ github.sha }}-* + path: evidence + + - name: Install Cosign + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + + - name: Create digest-bound evidence manifest + env: + AMD64_DIGEST: ${{ needs.publish.outputs.amd64_digest }} + ARM64_DIGEST: ${{ needs.publish.outputs.arm64_digest }} + DIGEST: ${{ needs.publish.outputs.digest }} + RELEASE_TAG: ${{ needs.validate.outputs.tag }} + REVISION: ${{ needs.validate.outputs.revision }} + run: | + jq -n --arg image "${IMAGE}" --arg digest "${DIGEST}" \ + --arg amd64 "${AMD64_DIGEST}" --arg arm64 "${ARM64_DIGEST}" \ + --arg tag "${RELEASE_TAG}" --arg revision "${REVISION}" \ + '{schema_version:1,image:$image,digest:$digest,architectures:{amd64:$amd64,arm64:$arm64},tag:$tag,revision:$revision}' \ + > release-evidence.json + tar --sort=name --mtime='UTC 1970-01-01' --owner=0 --group=0 \ + --numeric-owner -czf release-evidence.tar.gz evidence + sha256sum release-evidence.tar.gz > SHA256SUMS + + - name: Attest architecture and manifest evidence with GitHub OIDC + env: + AMD64_DIGEST: ${{ needs.publish.outputs.amd64_digest }} + ARM64_DIGEST: ${{ needs.publish.outputs.arm64_digest }} + DIGEST: ${{ needs.publish.outputs.digest }} + run: | + cosign attest --yes --type spdxjson \ + --predicate evidence/release-evidence-${GITHUB_SHA}-amd64/image-amd64.spdx.json \ + --bundle amd64-sbom.sigstore.json "${IMAGE}@${AMD64_DIGEST}" + cosign attest --yes --type spdxjson \ + --predicate evidence/release-evidence-${GITHUB_SHA}-arm64/image-arm64.spdx.json \ + --bundle arm64-sbom.sigstore.json "${IMAGE}@${ARM64_DIGEST}" + cosign attest --yes --type https://datopsis.dev/attestations/build-provenance/v1 \ + --predicate evidence/release-evidence-${GITHUB_SHA}-amd64/provenance-amd64.json \ + --bundle amd64-provenance.sigstore.json "${IMAGE}@${AMD64_DIGEST}" + cosign attest --yes --type https://datopsis.dev/attestations/build-provenance/v1 \ + --predicate evidence/release-evidence-${GITHUB_SHA}-arm64/provenance-arm64.json \ + --bundle arm64-provenance.sigstore.json "${IMAGE}@${ARM64_DIGEST}" + cosign attest --yes --type spdxjson \ + --predicate evidence/release-evidence-${GITHUB_SHA}-manifest/image.spdx.json \ + --bundle manifest-sbom.sigstore.json "${IMAGE}@${DIGEST}" + cosign attest --yes --type https://datopsis.dev/attestations/release-evidence/v1 \ + --predicate release-evidence.json --bundle release-evidence.sigstore.json \ + "${IMAGE}@${DIGEST}" + cosign sign --yes --bundle image.sigstore.json "${IMAGE}@${DIGEST}" + + - name: Verify issuer, workflow identity, digest, signature, and SBOM policy + env: + DIGEST: ${{ needs.publish.outputs.digest }} + EXPECTED_IDENTITY: https://github.com/${{ github.repository }}/.github/workflows/release.yml@refs/tags/${{ needs.validate.outputs.tag }} + run: | + cosign verify --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + --certificate-identity "${EXPECTED_IDENTITY}" --bundle image.sigstore.json \ + "${IMAGE}@${DIGEST}" + cosign verify-attestation --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + --certificate-identity "${EXPECTED_IDENTITY}" --type spdxjson \ + --bundle manifest-sbom.sigstore.json "${IMAGE}@${DIGEST}" + + - name: Retain signature and attestation bundles + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-signatures-${{ github.sha }} + path: | + *.sigstore.json + release-evidence.json + release-evidence.tar.gz + SHA256SUMS + if-no-files-found: error + retention-days: 90 + + release: + name: publish durable release evidence + needs: [validate, publish, sign] + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: write + steps: + - name: Download release signatures + uses: actions/download-artifact@608db3e4b26c69590a326f08f5a1c4b9b4d36179 # v8.0.1 + with: + name: release-signatures-${{ github.sha }} + path: release-assets + - name: Create GitHub Release only after every gate succeeds + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ needs.validate.outputs.tag }} + run: >- + gh release create "${RELEASE_TAG}" --verify-tag + --title "${RELEASE_TAG}" --generate-notes + release-assets/**/* diff --git a/.github/workflows/update-locks.yml b/.github/workflows/update-locks.yml index d2b249b..753df5b 100644 --- a/.github/workflows/update-locks.yml +++ b/.github/workflows/update-locks.yml @@ -1,6 +1,8 @@ name: Propose artifact lock update on: + schedule: + - cron: "17 8 * * 1" workflow_dispatch: permissions: diff --git a/.github/workflows/update-monitor.yml b/.github/workflows/update-monitor.yml new file mode 100644 index 0000000..5ec81a0 --- /dev/null +++ b/.github/workflows/update-monitor.yml @@ -0,0 +1,54 @@ +name: Monitor upstream updates + +on: + schedule: + - cron: "41 8 * * 1" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: update-monitor + cancel-in-progress: false + +jobs: + report: + name: update review dashboard + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: read + issues: write + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Check the maintained upstream PostgreSQL line + id: postgresql + run: >- + python3 scripts/check-postgresql-update.py + --report update-report.md --github-output "${GITHUB_OUTPUT}" + + - name: Create or refresh the update-review issue + env: + GH_TOKEN: ${{ github.token }} + ISSUE_TITLE: Supply-chain update review dashboard + run: | + issue="$(gh issue list --state open --search "${ISSUE_TITLE} in:title" \ + --json number,title --jq '.[] | select(.title == env.ISSUE_TITLE) | .number' | head -n1)" + if test -n "${issue}"; then + gh issue edit "${issue}" --body-file update-report.md + else + gh issue create --title "${ISSUE_TITLE}" --body-file update-report.md + fi + + - name: Retain the monitored result + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: upstream-update-report-${{ github.run_id }} + path: update-report.md + if-no-files-found: error + retention-days: 30 diff --git a/CHANGELOG.md b/CHANGELOG.md index 20efc02..f1d43a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,3 +43,7 @@ but container releases use the upstream-derived format documented in - Added executable deployment guidance for fixed and arbitrary identities, Podman, Docker and Compose, named and bind-mounted storage, TLS and isolated non-TLS networks, controlled transfer, operations, recovery, and teardown. +- Added the immutable, least-privilege native release pipeline; fail-closed tag + policy; digest-bound SBOM, provenance, scanning, signing, verification, and + evidence controls; upstream monitoring; emergency rebuild; and quarantine + procedures. diff --git a/Containerfile b/Containerfile index 8148d7a..44805ac 100644 --- a/Containerfile +++ b/Containerfile @@ -52,16 +52,34 @@ RUN test -n "${ARTIFACT_LOCK_SHA256}" \ FROM scratch +ARG UBI_MINIMAL_IMAGE +ARG UBI_MICRO_IMAGE ARG POSTGRESQL_VERSION="18.6" ARG POSTGRESQL_RPM_VERSION="18.6-1PGDG.rhel9.8" +ARG POSTGRESQL_PUBLISHER="PostgreSQL Global Development Group (PGDG)" +ARG POSTGRESQL_SIGNING_KEY_FINGERPRINT="unknown" ARG ARTIFACT_LOCK_SHA256 +ARG RELEASE_VERSION="development" +ARG RELEASE_REVISION="unknown" +ARG RELEASE_CREATED="1970-01-01T00:00:00Z" +ARG RELEASE_SOURCE="https://github.com/datopsis/postgresql-ubi" LABEL org.opencontainers.image.title="PostgreSQL on Red Hat UBI 9" \ org.opencontainers.image.description="A security-oriented, rootless PostgreSQL image built on Red Hat UBI 9 Micro" \ org.opencontainers.image.licenses="Apache-2.0" \ org.opencontainers.image.vendor="Datopsis" \ - org.opencontainers.image.version="${POSTGRESQL_VERSION}" \ + org.opencontainers.image.version="${RELEASE_VERSION}" \ + org.opencontainers.image.revision="${RELEASE_REVISION}" \ + org.opencontainers.image.created="${RELEASE_CREATED}" \ + org.opencontainers.image.source="${RELEASE_SOURCE}" \ + org.opencontainers.image.url="${RELEASE_SOURCE}" \ + org.opencontainers.image.documentation="${RELEASE_SOURCE}/blob/${RELEASE_REVISION}/README.md" \ io.datopsis.postgresql.rpm-version="${POSTGRESQL_RPM_VERSION}" \ + io.datopsis.postgresql.version="${POSTGRESQL_VERSION}" \ + io.datopsis.postgresql.publisher="${POSTGRESQL_PUBLISHER}" \ + io.datopsis.postgresql.signing-key-fingerprint="${POSTGRESQL_SIGNING_KEY_FINGERPRINT}" \ + io.datopsis.ubi.builder="${UBI_MINIMAL_IMAGE}" \ + io.datopsis.ubi.runtime="${UBI_MICRO_IMAGE}" \ io.datopsis.artifact-lock.sha256="${ARTIFACT_LOCK_SHA256}" COPY --from=builder /final/ / diff --git a/artifacts/assurance-tool-versions.json b/artifacts/assurance-tool-versions.json new file mode 100644 index 0000000..9b5e4ac --- /dev/null +++ b/artifacts/assurance-tool-versions.json @@ -0,0 +1,4 @@ +{ + "compliance_as_code": "0.1.81", + "status": "planned-input-not-yet-qualified" +} diff --git a/docs/CI.md b/docs/CI.md index 6a8991a..9397334 100644 --- a/docs/CI.md +++ b/docs/CI.md @@ -36,8 +36,20 @@ release evidence. Review workflow logs, warnings, skipped steps, scanner results, and retained artifacts rather than relying only on a green aggregate status. +## Release pipeline + +The tag-only pipeline validates immutable identity, builds and tests native OCI +candidates, scans before and after publication, requires `release` environment +approval, publishes the two-tag AMD64/ARM64 index, attaches keyless Cosign +evidence, verifies it, and only then creates a GitHub Release. Permissions, +evidence, failure handling, and consumer commands are in +[RELEASE.md](RELEASE.md). It cannot publish a supported image until Package 8 +completes the disposable rehearsal, settings audit, frozen qualification, and +independent approval. + ## Evidence boundary -CI artifacts contain exact-commit SBOM and scan results and currently expire -after 14 days. Release evidence requirements, retention, signing, provenance, -and publication remain first-release roadmap work. +CI artifacts contain exact-commit SBOM and scan results and expire after 14 +days. Release transfer archives expire after 7 days and release workflow +evidence after 90 days; durable release assets and their required independent +backup follow [the release policy](RELEASE.md#retention-and-backup). diff --git a/docs/MAINTENANCE.md b/docs/MAINTENANCE.md index 77bbbc5..1b9fa0a 100644 --- a/docs/MAINTENANCE.md +++ b/docs/MAINTENANCE.md @@ -49,6 +49,15 @@ inputs to review, not a substitute for advisory analysis. | CI Actions, scanners, vulnerability databases, and assurance tools | Review alerts continuously and perform a pinned-version review at least monthly. | | Support and security policy | Review at every release and at least quarterly. | +The weekly update dashboard compares the maintained PostgreSQL line with the +authoritative upstream versions feed. The scheduled lock resolver exercises +both architectures against current PGDG/UBI metadata and signing material; +Dependabot and Renovate propose pinned Actions, Python tools, UBI images, +scanners, Cosign, and assurance-tool changes. A bot proposal or green scheduled +run is only an alert: signing-key changes require fingerprint review, lock +changes require source/binary review, and scanner database changes invalidate +the affected evidence. See [the release supply chain](RELEASE.md). + Targets start when the project receives a credible report or an authoritative notice is public, whichever occurs first. Severity is not accepted from a scanner string alone: assessment includes vendor status, affected source and @@ -114,6 +123,11 @@ events permit. An urgent security or legal withdrawal can be immediate. - Release-candidate and published-release evidence is retained for the full support period plus one year, with release-critical evidence copied out of short-lived workflow artifacts. +- Within 24 hours of publication, copy release assets, raw manifest, Sigstore + bundles, checksums, release metadata, and the qualification ledger to the + access-controlled immutable backup named in the qualification record. Test + retrieval annually and before a provider change; an untested backup is not + release evidence. - Security advisory and incident evidence follows the deployment organization's legal, privacy, and records policy and must not be placed in a public repository merely to satisfy this project policy. diff --git a/docs/RELEASE.md b/docs/RELEASE.md new file mode 100644 index 0000000..e9ef355 --- /dev/null +++ b/docs/RELEASE.md @@ -0,0 +1,134 @@ +# Release supply chain + +No supported image exists yet. This document defines the mechanism. Package 8 +must rehearse it with a disposable candidate, verify GitHub settings, obtain +independent approval, and publish the first supported release. + +## Trust boundary + +The annotated tag, protected-`main` commit, architecture manifests, OCI index, +labels, locks, SBOMs, scans, Sigstore bundles, and GitHub Release must identify +one candidate. Consumers verify +`ghcr.io/datopsis/postgresql-ubi@sha256:`; tags are discovery aids. The +workflow creates only the full release tag and `sha-<12>` commit tag—never +`latest`, `18`, or `18.6`. + +`scripts/release.py` rejects impossible or non-current UTC dates, a reused +daily sequence, lightweight or moved tags, a commit outside `origin/main`, a +missing/misdated changelog section, and disagreement among tag, Containerfile, +input lock, or architecture locks. An existing GitHub Release also blocks +reuse. Package 8 adds the tag ruleset after the creation procedure is proven. + +## Required GitHub configuration + +Configure a `release` environment with independent required reviewers, no +bypass, and deployment limited to release tags. Keep default workflow-token +permissions read-only, expose no long-lived secret, permit only the pinned +Actions used here, enable immutable releases, and link GHCR to this repository. +Package 8 records actual settings because YAML cannot enforce them. + +## Pipeline and least privilege + +The tag workflow is non-cancelling. Jobs have no permissions beyond these: + +| Job | Permission | Purpose | +| --- | --- | --- | +| Validate | `contents: read` | Validate history, locks, tag, ancestry, sequence, and release reuse. | +| Build | `contents: read` | Build/test native AMD64 and ARM64 archives; cannot publish. | +| Approve/publish | `contents: read`, `packages: write`, `release` environment | Upload exact archives by digest and create two immutable tags. | +| Registry scan | `contents: read`, `packages: read`, `security-events: write` | Validate and scan the published digest. | +| Sign | `contents: read`, `packages: write`, `id-token: write` | Attach evidence and keyless signatures after scans pass. | +| GitHub Release | `contents: write` | Publish durable evidence after verification. | + +Native `ubuntu-24.04` AMD64 and `ubuntu-24.04-arm` ARM64 runners acquire their +checksum/fingerprint-locked closures, build without network access, run the +restricted-runtime suite, and export OCI archives. Each exact archive is +scanned and transferred using immutable GitHub Artifacts v4. ORAS uploads its +content by digest without staging tags; Buildx creates only the release and +commit tags. No pull-request artifact, cache, user-controlled expression, or +mutable artifact name enters the release. + +The published index must contain exactly one Linux AMD64 and one Linux ARM64 +descriptor. Scanner error, missing output, wrong architecture, fixed +High/Critical finding, or absent evidence blocks. Full Trivy and Grype JSON +keeps Unknown/Low/Medium and unfixed findings for review; a passing gate is not +a claim that the image has no vulnerabilities. + +## Evidence and verification + +Release assets include architecture and manifest SPDX JSON, complete Trivy and +Grype JSON, SARIF, OCI manifests, image configurations, locks, build metadata, +a digest/architecture ledger, SHA-256 inventory, and Sigstore bundles. +Architecture digests receive SPDX and build-provenance predicates; the index +receives SPDX and release-evidence predicates. The index digest is signed +keylessly with GitHub Actions OIDC. + +Use values from the GitHub Release and never substitute a mutable tag: + +```console +IMAGE=ghcr.io/datopsis/postgresql-ubi +DIGEST=sha256:<64-lowercase-hex-characters> +TAG=v-ubi9-r. +IDENTITY="https://github.com/datopsis/postgresql-ubi/.github/workflows/release.yml@refs/tags/${TAG}" + +cosign verify \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + --certificate-identity "${IDENTITY}" \ + --bundle image.sigstore.json "${IMAGE}@${DIGEST}" +cosign verify-attestation \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + --certificate-identity "${IDENTITY}" --type spdxjson \ + --bundle manifest-sbom.sigstore.json "${IMAGE}@${DIGEST}" +sha256sum --check SHA256SUMS +``` + +Also compare both platform digests to `release-evidence.json`, inspect raw OCI +labels, and perform the PostgreSQL checks in `QUALIFICATION.md`. OIDC exists +only in the signing job and grants no repository/registry permission itself. + +## Retention and backup + +Transfer archives expire after 7 days and workflow evidence after 90 days. +They are not durable records. The GitHub Release is the public copy. Within 24 +hours, the qualification owner copies all assets, release metadata, raw index, +bundles, and checksum inventory to an access-controlled, versioned, immutable +backup; records its location and restore test in `QUALIFICATION.md`; and tests +retrieval annually and before changing providers. Preserve it through support, +the 90-day supersession window, and one additional year. Sensitive incident +evidence follows the stricter legal/privacy policy and is not published. + +## Failure and quarantine + +A build or pre-publication scan failure publishes nothing. Untagged manifests +uploaded before failure remain unannounced and unsigned. A post-index failure +quarantines its release and commit tags: no GitHub Release, support statement, +signature, or announcement. Record tag, digest, run, cause, architectures, and +disposition. Delete a package version only after proving no released index +references it and preserving forensic evidence; deletion can break consumers. +Never move or reuse a Git tag, OCI tag, digest, or daily sequence. Fix the cause +in a new reviewed commit and release identifier. A partially attached +attestation is evidence of a failed candidate, not authority to deploy. + +## UBI-only emergency rebuild + +A relevant Critical or known-exploited UBI issue starts immediate assessment +and the 72-hour containment/mitigation target; it does not wait for PostgreSQL. +Keep reviewed PostgreSQL/PGDG inputs unchanged, resolve both current UBI +closures and base manifests, review Red Hat errata and source/binary deltas, +verify signing identities, regenerate locks, and run all Package 2–4 gates. +Explain every filesystem, package, SBOM, behavior, and finding delta. Use a new +date/sequence and normal qualification/approval. If no safe fix is available, +record mitigation or withdrawal, owner, affected digests, and next review. +Never silently rebuild an existing tag. + +## Workflow security review + +Package 4 confirms SHA-pinned Actions, read-only defaults, explicit job grants, +disabled checkout credentials, no release cache/fork trigger, non-cancelling +concurrency, fixed artifact names, and OIDC confined after scanning. The +release environment separates approval from building. Immutable artifact IDs +prevent overwrite and missing evidence is fatal. Package 8 must observe the +external rulesets, environment reviewers, Actions policy, GHCR access, OIDC +claims, and independent review. Runner/action compromise remains residual risk +managed through pins, native duplication, attestations, evidence review, and +withdrawal—not eliminated by YAML. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 390ad32..6dd5302 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -81,7 +81,7 @@ Work proceeds in this dependency order: and network-disabled assembly. 3. **Complete:** close the database security, storage, lifecycle, TLS, logging, backup, restore, and upgrade test matrix. -4. Add the release pipeline and remaining supply-chain controls. +4. **Complete:** add the release pipeline and remaining supply-chain controls. 5. Complete the cybersecurity requirement analysis, threat model, control artifacts, tailored SCAP evidence, and vulnerability policy. 6. Qualify rootless Podman and the selected operational profile on an exact @@ -300,39 +300,39 @@ resource exhaustion/recovery, PostgreSQL 18.4-to-18.6 preserved-data update, TLS rotation/negative, vulnerability, SBOM, and repository gates at revision `9827bd94ce41c35796b7fa56bd2cae35b7347aa3`. -## Package 4: CI, updates, and release supply chain +## Package 4: CI, updates, and release supply chain (complete) -- [ ] Add tests for release-tag syntax, real UTC dates and sequences, +- [x] Add tests for release-tag syntax, real UTC dates and sequences, PostgreSQL/UBI/lock matching, annotated tags, protected-`main` ancestry, changelog state, and rejection of mutable, malformed, reused, moved, or mismatched tags. -- [ ] Add a least-privilege, non-cancelling release workflow that publishes only +- [x] Add a least-privilege, non-cancelling release workflow that publishes only the immutable release and commit tags, produces the native AMD64/ARM64 manifest, records complete OCI metadata, and separates build, scan, approval, signing, and release permissions. -- [ ] Generate architecture-specific and manifest-level SBOM/provenance +- [x] Generate architecture-specific and manifest-level SBOM/provenance attestations, a downloadable complete SPDX SBOM, scan results, lock and source provenance, image configuration, and verification instructions bound to the published digest. -- [ ] Sign the image digest and complete SPDX attestation keylessly with GitHub +- [x] Sign the image digest and complete SPDX attestation keylessly with GitHub OIDC/Cosign, retain verification bundles, and test issuer, identity, digest, and attestation-policy verification without relying on a mutable tag. -- [ ] Retain full Trivy and Grype findings, including unfixed and lower-severity +- [x] Retain full Trivy and Grype findings, including unfixed and lower-severity inventory for human triage. Scanner operational errors, missing inventories, architecture mismatches, or absent evidence always block. -- [ ] Add monitored update proposals for PostgreSQL releases, PGDG RPMs and +- [x] Add monitored update proposals for PostgreSQL releases, PGDG RPMs and signing keys, UBI manifests and dependency locks, GitHub Actions, scanner engines/databases, Cosign, ComplianceAsCode, and other assurance tooling. -- [ ] Define the UBI-only emergency rebuild path and response target so a base +- [x] Define the UBI-only emergency rebuild path and response target so a base security update does not wait for a PostgreSQL release. -- [ ] Audit workflow permissions, immutable action references, artifact +- [x] Audit workflow permissions, immutable action references, artifact attestations, cache trust, fork behavior, expression injection, artifact overwrite/extraction risks, OIDC scope, token persistence, and environment protections. -- [ ] Define release-evidence retention and backup. Release evidence must outlive +- [x] Define release-evidence retention and backup. Release evidence must outlive workflow artifact expiry and remain available for incident response and verification throughout the support and supersession period. -- [ ] Define failed-candidate handling: no failed digest is signed or announced; +- [x] Define failed-candidate handling: no failed digest is signed or announced; a partially published tag/digest is quarantined or removed and never reused. **Exit evidence:** a rehearsal proves the exact reviewed candidate can be diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..ae21701 --- /dev/null +++ b/renovate.json @@ -0,0 +1,54 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "dependencyDashboard": true, + "extends": ["config:recommended"], + "packageRules": [ + { + "groupName": "Red Hat UBI 9 base images", + "matchDatasources": ["docker"], + "matchPackageNames": [ + "registry.access.redhat.com/ubi9/ubi-minimal", + "registry.access.redhat.com/ubi9/ubi-micro" + ] + }, + { + "groupName": "container assurance tools", + "matchPackageNames": [ + "anchore/grype", + "anchore/syft", + "aquasecurity/trivy", + "complianceascode/content", + "sigstore/cosign" + ] + } + ], + "customManagers": [ + { + "customType": "regex", + "datasourceTemplate": "github-releases", + "managerFilePatterns": ["/^\\.github/workflows/.*\\.ya?ml$/"], + "matchStrings": [ + "syft-version: v(?[0-9.]+)" + ], + "packageNameTemplate": "anchore/syft" + }, + { + "customType": "regex", + "datasourceTemplate": "github-releases", + "managerFilePatterns": ["/^\\.github/workflows/.*\\.ya?ml$/"], + "matchStrings": [ + "grype-version: v(?[0-9.]+)" + ], + "packageNameTemplate": "anchore/grype" + }, + { + "customType": "regex", + "datasourceTemplate": "github-releases", + "managerFilePatterns": ["/^artifacts/assurance-tool-versions\\.json$/"], + "matchStrings": [ + "\"compliance_as_code\": \"(?[0-9.]+)\"" + ], + "packageNameTemplate": "ComplianceAsCode/content" + } + ] +} diff --git a/scripts/build-offline.sh b/scripts/build-offline.sh index ee9116c..3a6cf1e 100644 --- a/scripts/build-offline.sh +++ b/scripts/build-offline.sh @@ -20,6 +20,14 @@ test ! -e "${bundle}" python3 scripts/artifacts.py acquire --lock "${lock}" --output "${bundle}" bash scripts/verify-key-fingerprints.sh "${bundle}" lock_sha=$(sha256sum "${lock}" | cut -d' ' -f1) +postgresql_signer=$(python3 -c ' +import json, sys +lock = json.load(open(sys.argv[1])) +values = {item["signing_key_fingerprint"] for item in lock["packages"] + if item["name"].startswith("postgresql")} +assert len(values) == 1 +print(values.pop()) +' "${lock}") builder=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["base_images"]["builder"]["reference"])' "${lock}") runtime_base=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["base_images"]["runtime"]["reference"])' "${lock}") @@ -39,7 +47,13 @@ build_arguments=(--file Containerfile --tag "${image}" --network=none \ --pull="${pull_flag}" --no-cache \ --build-arg UBI_MINIMAL_IMAGE=localhost/postgresql-ubi-builder:locked \ --build-arg UBI_MICRO_IMAGE=localhost/postgresql-ubi-runtime:locked \ + --build-arg POSTGRESQL_SIGNING_KEY_FINGERPRINT="${postgresql_signer}" \ --build-arg ARTIFACT_LOCK_SHA256="${lock_sha}" .) +for release_argument in RELEASE_VERSION RELEASE_REVISION RELEASE_CREATED RELEASE_SOURCE; do + if test -n "${!release_argument:-}"; then + build_arguments=(--build-arg "${release_argument}=${!release_argument}" "${build_arguments[@]}") + fi +done if test "${runtime}" = docker && test -n "${BUILD_METADATA_FILE:-}"; then build_arguments=(--metadata-file "${BUILD_METADATA_FILE}" "${build_arguments[@]}") fi diff --git a/scripts/check-postgresql-update.py b/scripts/check-postgresql-update.py new file mode 100644 index 0000000..78170e1 --- /dev/null +++ b/scripts/check-postgresql-update.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Create a fail-closed PostgreSQL upstream update report.""" + +from __future__ import annotations + +import argparse +import datetime as dt +import json +import urllib.request +from pathlib import Path + + +VERSIONS_URL = "https://www.postgresql.org/versions.json" + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--inputs", type=Path, default=Path("artifacts/lock-inputs.json")) + parser.add_argument("--report", type=Path, required=True) + parser.add_argument("--github-output", type=Path) + arguments = parser.parse_args() + + inputs = json.loads(arguments.inputs.read_text(encoding="utf-8")) + current = inputs["postgresql_version"] + major = current.split(".", 1)[0] + request = urllib.request.Request( + VERSIONS_URL, headers={"User-Agent": "datopsis/postgresql-ubi update monitor"} + ) + with urllib.request.urlopen(request, timeout=30) as response: + versions = json.load(response) + selected = next(item for item in versions if item["major"] == major) + latest = f"{major}.{selected['latestMinor']}" + update_available = latest != current + status = "UPDATE REVIEW REQUIRED" if update_available else "current" + timestamp = dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat() + report = f"""# PostgreSQL and supply-chain update monitor + +Generated: `{timestamp}` + +| Input | Locked/selected | Authoritative result | Status | +| --- | --- | --- | --- | +| PostgreSQL major {major} | `{current}` | `{latest}` (released {selected['relDate']}, EOL {selected['eolDate']}) | **{status}** | + +Authoritative PostgreSQL data: {VERSIONS_URL} + +The scheduled artifact-lock workflow separately resolves both native +architectures from current PGDG and UBI metadata and verifies every downloaded +RPM, source RPM, signing-key hash/fingerprint, and base digest. Review its +artifacts and failures; automation must never accept a changed key or lock. + +Dependabot and Renovate propose pinned GitHub Action, Python tool, UBI image, +Syft, Grype, Trivy, Cosign, and assurance-tool updates. Scanner databases are +refreshed by scheduled CI and release runs. Every proposal still requires the +review and qualification in `docs/MAINTENANCE.md`. +""" + arguments.report.write_text(report, encoding="utf-8", newline="\n") + if arguments.github_output: + with arguments.github_output.open("a", encoding="utf-8", newline="\n") as output: + output.write(f"update_available={str(update_available).lower()}\n") + output.write(f"current={current}\nlatest={latest}\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/release.py b/scripts/release.py new file mode 100644 index 0000000..efdb7ad --- /dev/null +++ b/scripts/release.py @@ -0,0 +1,234 @@ +#!/usr/bin/env python3 +"""Validate immutable release identity and emit deterministic release metadata.""" + +from __future__ import annotations + +import argparse +import datetime as dt +import json +import os +import re +import subprocess +import sys +from pathlib import Path + + +TAG_PATTERN = re.compile( + r"^v(?P[0-9]+\.[0-9]+)-ubi(?P[1-9][0-9]*)-" + r"r(?P[0-9]{8})\.(?P[1-9][0-9]*)$" +) +SHA256_PATTERN = re.compile(r"^sha256:[0-9a-f]{64}$") + + +class ReleaseError(ValueError): + """A release candidate violates the release contract.""" + + +def fail(message: str) -> None: + raise ReleaseError(message) + + +def load_json(path: Path) -> dict: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + fail(f"cannot read valid JSON from {path}: {exc}") + if not isinstance(value, dict): + fail(f"{path} must contain a JSON object") + return value + + +def container_args(path: Path) -> dict[str, str]: + try: + content = path.read_text(encoding="utf-8") + except OSError as exc: + fail(f"cannot read {path}: {exc}") + pairs = re.findall(r'^ARG ([A-Z0-9_]+)="([^"]+)"$', content, re.MULTILINE) + return dict(pairs) + + +def git(*arguments: str, cwd: Path) -> str: + result = subprocess.run( + ["git", *arguments], cwd=cwd, check=False, capture_output=True, text=True + ) + if result.returncode: + fail(result.stderr.strip() or f"git {' '.join(arguments)} failed") + return result.stdout.strip() + + +def strict_date(value: str) -> dt.date: + try: + parsed = dt.datetime.strptime(value, "%Y%m%d").date() + except ValueError as exc: + fail(f"release date {value!r} is not a real UTC calendar date: {exc}") + if parsed.strftime("%Y%m%d") != value: + fail(f"release date {value!r} is not canonical") + return parsed + + +def validate_locks(root: Path, postgresql: str, ubi_major: str, args: dict[str, str]) -> None: + inputs = load_json(root / "artifacts/lock-inputs.json") + if inputs.get("postgresql_version") != postgresql: + fail("tag PostgreSQL version does not match artifacts/lock-inputs.json") + ubi_release = inputs.get("ubi_release") + if not isinstance(ubi_release, str) or ubi_release.split(".", 1)[0] != ubi_major: + fail("tag UBI major does not match artifacts/lock-inputs.json") + + expected_rpm = inputs.get("postgresql_rpm_version") + if args.get("POSTGRESQL_VERSION") != postgresql: + fail("tag PostgreSQL version does not match Containerfile") + if args.get("POSTGRESQL_RPM_VERSION") != expected_rpm: + fail("Containerfile PostgreSQL RPM version does not match lock inputs") + + expected_bases = inputs.get("base_images") + container_bases = { + "builder": args.get("UBI_MINIMAL_IMAGE"), + "runtime": args.get("UBI_MICRO_IMAGE"), + } + if expected_bases != container_bases: + fail("Containerfile base image references do not match lock inputs") + + for architecture in ("amd64", "arm64"): + lock = load_json(root / f"artifacts/locks/{architecture}.json") + if lock.get("architecture") != architecture: + fail(f"{architecture} lock has the wrong architecture") + if lock.get("postgresql_version") != postgresql: + fail(f"{architecture} lock has the wrong PostgreSQL version") + if lock.get("postgresql_rpm_version") != expected_rpm: + fail(f"{architecture} lock has the wrong PostgreSQL RPM version") + bases = lock.get("base_images") + if not isinstance(bases, dict): + fail(f"{architecture} lock has no base image map") + for name, reference in expected_bases.items(): + item = bases.get(name) + if not isinstance(item, dict) or item.get("reference") != reference: + fail(f"{architecture} {name} base does not match lock inputs") + digest = item.get("digest") + if not isinstance(digest, str) or not SHA256_PATTERN.fullmatch(digest): + fail(f"{architecture} {name} base digest is malformed") + if not reference.endswith("@" + digest): + fail(f"{architecture} {name} base reference/digest mismatch") + + +def validate_changelog(path: Path, tag: str, release_date: dt.date) -> None: + try: + content = path.read_text(encoding="utf-8") + except OSError as exc: + fail(f"cannot read {path}: {exc}") + unreleased = content.find("## [Unreleased]") + heading = f"## [{tag}] - {release_date.isoformat()}" + release = content.find(heading) + if unreleased < 0 or release < 0 or unreleased > release: + fail(f"changelog must contain Unreleased followed by {heading!r}") + if content.count(f"## [{tag}]") != 1: + fail("changelog must contain the release heading exactly once") + + +def validate_git( + root: Path, + tag: str, + release_date: str, + sequence: int, + require_annotated: bool, + require_main: bool, + require_next_sequence: bool, +) -> str: + if require_annotated: + git("cat-file", "-e", f"refs/tags/{tag}^{{tag}}", cwd=root) + commit = git("rev-list", "-n", "1", tag, cwd=root) + event_sha = os.environ.get("GITHUB_SHA") + if event_sha and commit != event_sha: + fail("release tag does not resolve to GITHUB_SHA") + if require_main: + git("rev-parse", "--verify", "refs/remotes/origin/main", cwd=root) + result = subprocess.run( + ["git", "merge-base", "--is-ancestor", commit, "refs/remotes/origin/main"], + cwd=root, + check=False, + ) + if result.returncode: + fail("release commit is not reachable from protected origin/main") + if require_next_sequence: + tags = git("tag", "--list", f"v*-r{release_date}.*", cwd=root).splitlines() + sequences = [] + for known in tags: + match = TAG_PATTERN.fullmatch(known) + if known != tag and match: + sequences.append(int(match.group("sequence"))) + expected = max(sequences, default=0) + 1 + if sequence != expected: + fail(f"release sequence must be {expected}, not {sequence}") + return commit + + +def validate_tag(namespace: argparse.Namespace) -> dict[str, str]: + root = namespace.root.resolve() + match = TAG_PATTERN.fullmatch(namespace.tag) + if not match: + fail( + "release tag must match " + "v-ubi-r." + ) + release_date = strict_date(match.group("date")) + if namespace.require_current_date: + expected_date = os.environ.get("RELEASE_DATE_UTC") + today = ( + strict_date(expected_date) + if expected_date + else dt.datetime.now(dt.timezone.utc).date() + ) + if release_date != today: + fail("release tag date is not the current UTC date") + + args = container_args(root / "Containerfile") + validate_locks(root, match.group("postgresql"), match.group("ubi"), args) + validate_changelog(root / "CHANGELOG.md", namespace.tag, release_date) + commit = validate_git( + root, + namespace.tag, + match.group("date"), + int(match.group("sequence")), + namespace.require_annotated, + namespace.require_main, + namespace.require_next_sequence, + ) + values = { + "tag": namespace.tag, + "release_version": namespace.tag.removeprefix("v"), + "postgresql_version": match.group("postgresql"), + "ubi_major": match.group("ubi"), + "release_date": release_date.isoformat(), + "revision": commit, + "commit_tag": f"sha-{commit[:12]}", + } + if namespace.github_output: + with namespace.github_output.open("a", encoding="utf-8", newline="\n") as output: + for key, value in values.items(): + output.write(f"{key}={value}\n") + return values + + +def parser() -> argparse.ArgumentParser: + result = argparse.ArgumentParser() + result.add_argument("tag") + result.add_argument("--root", type=Path, default=Path(".")) + result.add_argument("--require-annotated", action="store_true") + result.add_argument("--require-main", action="store_true") + result.add_argument("--require-current-date", action="store_true") + result.add_argument("--require-next-sequence", action="store_true") + result.add_argument("--github-output", type=Path) + return result + + +def main() -> int: + try: + values = validate_tag(parser().parse_args()) + except ReleaseError as exc: + print(f"release validation failed: {exc}", file=sys.stderr) + return 1 + print(json.dumps(values, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/validate-release-tag.sh b/scripts/validate-release-tag.sh new file mode 100644 index 0000000..a4bc65c --- /dev/null +++ b/scripts/validate-release-tag.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +exec python3 "$(dirname "${BASH_SOURCE[0]}")/release.py" "$@" diff --git a/tests/test_release.py b/tests/test_release.py new file mode 100644 index 0000000..e0199d3 --- /dev/null +++ b/tests/test_release.py @@ -0,0 +1,150 @@ +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +REPOSITORY = Path(__file__).resolve().parents[1] +SCRIPT = REPOSITORY / "scripts" / "release.py" +GOOD_TAG = "v18.6-ubi9-r20260911.1" + + +class ReleaseTagTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(dir=REPOSITORY) + self.root = Path(self.temporary.name) + (self.root / "artifacts/locks").mkdir(parents=True) + (self.root / "Containerfile").write_text( + '\n'.join( + [ + 'ARG UBI_MINIMAL_IMAGE="registry/ubi9/minimal:9.8@sha256:' + 'a' * 64 + '"', + 'ARG UBI_MICRO_IMAGE="registry/ubi9/micro:9.8@sha256:' + 'b' * 64 + '"', + 'ARG POSTGRESQL_VERSION="18.6"', + 'ARG POSTGRESQL_RPM_VERSION="18.6-1PGDG.rhel9.8"', + ] + ) + '\n', + encoding="utf-8", + ) + bases = { + "builder": "registry/ubi9/minimal:9.8@sha256:" + "a" * 64, + "runtime": "registry/ubi9/micro:9.8@sha256:" + "b" * 64, + } + inputs = { + "postgresql_version": "18.6", + "postgresql_rpm_version": "18.6-1PGDG.rhel9.8", + "ubi_release": "9.8", + "base_images": bases, + } + self.write_json("artifacts/lock-inputs.json", inputs) + for architecture in ("amd64", "arm64"): + lock_bases = { + name: { + "reference": reference, + "digest": reference.rsplit("@", 1)[1], + } + for name, reference in bases.items() + } + self.write_json( + f"artifacts/locks/{architecture}.json", + { + "architecture": architecture, + "postgresql_version": "18.6", + "postgresql_rpm_version": "18.6-1PGDG.rhel9.8", + "base_images": lock_bases, + }, + ) + (self.root / "CHANGELOG.md").write_text( + f"# Changelog\n\n## [Unreleased]\n\n## [{GOOD_TAG}] - 2026-09-11\n", + encoding="utf-8", + ) + self.git("init", "--quiet") + self.git("config", "user.name", "Release test") + self.git("config", "user.email", "release@example.invalid") + self.git("add", ".") + self.git("commit", "--quiet", "-m", "fixture") + self.git("tag", "--annotate", GOOD_TAG, "--message", "fixture release") + + def tearDown(self): + self.temporary.cleanup() + + def write_json(self, relative: str, value: dict): + (self.root / relative).write_text(json.dumps(value), encoding="utf-8") + + def git(self, *args: str): + subprocess.run(["git", *args], cwd=self.root, check=True, capture_output=True) + + def validate(self, tag=GOOD_TAG, *options, environment=None): + env = os.environ.copy() + if environment: + env.update(environment) + return subprocess.run( + [sys.executable, os.fspath(SCRIPT), tag, "--root", os.fspath(self.root), *options], + check=False, + capture_output=True, + text=True, + env=env, + ) + + def assert_rejected(self, tag=GOOD_TAG, *options, environment=None): + self.assertNotEqual( + self.validate(tag, *options, environment=environment).returncode, 0 + ) + + def test_accepts_complete_matching_annotated_candidate(self): + result = self.validate( + GOOD_TAG, + "--require-annotated", + "--require-current-date", + "--require-next-sequence", + environment={"RELEASE_DATE_UTC": "20260911"}, + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(json.loads(result.stdout)["commit_tag"].split("-")[0], "sha") + + def test_rejects_malformed_mutable_and_impossible_dates(self): + for tag in ( + "latest", + "18", + "v18.6-ubi9-r20260230.1", + "v18.6-ubi9-r20260911.0", + "v18.6-ubi9-r20260911.01", + ): + with self.subTest(tag=tag): + self.assert_rejected(tag) + + def test_rejects_version_ubi_lock_and_changelog_mismatches(self): + for tag in ( + "v18.7-ubi9-r20260911.1", + "v18.6-ubi10-r20260911.1", + "v18.6-ubi9-r20260912.1", + ): + with self.subTest(tag=tag): + self.assert_rejected(tag) + lock = json.loads((self.root / "artifacts/locks/arm64.json").read_text()) + lock["postgresql_version"] = "18.5" + self.write_json("artifacts/locks/arm64.json", lock) + self.assert_rejected() + + def test_rejects_lightweight_tag_wrong_commit_and_moved_tag(self): + self.git("tag", "--delete", GOOD_TAG) + self.git("tag", GOOD_TAG) + self.assert_rejected(GOOD_TAG, "--require-annotated") + self.git("tag", "--delete", GOOD_TAG) + self.git("tag", "--annotate", GOOD_TAG, "--message", "moved") + self.assert_rejected( + GOOD_TAG, + "--require-annotated", + environment={"GITHUB_SHA": "0" * 40}, + ) + + def test_rejects_reused_daily_sequence(self): + second = "v18.6-ubi9-r20260911.2" + self.git("tag", "--annotate", second, "--message", "second") + self.assert_rejected(GOOD_TAG, "--require-next-sequence") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_release_workflow.py b/tests/test_release_workflow.py new file mode 100644 index 0000000..1edc608 --- /dev/null +++ b/tests/test_release_workflow.py @@ -0,0 +1,48 @@ +import re +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +WORKFLOW = ROOT / ".github/workflows/release.yml" + + +class ReleaseWorkflowPolicyTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.content = WORKFLOW.read_text(encoding="utf-8") + + def test_third_party_actions_are_immutable(self): + uses = re.findall(r"^\s*uses:\s*([^\s#]+)", self.content, re.MULTILINE) + self.assertGreater(len(uses), 0) + for reference in uses: + with self.subTest(reference=reference): + self.assertRegex(reference, r"^[^@]+@[0-9a-f]{40}$") + + def test_release_cannot_be_cancelled_or_run_from_pull_request(self): + self.assertIn("cancel-in-progress: false", self.content) + self.assertNotIn("pull_request_target:", self.content) + self.assertNotIn("pull_request:", self.content) + self.assertNotIn("workflow_dispatch:", self.content) + + def test_approval_scanning_oidc_and_release_are_separate(self): + self.assertIn("environment: release", self.content) + self.assertEqual(self.content.count("id-token: write"), 1) + self.assertIn("name: scan published digest", self.content) + self.assertIn("name: attest, sign, and independently verify", self.content) + self.assertIn("name: publish durable release evidence", self.content) + + def test_no_mutable_consumer_tags_or_release_cache(self): + self.assertNotRegex(self.content, r"type=raw,value=(latest|18|18[.]6)") + self.assertNotIn("cache-from:", self.content) + self.assertNotIn("cache-to:", self.content) + self.assertIn('${IMAGE}:${RELEASE_TAG}', self.content) + self.assertIn('${IMAGE}:${COMMIT_TAG}', self.content) + + def test_missing_evidence_is_fatal(self): + self.assertGreaterEqual(self.content.count("if-no-files-found: error"), 4) + self.assertNotIn("continue-on-error: true", self.content) + + +if __name__ == "__main__": + unittest.main() From a7ba8668563da8830392eda8695a17e11ea4738f Mon Sep 17 00:00:00 2001 From: joey-huckabee <138994589+joey-huckabee@users.noreply.github.com> Date: Fri, 11 Sep 2026 22:58:31 -0500 Subject: [PATCH 2/4] fix: harden release metadata checks --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 12 ++++++------ Containerfile | 4 ++-- scripts/build-offline.sh | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f25a7e7..1debb27 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -127,7 +127,7 @@ jobs: - name: Prove acquisition material is absent from the final image run: | - if docker history --no-trunc --format '{{.CreatedBy}}' "${TEST_IMAGE}" | grep -E 'https?://|RPM-GPG-KEY|/tmp/artifacts'; then + if docker history --no-trunc --format '{{.CreatedBy}}' "${TEST_IMAGE}" | grep -E 'download[.]postgresql[.]org|dnf-srpms[.]postgresql[.]org|cdn-ubi[.]redhat[.]com|security[.]access[.]redhat[.]com|RPM-GPG-KEY|[.]artifact-bundle|/tmp/artifacts'; then echo 'forbidden acquisition material found in final image history' >&2 exit 1 fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e3d1dcb..f8a8ad3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -147,7 +147,7 @@ jobs: --build-arg UBI_MINIMAL_IMAGE="${builder}" \ --build-arg UBI_MICRO_IMAGE="${runtime}" \ --build-arg ARTIFACT_LOCK_SHA256="${lock_sha}" \ - --build-arg POSTGRESQL_SIGNING_KEY_FINGERPRINT="${postgresql_signer}" \ + --build-arg POSTGRESQL_SIGNER_FINGERPRINT="${postgresql_signer}" \ --build-arg RELEASE_VERSION="${RELEASE_VERSION}" \ --build-arg RELEASE_REVISION="${RELEASE_REVISION}" \ --build-arg RELEASE_CREATED="${CREATED}" \ @@ -452,19 +452,19 @@ jobs: DIGEST: ${{ needs.publish.outputs.digest }} run: | cosign attest --yes --type spdxjson \ - --predicate evidence/release-evidence-${GITHUB_SHA}-amd64/image-amd64.spdx.json \ + --predicate "evidence/release-evidence-${GITHUB_SHA}-amd64/image-amd64.spdx.json" \ --bundle amd64-sbom.sigstore.json "${IMAGE}@${AMD64_DIGEST}" cosign attest --yes --type spdxjson \ - --predicate evidence/release-evidence-${GITHUB_SHA}-arm64/image-arm64.spdx.json \ + --predicate "evidence/release-evidence-${GITHUB_SHA}-arm64/image-arm64.spdx.json" \ --bundle arm64-sbom.sigstore.json "${IMAGE}@${ARM64_DIGEST}" cosign attest --yes --type https://datopsis.dev/attestations/build-provenance/v1 \ - --predicate evidence/release-evidence-${GITHUB_SHA}-amd64/provenance-amd64.json \ + --predicate "evidence/release-evidence-${GITHUB_SHA}-amd64/provenance-amd64.json" \ --bundle amd64-provenance.sigstore.json "${IMAGE}@${AMD64_DIGEST}" cosign attest --yes --type https://datopsis.dev/attestations/build-provenance/v1 \ - --predicate evidence/release-evidence-${GITHUB_SHA}-arm64/provenance-arm64.json \ + --predicate "evidence/release-evidence-${GITHUB_SHA}-arm64/provenance-arm64.json" \ --bundle arm64-provenance.sigstore.json "${IMAGE}@${ARM64_DIGEST}" cosign attest --yes --type spdxjson \ - --predicate evidence/release-evidence-${GITHUB_SHA}-manifest/image.spdx.json \ + --predicate "evidence/release-evidence-${GITHUB_SHA}-manifest/image.spdx.json" \ --bundle manifest-sbom.sigstore.json "${IMAGE}@${DIGEST}" cosign attest --yes --type https://datopsis.dev/attestations/release-evidence/v1 \ --predicate release-evidence.json --bundle release-evidence.sigstore.json \ diff --git a/Containerfile b/Containerfile index 44805ac..d6a031a 100644 --- a/Containerfile +++ b/Containerfile @@ -57,7 +57,7 @@ ARG UBI_MICRO_IMAGE ARG POSTGRESQL_VERSION="18.6" ARG POSTGRESQL_RPM_VERSION="18.6-1PGDG.rhel9.8" ARG POSTGRESQL_PUBLISHER="PostgreSQL Global Development Group (PGDG)" -ARG POSTGRESQL_SIGNING_KEY_FINGERPRINT="unknown" +ARG POSTGRESQL_SIGNER_FINGERPRINT="unknown" ARG ARTIFACT_LOCK_SHA256 ARG RELEASE_VERSION="development" ARG RELEASE_REVISION="unknown" @@ -77,7 +77,7 @@ LABEL org.opencontainers.image.title="PostgreSQL on Red Hat UBI 9" \ io.datopsis.postgresql.rpm-version="${POSTGRESQL_RPM_VERSION}" \ io.datopsis.postgresql.version="${POSTGRESQL_VERSION}" \ io.datopsis.postgresql.publisher="${POSTGRESQL_PUBLISHER}" \ - io.datopsis.postgresql.signing-key-fingerprint="${POSTGRESQL_SIGNING_KEY_FINGERPRINT}" \ + io.datopsis.postgresql.signing-key-fingerprint="${POSTGRESQL_SIGNER_FINGERPRINT}" \ io.datopsis.ubi.builder="${UBI_MINIMAL_IMAGE}" \ io.datopsis.ubi.runtime="${UBI_MICRO_IMAGE}" \ io.datopsis.artifact-lock.sha256="${ARTIFACT_LOCK_SHA256}" diff --git a/scripts/build-offline.sh b/scripts/build-offline.sh index 3a6cf1e..1f08184 100644 --- a/scripts/build-offline.sh +++ b/scripts/build-offline.sh @@ -47,7 +47,7 @@ build_arguments=(--file Containerfile --tag "${image}" --network=none \ --pull="${pull_flag}" --no-cache \ --build-arg UBI_MINIMAL_IMAGE=localhost/postgresql-ubi-builder:locked \ --build-arg UBI_MICRO_IMAGE=localhost/postgresql-ubi-runtime:locked \ - --build-arg POSTGRESQL_SIGNING_KEY_FINGERPRINT="${postgresql_signer}" \ + --build-arg POSTGRESQL_SIGNER_FINGERPRINT="${postgresql_signer}" \ --build-arg ARTIFACT_LOCK_SHA256="${lock_sha}" .) for release_argument in RELEASE_VERSION RELEASE_REVISION RELEASE_CREATED RELEASE_SOURCE; do if test -n "${!release_argument:-}"; then From bafc6ac1aac8657274137e0530eadfb8a3de754d Mon Sep 17 00:00:00 2001 From: joey-huckabee <138994589+joey-huckabee@users.noreply.github.com> Date: Fri, 11 Sep 2026 23:02:13 -0500 Subject: [PATCH 3/4] test: isolate release fixtures from actions context --- tests/test_release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_release.py b/tests/test_release.py index e0199d3..0385c06 100644 --- a/tests/test_release.py +++ b/tests/test_release.py @@ -78,6 +78,7 @@ def git(self, *args: str): def validate(self, tag=GOOD_TAG, *options, environment=None): env = os.environ.copy() + env.pop("GITHUB_SHA", None) if environment: env.update(environment) return subprocess.run( From 6bb2836b45926f90bb535e1d2cd4ba836c7932de Mon Sep 17 00:00:00 2001 From: joey-huckabee <138994589+joey-huckabee@users.noreply.github.com> Date: Fri, 11 Sep 2026 23:06:08 -0500 Subject: [PATCH 4/4] fix: pin official artifact download action --- .github/workflows/release.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f8a8ad3..b9c2216 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -257,7 +257,7 @@ jobs: arm64_digest: ${{ steps.push.outputs.arm64_digest }} steps: - name: Download exact native OCI candidates - uses: actions/download-artifact@608db3e4b26c69590a326f08f5a1c4b9b4d36179 # v8.0.1 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: pattern: release-image-${{ github.sha }}-* path: candidates @@ -420,7 +420,7 @@ jobs: packages: write steps: - name: Download complete evidence - uses: actions/download-artifact@608db3e4b26c69590a326f08f5a1c4b9b4d36179 # v8.0.1 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: pattern: release-evidence-${{ github.sha }}-* path: evidence @@ -504,7 +504,7 @@ jobs: contents: write steps: - name: Download release signatures - uses: actions/download-artifact@608db3e4b26c69590a326f08f5a1c4b9b4d36179 # v8.0.1 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: release-signatures-${{ github.sha }} path: release-assets