chore(deps): bump actions/attest from 4.1.1 to 4.2.0 in the actions-minor-patch group #90
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Docker | |
| on: | |
| pull_request: | |
| branches: [main] | |
| paths: | |
| - "notify/**" | |
| - ".github/workflows/docker.yml" | |
| - ".github/workflows/ci.yml" | |
| - ".github/workflows/security.yml" | |
| - ".github/scripts/notify-publish-safety.rb" | |
| push: | |
| branches: [main] | |
| paths: | |
| - "notify/**" | |
| - ".github/workflows/docker.yml" | |
| - ".github/workflows/ci.yml" | |
| - ".github/workflows/security.yml" | |
| - ".github/scripts/notify-publish-safety.rb" | |
| workflow_dispatch: | |
| inputs: | |
| release_tag: | |
| description: "Existing notify-vX.Y.Z source tag" | |
| required: true | |
| type: string | |
| confirmation: | |
| description: "Type PUBLISH_NOTIFY_RELEASE to publish images and binaries" | |
| required: true | |
| type: string | |
| recovery_run_id: | |
| description: "Failed tag-bound publication run to resume from main; empty for a normal tag dispatch" | |
| required: false | |
| type: string | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && github.actor == github.repository_owner && github.triggering_actor == github.repository_owner && inputs.confirmation == 'PUBLISH_NOTIFY_RELEASE' && inputs.release_tag || github.event_name == 'workflow_dispatch' && github.run_id || github.ref }} | |
| # A second owner dispatch queues behind an active publication. It may verify | |
| # and resume identical draft material but must never replace an artifact. | |
| cancel-in-progress: ${{ github.event_name != 'workflow_dispatch' }} | |
| permissions: | |
| contents: read | |
| env: | |
| RELEASE_SPEC: notify/release.json | |
| IMAGE_NAME: ghcr.io/psimaker/vaultsync-notify | |
| jobs: | |
| publish-safety-policy: | |
| name: Publish Safety Policy | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| steps: | |
| - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 | |
| with: | |
| persist-credentials: false | |
| - name: Verify publication remains owner-gated and immutable | |
| run: ruby .github/scripts/notify-publish-safety.rb | |
| notify-guard: | |
| name: Notify Guard Tests | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| steps: | |
| - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 | |
| with: | |
| persist-credentials: false | |
| - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 | |
| with: | |
| go-version-file: notify/go.mod | |
| - name: Run notify test suite | |
| working-directory: notify | |
| run: go test ./... -count=1 | |
| build-without-push: | |
| name: Build Image (no push) | |
| if: github.event_name != 'workflow_dispatch' | |
| needs: | |
| - publish-safety-policy | |
| - notify-guard | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 | |
| steps: | |
| - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 | |
| with: | |
| persist-credentials: false | |
| - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 | |
| - name: Build local validation image | |
| uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 | |
| with: | |
| context: notify | |
| load: true | |
| push: false | |
| platforms: linux/amd64 | |
| tags: vaultsync-notify:ci-${{ github.sha }} | |
| build-args: | | |
| VERSION=dev-${{ github.sha }} | |
| provenance: false | |
| sbom: false | |
| - name: Verify the minimal runtime image | |
| run: | | |
| set -euo pipefail | |
| image="vaultsync-notify:ci-${GITHUB_SHA}" | |
| test "$(docker image inspect --format '{{.Config.User}}' "$image")" = "65534:65534" | |
| test "$(docker run --rm --network none --read-only --cap-drop ALL \ | |
| --security-opt no-new-privileges "$image" --version)" = "vaultsync-notify dev-${GITHUB_SHA}" | |
| if docker run --rm --network none --entrypoint /bin/sh "$image" -c true >/dev/null 2>&1; then | |
| echo "runtime image unexpectedly contains a shell" >&2 | |
| exit 1 | |
| fi | |
| - name: Scan local validation image | |
| uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 | |
| with: | |
| image-ref: vaultsync-notify:ci-${{ github.sha }} | |
| scanners: vuln,secret | |
| severity: HIGH,CRITICAL | |
| ignore-unfixed: true | |
| exit-code: "1" | |
| publish-gate: | |
| name: Owner Publish Gate | |
| if: github.event_name == 'workflow_dispatch' | |
| needs: | |
| - publish-safety-policy | |
| - notify-guard | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| permissions: | |
| actions: read | |
| contents: read | |
| outputs: | |
| version: ${{ steps.validate.outputs.version }} | |
| release_tag: ${{ steps.validate.outputs.release_tag }} | |
| release_sha: ${{ steps.validate.outputs.release_sha }} | |
| image: ${{ steps.validate.outputs.image }} | |
| rollback_image: ${{ steps.validate.outputs.rollback_image }} | |
| rollback_commit: ${{ steps.validate.outputs.rollback_commit }} | |
| release_is_public: ${{ steps.validate.outputs.release_is_public }} | |
| recovery_mode: ${{ steps.validate.outputs.recovery_mode }} | |
| steps: | |
| - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 | |
| with: | |
| fetch-depth: 0 | |
| persist-credentials: false | |
| - name: Validate owner, manifest, tag, commit, and draft state | |
| id: validate | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| EVENT_NAME: ${{ github.event_name }} | |
| REF_TYPE: ${{ github.ref_type }} | |
| REF_NAME: ${{ github.ref_name }} | |
| RELEASE_TAG_INPUT: ${{ inputs.release_tag }} | |
| CONFIRMATION: ${{ inputs.confirmation }} | |
| RECOVERY_RUN_ID: ${{ inputs.recovery_run_id }} | |
| ACTOR: ${{ github.actor }} | |
| TRIGGERING_ACTOR: ${{ github.triggering_actor }} | |
| REPOSITORY_OWNER: ${{ github.repository_owner }} | |
| WORKFLOW_SHA: ${{ github.sha }} | |
| run: | | |
| set -euo pipefail | |
| test "$EVENT_NAME" = workflow_dispatch | |
| test "$ACTOR" = "$REPOSITORY_OWNER" | |
| test "$TRIGGERING_ACTOR" = "$REPOSITORY_OWNER" | |
| test "$CONFIRMATION" = PUBLISH_NOTIFY_RELEASE | |
| version=$(jq -er '.version | select(test("^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$"))' "$RELEASE_SPEC") | |
| release_tag=$(jq -er '.tag' "$RELEASE_SPEC") | |
| image=$(jq -er '.image' "$RELEASE_SPEC") | |
| version_image=$(jq -er '.version_image' "$RELEASE_SPEC") | |
| rollback_tag=$(jq -er '.rollback.tag' "$RELEASE_SPEC") | |
| rollback_commit=$(jq -er '.rollback.commit | select(test("^[0-9a-f]{40}$"))' "$RELEASE_SPEC") | |
| rollback_image=$(jq -er '.rollback.image | select(test("^ghcr\\.io/psimaker/vaultsync-notify@sha256:[0-9a-f]{64}$"))' "$RELEASE_SPEC") | |
| test "$release_tag" = "notify-v${version}" | |
| test "$image" = "$IMAGE_NAME" | |
| test "$version_image" = "${IMAGE_NAME}:${version}" | |
| test "$RELEASE_TAG_INPUT" = "$release_tag" | |
| git fetch origin main --tags | |
| test "$(git cat-file -t "refs/tags/${release_tag}")" = commit | |
| resolved=$(git rev-parse --verify "refs/tags/${release_tag}^{commit}") | |
| git show-ref --verify --quiet refs/remotes/origin/main | |
| git merge-base --is-ancestor "$resolved" refs/remotes/origin/main | |
| test "$(gh api "repos/${GITHUB_REPOSITORY}/commits/${resolved}" --jq .commit.verification.verified)" = true | |
| recovery_mode=false | |
| case "$REF_TYPE" in | |
| tag) | |
| test "$REF_NAME" = "$release_tag" | |
| test -z "$RECOVERY_RUN_ID" | |
| test "$WORKFLOW_SHA" = "$resolved" | |
| ;; | |
| branch) | |
| test "$REF_NAME" = main | |
| printf '%s\n' "$RECOVERY_RUN_ID" | grep -Eq '^[1-9][0-9]*$' | |
| test "$WORKFLOW_SHA" = "$(git rev-parse refs/remotes/origin/main)" | |
| test "$(gh api "repos/${GITHUB_REPOSITORY}/commits/${WORKFLOW_SHA}" --jq .commit.verification.verified)" = true | |
| recovery_json=$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${RECOVERY_RUN_ID}") | |
| test "$(jq -r .id <<<"$recovery_json")" = "$RECOVERY_RUN_ID" | |
| test "$(jq -r .event <<<"$recovery_json")" = workflow_dispatch | |
| test "$(jq -r .path <<<"$recovery_json")" = .github/workflows/docker.yml | |
| test "$(jq -r .head_branch <<<"$recovery_json")" = "$release_tag" | |
| test "$(jq -r .head_sha <<<"$recovery_json")" = "$resolved" | |
| test "$(jq -r .conclusion <<<"$recovery_json")" = failure | |
| test "$(jq -r .actor.login <<<"$recovery_json")" = "$REPOSITORY_OWNER" | |
| test "$(jq -r .triggering_actor.login <<<"$recovery_json")" = "$REPOSITORY_OWNER" | |
| recovery_jobs=$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${RECOVERY_RUN_ID}/jobs?per_page=100") | |
| test "$(jq '[.jobs[] | select(.name == "Stage Release Binaries" and .conclusion == "failure")] | length' <<<"$recovery_jobs")" = 1 | |
| test "$(jq '[.jobs[] | select(.name == "Stage Release Binaries") | .steps[] | select(.name == "Create or verify the exact draft release and assets" and .conclusion == "failure")] | length' <<<"$recovery_jobs")" = 1 | |
| test "$(jq '[.jobs[] | select(.name == "Publish Image" and .conclusion == "success")] | length' <<<"$recovery_jobs")" = 1 | |
| test "$(jq '[.jobs[] | select(.name == "Published Helper Rollout" and .conclusion == "skipped")] | length' <<<"$recovery_jobs")" = 1 | |
| test "$(jq '[.jobs[] | select(.name == "Finalize Helper Release" and .conclusion == "skipped")] | length' <<<"$recovery_jobs")" = 1 | |
| recovery_mode=true | |
| ;; | |
| *) exit 1 ;; | |
| esac | |
| test "$(git rev-parse --verify "refs/tags/${rollback_tag}^{commit}")" = "$rollback_commit" | |
| gh release view "$rollback_tag" --repo "$GITHUB_REPOSITORY" >/dev/null | |
| release_is_public=false | |
| repository_name=${GITHUB_REPOSITORY#*/} | |
| test "$GITHUB_REPOSITORY" = "${REPOSITORY_OWNER}/${repository_name}" | |
| # GraphQL variables must remain literal for gh. | |
| # shellcheck disable=SC2016 | |
| release_lookup=$(gh api graphql \ | |
| -F owner="$REPOSITORY_OWNER" -F name="$repository_name" -F tag="$release_tag" \ | |
| -f query='query($owner: String!, $name: String!, $tag: String!) { | |
| repository(owner: $owner, name: $name) { | |
| release(tagName: $tag) { tagName isDraft isPrerelease publishedAt } | |
| } | |
| }') | |
| jq -e '.data.repository != null and ((.errors // []) | length == 0)' <<<"$release_lookup" >/dev/null | |
| release_json=$(jq -c '.data.repository.release' <<<"$release_lookup") | |
| release_type=$(jq -r 'type' <<<"$release_json") | |
| case $release_type in | |
| null) ;; | |
| object) | |
| test "$(jq -r .tagName <<<"$release_json")" = "$release_tag" | |
| test "$(jq -r .isPrerelease <<<"$release_json")" = false | |
| case $(jq -r .isDraft <<<"$release_json") in | |
| true) ;; | |
| false) | |
| jq -er '.publishedAt | select(type == "string" and length > 0)' <<<"$release_json" >/dev/null | |
| release_is_public=true | |
| ;; | |
| *) exit 1 ;; | |
| esac | |
| ;; | |
| *) exit 1 ;; | |
| esac | |
| if [ "$recovery_mode" = true ] && [ "$release_type" = null ]; then | |
| # GitHub exposes draft releases only to tokens with push access. Keep | |
| # this gate read-only; release-binaries resolves the exact draft with | |
| # its isolated contents:write token and refuses to recreate a missing | |
| # draft during recovery before performing any release mutation. | |
| echo "Draft visibility deferred to the exact recovery staging gate." | |
| fi | |
| manifest_digest=$(sha256sum "$RELEASE_SPEC" | awk '{print $1}') | |
| git checkout --detach "$resolved" | |
| test "$(sha256sum "$RELEASE_SPEC" | awk '{print $1}')" = "$manifest_digest" | |
| { | |
| echo "version=$version" | |
| echo "release_tag=$release_tag" | |
| echo "release_sha=$resolved" | |
| echo "image=$image" | |
| echo "rollback_image=$rollback_image" | |
| echo "rollback_commit=$rollback_commit" | |
| echo "release_is_public=$release_is_public" | |
| echo "recovery_mode=$recovery_mode" | |
| } >> "$GITHUB_OUTPUT" | |
| - name: Scan the exact helper source before any publication mutation | |
| uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 | |
| with: | |
| scan-type: fs | |
| scan-ref: notify | |
| scanners: vuln,secret | |
| severity: HIGH,CRITICAL | |
| ignore-unfixed: true | |
| exit-code: "1" | |
| publish-image: | |
| name: Publish Image | |
| if: >- | |
| github.event_name == 'workflow_dispatch' && | |
| github.ref_type == 'tag' && | |
| inputs.release_tag == github.ref_name && | |
| inputs.recovery_run_id == '' && | |
| inputs.confirmation == 'PUBLISH_NOTIFY_RELEASE' && | |
| github.actor == github.repository_owner && | |
| github.triggering_actor == github.repository_owner | |
| needs: | |
| - publish-safety-policy | |
| - notify-guard | |
| - publish-gate | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 30 | |
| permissions: | |
| contents: read | |
| packages: write | |
| id-token: write | |
| attestations: write | |
| outputs: | |
| digest: ${{ steps.select-image.outputs.digest }} | |
| steps: | |
| - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 | |
| with: | |
| ref: ${{ needs.publish-gate.outputs.release_sha }} | |
| persist-credentials: false | |
| - uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4 | |
| - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 | |
| - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.repository_owner }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Detect an existing exact release image | |
| id: existing-image | |
| env: | |
| IMAGE: ${{ needs.publish-gate.outputs.image }} | |
| VERSION: ${{ needs.publish-gate.outputs.version }} | |
| RELEASE_SHA: ${{ needs.publish-gate.outputs.release_sha }} | |
| RELEASE_IS_PUBLIC: ${{ needs.publish-gate.outputs.release_is_public }} | |
| run: | | |
| set -euo pipefail | |
| token=$(curl -fsSL "https://ghcr.io/token?scope=repository:psimaker/vaultsync-notify:pull" | jq -er .token) | |
| http_status=$(curl -sS -o /tmp/vaultsync-notify-version-manifest.json -w '%{http_code}' \ | |
| -H "Authorization: Bearer $token" \ | |
| -H 'Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json' \ | |
| "https://ghcr.io/v2/psimaker/vaultsync-notify/manifests/${VERSION}") | |
| if [ "$http_status" = 200 ]; then | |
| manifest=$(docker buildx imagetools inspect "${IMAGE}:${VERSION}" --format '{{json .Manifest}}') | |
| digest=$(jq -er '.digest | select(test("^sha256:[0-9a-f]{64}$"))' <<<"$manifest") | |
| docker pull "${IMAGE}@${digest}" >/dev/null | |
| test "$(docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "${IMAGE}@${digest}")" = "$RELEASE_SHA" | |
| test "$(docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.version"}}' "${IMAGE}@${digest}")" = "$VERSION" | |
| test "$(docker run --rm --network none --read-only --cap-drop ALL --security-opt no-new-privileges "${IMAGE}@${digest}" --version)" = "vaultsync-notify $VERSION" | |
| echo "present=true" >> "$GITHUB_OUTPUT" | |
| echo "digest=$digest" >> "$GITHUB_OUTPUT" | |
| elif [ "$http_status" = 404 ]; then | |
| if [ "$RELEASE_IS_PUBLIC" = true ]; then | |
| echo "public release image is missing; refusing to publish on a finalized release" >&2 | |
| exit 1 | |
| fi | |
| echo "present=false" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "GHCR lookup failed with HTTP ${http_status}; refusing to treat the version tag as absent" >&2 | |
| exit 1 | |
| fi | |
| - name: Derive version-only image metadata | |
| uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6 | |
| id: meta | |
| with: | |
| images: ${{ needs.publish-gate.outputs.image }} | |
| tags: | | |
| type=raw,value=${{ needs.publish-gate.outputs.version }} | |
| - name: Build and publish the absent release image | |
| if: >- | |
| needs.publish-gate.outputs.release_is_public != 'true' && | |
| steps.existing-image.outputs.present != 'true' | |
| uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 | |
| id: build | |
| with: | |
| context: notify | |
| push: true | |
| platforms: linux/amd64,linux/arm64 | |
| tags: ${{ steps.meta.outputs.tags }} | |
| labels: ${{ steps.meta.outputs.labels }} | |
| build-args: | | |
| VERSION=${{ needs.publish-gate.outputs.version }} | |
| provenance: mode=max | |
| sbom: true | |
| - name: Select and revalidate the immutable image digest | |
| id: select-image | |
| env: | |
| IMAGE: ${{ needs.publish-gate.outputs.image }} | |
| VERSION: ${{ needs.publish-gate.outputs.version }} | |
| RELEASE_SHA: ${{ needs.publish-gate.outputs.release_sha }} | |
| EXISTING_DIGEST: ${{ steps.existing-image.outputs.digest }} | |
| BUILT_DIGEST: ${{ steps.build.outputs.digest }} | |
| run: | | |
| set -euo pipefail | |
| digest=${BUILT_DIGEST:-$EXISTING_DIGEST} | |
| printf '%s\n' "$digest" | grep -Eq '^sha256:[0-9a-f]{64}$' | |
| current=$(docker buildx imagetools inspect "${IMAGE}:${VERSION}" --format '{{json .Manifest}}' | jq -er .digest) | |
| test "$current" = "$digest" | |
| docker pull "${IMAGE}@${digest}" >/dev/null | |
| test "$(docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "${IMAGE}@${digest}")" = "$RELEASE_SHA" | |
| test "$(docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.version"}}' "${IMAGE}@${digest}")" = "$VERSION" | |
| test "$(docker run --rm --network none --read-only --cap-drop ALL --security-opt no-new-privileges "${IMAGE}@${digest}" --version)" = "vaultsync-notify $VERSION" | |
| echo "digest=$digest" >> "$GITHUB_OUTPUT" | |
| - name: Scan published image | |
| uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 | |
| with: | |
| image-ref: ${{ needs.publish-gate.outputs.image }}@${{ steps.select-image.outputs.digest }} | |
| scanners: vuln,secret | |
| severity: HIGH,CRITICAL | |
| ignore-unfixed: true | |
| exit-code: "1" | |
| - name: Create the image SPDX SBOM | |
| uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 | |
| with: | |
| scan-type: image | |
| image-ref: ${{ needs.publish-gate.outputs.image }}@${{ steps.select-image.outputs.digest }} | |
| format: spdx-json | |
| output: /tmp/vaultsync-notify-image.spdx.json | |
| - name: Check for existing repository provenance | |
| id: image-provenance | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| IMAGE: ${{ needs.publish-gate.outputs.image }} | |
| DIGEST: ${{ steps.select-image.outputs.digest }} | |
| RELEASE_SHA: ${{ needs.publish-gate.outputs.release_sha }} | |
| RELEASE_TAG: ${{ needs.publish-gate.outputs.release_tag }} | |
| RELEASE_IS_PUBLIC: ${{ needs.publish-gate.outputs.release_is_public }} | |
| run: | | |
| set -euo pipefail | |
| if gh attestation verify "oci://${IMAGE}@${DIGEST}" --repo "$GITHUB_REPOSITORY" \ | |
| --source-digest "$RELEASE_SHA" --source-ref "refs/tags/${RELEASE_TAG}" \ | |
| --signer-workflow "${GITHUB_REPOSITORY}/.github/workflows/docker.yml" >/dev/null 2>&1; then | |
| echo "present=true" >> "$GITHUB_OUTPUT" | |
| else | |
| if [ "$RELEASE_IS_PUBLIC" = true ]; then | |
| echo "public image provenance is missing or mismatched; refusing to create it" >&2 | |
| exit 1 | |
| fi | |
| echo "present=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Attest image provenance | |
| if: >- | |
| needs.publish-gate.outputs.release_is_public != 'true' && | |
| steps.image-provenance.outputs.present != 'true' | |
| uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0 | |
| with: | |
| subject-name: ${{ needs.publish-gate.outputs.image }} | |
| subject-digest: ${{ steps.select-image.outputs.digest }} | |
| push-to-registry: true | |
| - name: Check for existing repository SBOM attestation | |
| id: image-sbom | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| IMAGE: ${{ needs.publish-gate.outputs.image }} | |
| DIGEST: ${{ steps.select-image.outputs.digest }} | |
| RELEASE_SHA: ${{ needs.publish-gate.outputs.release_sha }} | |
| RELEASE_TAG: ${{ needs.publish-gate.outputs.release_tag }} | |
| RELEASE_IS_PUBLIC: ${{ needs.publish-gate.outputs.release_is_public }} | |
| run: | | |
| set -euo pipefail | |
| if gh attestation verify "oci://${IMAGE}@${DIGEST}" --repo "$GITHUB_REPOSITORY" \ | |
| --predicate-type https://spdx.dev/Document/v2.3 \ | |
| --source-digest "$RELEASE_SHA" --source-ref "refs/tags/${RELEASE_TAG}" \ | |
| --signer-workflow "${GITHUB_REPOSITORY}/.github/workflows/docker.yml" >/dev/null 2>&1; then | |
| echo "present=true" >> "$GITHUB_OUTPUT" | |
| else | |
| if [ "$RELEASE_IS_PUBLIC" = true ]; then | |
| echo "public image SBOM attestation is missing or mismatched; refusing to create it" >&2 | |
| exit 1 | |
| fi | |
| echo "present=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Attest image SBOM | |
| if: >- | |
| needs.publish-gate.outputs.release_is_public != 'true' && | |
| steps.image-sbom.outputs.present != 'true' | |
| uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0 | |
| with: | |
| subject-name: ${{ needs.publish-gate.outputs.image }} | |
| subject-digest: ${{ steps.select-image.outputs.digest }} | |
| sbom-path: /tmp/vaultsync-notify-image.spdx.json | |
| push-to-registry: true | |
| recover-image: | |
| name: Recover Published Image (read-only) | |
| if: >- | |
| github.event_name == 'workflow_dispatch' && | |
| github.ref == 'refs/heads/main' && | |
| inputs.recovery_run_id != '' && | |
| inputs.confirmation == 'PUBLISH_NOTIFY_RELEASE' && | |
| github.actor == github.repository_owner && | |
| github.triggering_actor == github.repository_owner | |
| needs: | |
| - publish-safety-policy | |
| - notify-guard | |
| - publish-gate | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 | |
| permissions: | |
| contents: read | |
| packages: read | |
| outputs: | |
| digest: ${{ steps.verify-image.outputs.digest }} | |
| steps: | |
| - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 | |
| with: | |
| ref: ${{ needs.publish-gate.outputs.release_sha }} | |
| persist-credentials: false | |
| - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 | |
| - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.repository_owner }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Verify the immutable recovery image and attestations | |
| id: verify-image | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| IMAGE: ${{ needs.publish-gate.outputs.image }} | |
| VERSION: ${{ needs.publish-gate.outputs.version }} | |
| RELEASE_SHA: ${{ needs.publish-gate.outputs.release_sha }} | |
| RELEASE_TAG: ${{ needs.publish-gate.outputs.release_tag }} | |
| run: | | |
| set -euo pipefail | |
| manifest=$(docker buildx imagetools inspect "${IMAGE}:${VERSION}" --format '{{json .Manifest}}') | |
| digest=$(jq -er '.digest | select(test("^sha256:[0-9a-f]{64}$"))' <<<"$manifest") | |
| test "$(jq '[.manifests[] | select(.platform.os == "linux" and .platform.architecture == "amd64")] | length' <<<"$manifest")" = 1 | |
| test "$(jq '[.manifests[] | select(.platform.os == "linux" and .platform.architecture == "arm64")] | length' <<<"$manifest")" = 1 | |
| docker pull "${IMAGE}@${digest}" >/dev/null | |
| test "$(docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "${IMAGE}@${digest}")" = "$RELEASE_SHA" | |
| test "$(docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.version"}}' "${IMAGE}@${digest}")" = "$VERSION" | |
| test "$(docker run --rm --network none --read-only --cap-drop ALL --security-opt no-new-privileges "${IMAGE}@${digest}" --version)" = "vaultsync-notify $VERSION" | |
| gh attestation verify "oci://${IMAGE}@${digest}" --repo "$GITHUB_REPOSITORY" \ | |
| --source-digest "$RELEASE_SHA" --source-ref "refs/tags/${RELEASE_TAG}" \ | |
| --signer-workflow "${GITHUB_REPOSITORY}/.github/workflows/docker.yml" >/dev/null | |
| gh attestation verify "oci://${IMAGE}@${digest}" --repo "$GITHUB_REPOSITORY" \ | |
| --predicate-type https://spdx.dev/Document/v2.3 \ | |
| --source-digest "$RELEASE_SHA" --source-ref "refs/tags/${RELEASE_TAG}" \ | |
| --signer-workflow "${GITHUB_REPOSITORY}/.github/workflows/docker.yml" >/dev/null | |
| echo "digest=$digest" >> "$GITHUB_OUTPUT" | |
| - name: Scan the immutable recovery image | |
| uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 | |
| with: | |
| image-ref: ${{ needs.publish-gate.outputs.image }}@${{ steps.verify-image.outputs.digest }} | |
| scanners: vuln,secret | |
| severity: HIGH,CRITICAL | |
| ignore-unfixed: true | |
| exit-code: "1" | |
| image-ready: | |
| name: Select Immutable Helper Image | |
| if: >- | |
| always() && | |
| github.event_name == 'workflow_dispatch' && | |
| inputs.confirmation == 'PUBLISH_NOTIFY_RELEASE' && | |
| github.actor == github.repository_owner && | |
| github.triggering_actor == github.repository_owner && | |
| needs.publish-safety-policy.result == 'success' && | |
| needs.notify-guard.result == 'success' && | |
| needs.publish-gate.result == 'success' && | |
| ((needs.publish-gate.outputs.recovery_mode == 'true' && | |
| needs.recover-image.result == 'success' && needs.publish-image.result == 'skipped') || | |
| (needs.publish-gate.outputs.recovery_mode != 'true' && | |
| needs.publish-image.result == 'success' && needs.recover-image.result == 'skipped')) | |
| needs: | |
| - publish-safety-policy | |
| - notify-guard | |
| - publish-gate | |
| - publish-image | |
| - recover-image | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| permissions: | |
| contents: read | |
| outputs: | |
| digest: ${{ steps.select.outputs.digest }} | |
| steps: | |
| - name: Select the only eligible immutable digest | |
| id: select | |
| env: | |
| RECOVERY_MODE: ${{ needs.publish-gate.outputs.recovery_mode }} | |
| PUBLISHED_DIGEST: ${{ needs.publish-image.outputs.digest }} | |
| RECOVERED_DIGEST: ${{ needs.recover-image.outputs.digest }} | |
| run: | | |
| set -euo pipefail | |
| if [ "$RECOVERY_MODE" = true ]; then | |
| test -z "$PUBLISHED_DIGEST" | |
| digest=$RECOVERED_DIGEST | |
| else | |
| test -z "$RECOVERED_DIGEST" | |
| digest=$PUBLISHED_DIGEST | |
| fi | |
| printf '%s\n' "$digest" | grep -Eq '^sha256:[0-9a-f]{64}$' | |
| echo "digest=$digest" >> "$GITHUB_OUTPUT" | |
| attest-binaries: | |
| name: Attest Release Binaries | |
| if: >- | |
| always() && | |
| github.event_name == 'workflow_dispatch' && | |
| github.ref_type == 'tag' && | |
| inputs.release_tag == github.ref_name && | |
| inputs.recovery_run_id == '' && | |
| inputs.confirmation == 'PUBLISH_NOTIFY_RELEASE' && | |
| github.actor == github.repository_owner && | |
| github.triggering_actor == github.repository_owner && | |
| needs.publish-safety-policy.result == 'success' && | |
| needs.notify-guard.result == 'success' && | |
| needs.publish-gate.result == 'success' && | |
| needs.image-ready.result == 'success' | |
| needs: | |
| - publish-safety-policy | |
| - notify-guard | |
| - publish-gate | |
| - image-ready | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 | |
| permissions: | |
| contents: read | |
| id-token: write | |
| attestations: write | |
| steps: | |
| - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 | |
| with: | |
| ref: ${{ needs.publish-gate.outputs.release_sha }} | |
| persist-credentials: false | |
| - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 | |
| with: | |
| go-version-file: notify/go.mod | |
| cache: false | |
| - name: Cross-compile every binary twice for attestation | |
| working-directory: notify | |
| env: | |
| RELEASE_VERSION: ${{ needs.publish-gate.outputs.version }} | |
| run: | | |
| set -euo pipefail | |
| rm -rf dist verify-dist | |
| mkdir -p dist verify-dist | |
| mapfile -t binaries < <(jq -r '.binaries[]' release.json) | |
| for output_directory in dist verify-dist; do | |
| for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64; do | |
| goos=${target%/*} | |
| goarch=${target#*/} | |
| out="${output_directory}/vaultsync-notify_${goos}_${goarch}" | |
| [ "$goos" = windows ] && out="$out.exe" | |
| GOOS="$goos" GOARCH="$goarch" CGO_ENABLED=0 go build -trimpath -buildvcs=false \ | |
| -ldflags="-s -w -X main.version=${RELEASE_VERSION}" -o "$out" . | |
| done | |
| done | |
| for name in "${binaries[@]}"; do | |
| cmp "dist/$name" "verify-dist/$name" | |
| done | |
| (cd dist && sha256sum -- "${binaries[@]}" > SHA256SUMS) | |
| - name: Scan attested binaries for vulnerabilities and secrets | |
| uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 | |
| with: | |
| scan-type: rootfs | |
| scan-ref: notify/dist | |
| scanners: vuln,secret | |
| severity: HIGH,CRITICAL | |
| ignore-unfixed: true | |
| exit-code: "1" | |
| - name: Create deterministic attestation SPDX SBOM | |
| uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 | |
| with: | |
| scan-type: rootfs | |
| scan-ref: notify/dist | |
| format: spdx-json | |
| output: notify/dist/SBOM.spdx.json | |
| - name: Normalize deterministic attestation SBOM metadata | |
| env: | |
| RELEASE_TAG: ${{ needs.publish-gate.outputs.release_tag }} | |
| RELEASE_SHA: ${{ needs.publish-gate.outputs.release_sha }} | |
| run: | | |
| set -euo pipefail | |
| jq --arg namespace "https://github.com/${GITHUB_REPOSITORY}/sbom/${RELEASE_TAG}/${RELEASE_SHA}" \ | |
| '.creationInfo.created = "1970-01-01T00:00:00Z" | |
| | .documentNamespace = $namespace | |
| | walk(if type == "object" and has("annotationDate") | |
| then .annotationDate = "1970-01-01T00:00:00Z" | |
| else . end)' \ | |
| notify/dist/SBOM.spdx.json > /tmp/SBOM.spdx.json | |
| mv /tmp/SBOM.spdx.json notify/dist/SBOM.spdx.json | |
| - name: Check for existing exact binary provenance | |
| id: binary-provenance | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| RELEASE_SHA: ${{ needs.publish-gate.outputs.release_sha }} | |
| RELEASE_TAG: ${{ needs.publish-gate.outputs.release_tag }} | |
| RELEASE_IS_PUBLIC: ${{ needs.publish-gate.outputs.release_is_public }} | |
| run: | | |
| set -euo pipefail | |
| present=true | |
| while IFS= read -r name; do | |
| if ! gh attestation verify "notify/dist/$name" --repo "$GITHUB_REPOSITORY" \ | |
| --source-digest "$RELEASE_SHA" --source-ref "refs/tags/${RELEASE_TAG}" \ | |
| --signer-workflow "${GITHUB_REPOSITORY}/.github/workflows/docker.yml" >/dev/null 2>&1; then | |
| present=false | |
| fi | |
| done < <(jq -r '.binaries[]' "$RELEASE_SPEC") | |
| if [ "$RELEASE_IS_PUBLIC" = true ] && [ "$present" != true ]; then | |
| echo "public binary provenance is missing or mismatched; refusing to create it" >&2 | |
| exit 1 | |
| fi | |
| echo "present=$present" >> "$GITHUB_OUTPUT" | |
| - name: Attest binary provenance | |
| if: >- | |
| needs.publish-gate.outputs.release_is_public != 'true' && | |
| steps.binary-provenance.outputs.present != 'true' | |
| uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0 | |
| with: | |
| subject-path: notify/dist/vaultsync-notify_* | |
| - name: Check for existing exact binary SBOM attestation | |
| id: binary-sbom | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| RELEASE_SHA: ${{ needs.publish-gate.outputs.release_sha }} | |
| RELEASE_TAG: ${{ needs.publish-gate.outputs.release_tag }} | |
| RELEASE_IS_PUBLIC: ${{ needs.publish-gate.outputs.release_is_public }} | |
| run: | | |
| set -euo pipefail | |
| present=true | |
| while IFS= read -r name; do | |
| if ! gh attestation verify "notify/dist/$name" --repo "$GITHUB_REPOSITORY" \ | |
| --predicate-type https://spdx.dev/Document/v2.3 \ | |
| --source-digest "$RELEASE_SHA" --source-ref "refs/tags/${RELEASE_TAG}" \ | |
| --signer-workflow "${GITHUB_REPOSITORY}/.github/workflows/docker.yml" >/dev/null 2>&1; then | |
| present=false | |
| fi | |
| done < <(jq -r '.binaries[]' "$RELEASE_SPEC") | |
| if [ "$RELEASE_IS_PUBLIC" = true ] && [ "$present" != true ]; then | |
| echo "public binary SBOM attestation is missing or mismatched; refusing to create it" >&2 | |
| exit 1 | |
| fi | |
| echo "present=$present" >> "$GITHUB_OUTPUT" | |
| - name: Attest binary SBOM | |
| if: >- | |
| needs.publish-gate.outputs.release_is_public != 'true' && | |
| steps.binary-sbom.outputs.present != 'true' | |
| uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0 | |
| with: | |
| subject-path: notify/dist/vaultsync-notify_* | |
| sbom-path: notify/dist/SBOM.spdx.json | |
| binary-attestation-ready: | |
| name: Require Immutable Binary Attestations | |
| if: >- | |
| always() && | |
| github.event_name == 'workflow_dispatch' && | |
| inputs.confirmation == 'PUBLISH_NOTIFY_RELEASE' && | |
| github.actor == github.repository_owner && | |
| github.triggering_actor == github.repository_owner && | |
| needs.publish-safety-policy.result == 'success' && | |
| needs.notify-guard.result == 'success' && | |
| needs.publish-gate.result == 'success' && | |
| needs.image-ready.result == 'success' && | |
| ((needs.publish-gate.outputs.recovery_mode == 'true' && | |
| needs.attest-binaries.result == 'skipped') || | |
| (needs.publish-gate.outputs.recovery_mode != 'true' && | |
| needs.attest-binaries.result == 'success')) | |
| needs: | |
| - publish-safety-policy | |
| - notify-guard | |
| - publish-gate | |
| - image-ready | |
| - attest-binaries | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Confirm the only eligible attestation path | |
| env: | |
| RECOVERY_MODE: ${{ needs.publish-gate.outputs.recovery_mode }} | |
| ATTESTATION_RESULT: ${{ needs.attest-binaries.result }} | |
| run: | | |
| set -euo pipefail | |
| if [ "$RECOVERY_MODE" = true ]; then | |
| test "$ATTESTATION_RESULT" = skipped | |
| else | |
| test "$ATTESTATION_RESULT" = success | |
| fi | |
| release-binaries: | |
| name: Stage Release Binaries | |
| if: >- | |
| always() && | |
| github.event_name == 'workflow_dispatch' && | |
| ((github.ref_type == 'tag' && inputs.release_tag == github.ref_name && inputs.recovery_run_id == '') || | |
| (github.ref == 'refs/heads/main' && inputs.recovery_run_id != '')) && | |
| inputs.confirmation == 'PUBLISH_NOTIFY_RELEASE' && | |
| github.actor == github.repository_owner && | |
| github.triggering_actor == github.repository_owner && | |
| needs.publish-safety-policy.result == 'success' && | |
| needs.notify-guard.result == 'success' && | |
| needs.publish-gate.result == 'success' && | |
| needs.image-ready.result == 'success' && | |
| needs.binary-attestation-ready.result == 'success' | |
| needs: | |
| - publish-safety-policy | |
| - notify-guard | |
| - publish-gate | |
| - image-ready | |
| - binary-attestation-ready | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 | |
| permissions: | |
| contents: write | |
| steps: | |
| - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 | |
| with: | |
| ref: ${{ needs.publish-gate.outputs.release_sha }} | |
| fetch-depth: 0 | |
| persist-credentials: false | |
| - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 | |
| with: | |
| go-version-file: notify/go.mod | |
| cache: false | |
| - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 | |
| - name: Cross-compile every binary twice | |
| working-directory: notify | |
| env: | |
| RELEASE_VERSION: ${{ needs.publish-gate.outputs.version }} | |
| run: | | |
| set -euo pipefail | |
| rm -rf dist verify-dist | |
| mkdir -p dist verify-dist | |
| mapfile -t binaries < <(jq -r '.binaries[]' release.json) | |
| for output_directory in dist verify-dist; do | |
| for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64; do | |
| goos=${target%/*} | |
| goarch=${target#*/} | |
| out="${output_directory}/vaultsync-notify_${goos}_${goarch}" | |
| [ "$goos" = windows ] && out="$out.exe" | |
| GOOS="$goos" GOARCH="$goarch" CGO_ENABLED=0 go build -trimpath -buildvcs=false \ | |
| -ldflags="-s -w -X main.version=${RELEASE_VERSION}" -o "$out" . | |
| done | |
| done | |
| for name in "${binaries[@]}"; do | |
| cmp "dist/$name" "verify-dist/$name" | |
| done | |
| (cd dist && sha256sum -- "${binaries[@]}" > SHA256SUMS) | |
| - name: Scan release binaries for vulnerabilities and secrets | |
| uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 | |
| with: | |
| scan-type: rootfs | |
| scan-ref: notify/dist | |
| scanners: vuln,secret | |
| severity: HIGH,CRITICAL | |
| ignore-unfixed: true | |
| exit-code: "1" | |
| - name: Create deterministic binary-set SPDX SBOM | |
| uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 | |
| with: | |
| scan-type: rootfs | |
| scan-ref: notify/dist | |
| format: spdx-json | |
| output: notify/dist/SBOM.spdx.json | |
| - name: Normalize deterministic SBOM metadata | |
| env: | |
| RELEASE_TAG: ${{ needs.publish-gate.outputs.release_tag }} | |
| RELEASE_SHA: ${{ needs.publish-gate.outputs.release_sha }} | |
| run: | | |
| set -euo pipefail | |
| jq --arg namespace "https://github.com/${GITHUB_REPOSITORY}/sbom/${RELEASE_TAG}/${RELEASE_SHA}" \ | |
| '.creationInfo.created = "1970-01-01T00:00:00Z" | |
| | .documentNamespace = $namespace | |
| | walk(if type == "object" and has("annotationDate") | |
| then .annotationDate = "1970-01-01T00:00:00Z" | |
| else . end)' \ | |
| notify/dist/SBOM.spdx.json > /tmp/SBOM.spdx.json | |
| mv /tmp/SBOM.spdx.json notify/dist/SBOM.spdx.json | |
| - name: Verify and adopt the immutable staged SBOM in recovery | |
| if: needs.publish-gate.outputs.recovery_mode == 'true' | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| RELEASE_TAG: ${{ needs.publish-gate.outputs.release_tag }} | |
| RELEASE_VERSION: ${{ needs.publish-gate.outputs.version }} | |
| RELEASE_SHA: ${{ needs.publish-gate.outputs.release_sha }} | |
| run: | | |
| set -euo pipefail | |
| repository_owner=${GITHUB_REPOSITORY%%/*} | |
| repository_name=${GITHUB_REPOSITORY#*/} | |
| # GraphQL includes drafts; REST by-tag does not. | |
| # shellcheck disable=SC2016 | |
| lookup=$(gh api graphql \ | |
| -F owner="$repository_owner" -F name="$repository_name" -F tag="$RELEASE_TAG" \ | |
| -f query='query($owner: String!, $name: String!, $tag: String!) { | |
| repository(owner: $owner, name: $name) { | |
| release(tagName: $tag) { databaseId } | |
| } | |
| }') | |
| jq -e '.data.repository != null and ((.errors // []) | length == 0)' <<<"$lookup" >/dev/null | |
| release_id=$(jq -r '.data.repository.release.databaseId // empty' <<<"$lookup") | |
| printf '%s\n' "$release_id" | grep -Eq '^[1-9][0-9]*$' | |
| release_json=$(gh api "repos/${GITHUB_REPOSITORY}/releases/${release_id}") | |
| test "$(jq -r .tag_name <<<"$release_json")" = "$RELEASE_TAG" | |
| test "$(jq -r .name <<<"$release_json")" = "vaultsync-notify ${RELEASE_VERSION}" | |
| test "$(jq -r .prerelease <<<"$release_json")" = false | |
| case $(jq -r .draft <<<"$release_json") in true|false) ;; *) exit 1 ;; esac | |
| asset_name=SBOM.spdx.json | |
| matches=$(jq -r --arg name "$asset_name" '[.assets[] | select(.name == $name)] | length' <<<"$release_json") | |
| test "$matches" = 1 | |
| asset_id=$(jq -r --arg name "$asset_name" '.assets[] | select(.name == $name) | .id' <<<"$release_json") | |
| asset_digest=$(jq -r --arg name "$asset_name" '.assets[] | select(.name == $name) | .digest' <<<"$release_json") | |
| printf '%s\n' "$asset_id" | grep -Eq '^[1-9][0-9]*$' | |
| printf '%s\n' "$asset_digest" | grep -Eq '^sha256:[0-9a-f]{64}$' | |
| staged=/tmp/vaultsync-notify-staged-SBOM.spdx.json | |
| gh api --method GET -H 'Accept: application/octet-stream' \ | |
| "repos/${GITHUB_REPOSITORY}/releases/assets/${asset_id}" > "$staged" | |
| test "sha256:$(sha256sum "$staged" | awk '{print $1}')" = "$asset_digest" | |
| generated=notify/dist/SBOM.spdx.json | |
| namespace="https://github.com/${GITHUB_REPOSITORY}/sbom/${RELEASE_TAG}/${RELEASE_SHA}" | |
| for candidate in "$generated" "$staged"; do | |
| jq -e --arg namespace "$namespace" ' | |
| .spdxVersion == "SPDX-2.3" and | |
| .dataLicense == "CC0-1.0" and | |
| .name == "notify/dist" and | |
| .documentNamespace == $namespace and | |
| .creationInfo.created == "1970-01-01T00:00:00Z" and | |
| (.creationInfo.creators | index("Tool: trivy-0.70.0") != null) | |
| ' "$candidate" >/dev/null | |
| done | |
| canonical_filter='walk(if type == "object" and has("annotationDate") | |
| then .annotationDate = "1970-01-01T00:00:00Z" | |
| else . end)' | |
| jq -S "$canonical_filter" "$generated" > /tmp/vaultsync-notify-generated-SBOM.canonical.json | |
| jq -S "$canonical_filter" "$staged" > /tmp/vaultsync-notify-staged-SBOM.canonical.json | |
| cmp /tmp/vaultsync-notify-generated-SBOM.canonical.json \ | |
| /tmp/vaultsync-notify-staged-SBOM.canonical.json | |
| mv "$staged" "$generated" | |
| - name: Verify exact binary provenance | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| RELEASE_SHA: ${{ needs.publish-gate.outputs.release_sha }} | |
| RELEASE_TAG: ${{ needs.publish-gate.outputs.release_tag }} | |
| run: | | |
| set -euo pipefail | |
| while IFS= read -r name; do | |
| if ! gh attestation verify "notify/dist/$name" --repo "$GITHUB_REPOSITORY" \ | |
| --source-digest "$RELEASE_SHA" --source-ref "refs/tags/${RELEASE_TAG}" \ | |
| --signer-workflow "${GITHUB_REPOSITORY}/.github/workflows/docker.yml" >/dev/null 2>&1; then | |
| echo "immutable binary provenance is missing or mismatched for $name" >&2 | |
| exit 1 | |
| fi | |
| done < <(jq -r '.binaries[]' "$RELEASE_SPEC") | |
| - name: Verify exact binary SBOM attestation | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| RELEASE_SHA: ${{ needs.publish-gate.outputs.release_sha }} | |
| RELEASE_TAG: ${{ needs.publish-gate.outputs.release_tag }} | |
| run: | | |
| set -euo pipefail | |
| while IFS= read -r name; do | |
| if ! gh attestation verify "notify/dist/$name" --repo "$GITHUB_REPOSITORY" \ | |
| --predicate-type https://spdx.dev/Document/v2.3 \ | |
| --source-digest "$RELEASE_SHA" --source-ref "refs/tags/${RELEASE_TAG}" \ | |
| --signer-workflow "${GITHUB_REPOSITORY}/.github/workflows/docker.yml" >/dev/null 2>&1; then | |
| echo "immutable binary SBOM attestation is missing or mismatched for $name" >&2 | |
| exit 1 | |
| fi | |
| done < <(jq -r '.binaries[]' "$RELEASE_SPEC") | |
| - name: Record exact image index and platform digests | |
| env: | |
| IMAGE: ${{ needs.publish-gate.outputs.image }} | |
| VERSION: ${{ needs.publish-gate.outputs.version }} | |
| DIGEST: ${{ needs.image-ready.outputs.digest }} | |
| run: | | |
| set -euo pipefail | |
| manifest=$(docker buildx imagetools inspect "${IMAGE}@${DIGEST}" --format '{{json .Manifest}}') | |
| test "$(jq -r .digest <<<"$manifest")" = "$DIGEST" | |
| test "$(jq '[.manifests[] | select(.platform.os == "linux" and .platform.architecture == "amd64")] | length' <<<"$manifest")" = 1 | |
| test "$(jq '[.manifests[] | select(.platform.os == "linux" and .platform.architecture == "arm64")] | length' <<<"$manifest")" = 1 | |
| test "$(jq '[.manifests[] | select( | |
| (.platform.os == "linux" and (.platform.architecture == "amd64" or .platform.architecture == "arm64")) or | |
| (.platform.os == "unknown" and .platform.architecture == "unknown" and | |
| .annotations["vnd.docker.reference.type"] == "attestation-manifest") | |
| )] | length' <<<"$manifest")" = "$(jq '.manifests | length' <<<"$manifest")" | |
| amd64=$(jq -er '.manifests[] | select(.platform.os == "linux" and .platform.architecture == "amd64") | .digest' <<<"$manifest") | |
| arm64=$(jq -er '.manifests[] | select(.platform.os == "linux" and .platform.architecture == "arm64") | .digest' <<<"$manifest") | |
| cat > notify/dist/IMAGE-DIGESTS <<EOF | |
| image=${IMAGE} | |
| version_tag=${IMAGE}:${VERSION} | |
| index_digest=${DIGEST} | |
| linux_amd64_digest=${amd64} | |
| linux_arm64_digest=${arm64} | |
| EOF | |
| - name: Create deterministic release manifest | |
| env: | |
| RELEASE_SHA: ${{ needs.publish-gate.outputs.release_sha }} | |
| IMAGE_DIGEST: ${{ needs.image-ready.outputs.digest }} | |
| run: | | |
| ruby <<'RUBY' | |
| require "digest" | |
| require "json" | |
| root = ENV.fetch("GITHUB_WORKSPACE") | |
| spec = JSON.parse(File.read(File.join(root, "notify/release.json"))) | |
| dist = File.join(root, "notify/dist") | |
| binaries = spec.fetch("binaries").map do |name| | |
| path = File.join(dist, name) | |
| { "name" => name, "size" => File.size(path), "sha256" => Digest::SHA256.file(path).hexdigest } | |
| end | |
| manifest = { | |
| "format_version" => 1, | |
| "version" => spec.fetch("version"), | |
| "tag" => spec.fetch("tag"), | |
| "source_commit" => ENV.fetch("RELEASE_SHA"), | |
| "image" => spec.fetch("image"), | |
| "image_index_digest" => ENV.fetch("IMAGE_DIGEST"), | |
| "rollback" => spec.fetch("rollback"), | |
| "binaries" => binaries, | |
| "binary_sbom" => { | |
| "name" => "SBOM.spdx.json", | |
| "sha256" => Digest::SHA256.file(File.join(dist, "SBOM.spdx.json")).hexdigest | |
| }, | |
| "image_digests_sha256" => Digest::SHA256.file(File.join(dist, "IMAGE-DIGESTS")).hexdigest, | |
| "release_assets" => spec.fetch("release_assets") | |
| } | |
| File.write(File.join(dist, "RELEASE-MANIFEST.json"), JSON.pretty_generate(manifest) + "\n") | |
| RUBY | |
| - name: Create or verify the exact draft release and assets | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| RELEASE_TAG: ${{ needs.publish-gate.outputs.release_tag }} | |
| RELEASE_VERSION: ${{ needs.publish-gate.outputs.version }} | |
| RELEASE_IS_PUBLIC: ${{ needs.publish-gate.outputs.release_is_public }} | |
| RECOVERY_MODE: ${{ needs.publish-gate.outputs.recovery_mode }} | |
| run: | | |
| set -euo pipefail | |
| resolve_release() { | |
| repository_owner=${GITHUB_REPOSITORY%%/*} | |
| repository_name=${GITHUB_REPOSITORY#*/} | |
| # GraphQL includes drafts, unlike the REST release-by-tag endpoint. | |
| # Keep the query literal so shell expansion cannot alter its variables. | |
| # shellcheck disable=SC2016 | |
| lookup=$(gh api graphql \ | |
| -F owner="$repository_owner" -F name="$repository_name" -F tag="$RELEASE_TAG" \ | |
| -f query='query($owner: String!, $name: String!, $tag: String!) { | |
| repository(owner: $owner, name: $name) { | |
| release(tagName: $tag) { databaseId } | |
| } | |
| }') || return 1 | |
| jq -e '.data.repository != null and ((.errors // []) | length == 0)' <<<"$lookup" >/dev/null || return 1 | |
| release_id=$(jq -r '.data.repository.release.databaseId // empty' <<<"$lookup") || return 1 | |
| if [ -z "$release_id" ]; then | |
| printf 'null\n' | |
| return | |
| fi | |
| printf '%s\n' "$release_id" | grep -Eq '^[1-9][0-9]*$' || return 1 | |
| gh api "repos/${GITHUB_REPOSITORY}/releases/${release_id}" || return 1 | |
| } | |
| release_json=$(resolve_release) | |
| case $(jq -r type <<<"$release_json") in | |
| object) | |
| test "$(jq -r .tag_name <<<"$release_json")" = "$RELEASE_TAG" | |
| test "$(jq -r .prerelease <<<"$release_json")" = false | |
| test "$(jq -r .name <<<"$release_json")" = "vaultsync-notify ${RELEASE_VERSION}" | |
| ;; | |
| null) | |
| test "$RELEASE_IS_PUBLIC" != true | |
| test "$RECOVERY_MODE" != true | |
| gh release create "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --verify-tag \ | |
| --draft --latest=false --title "vaultsync-notify ${RELEASE_VERSION}" \ | |
| --notes "Staged helper release. Publication remains blocked until the digest-bound supported-host rollback and post-publication gates succeed." | |
| ;; | |
| *) exit 1 ;; | |
| esac | |
| mapfile -t binaries < <(jq -r '.binaries[]' "$RELEASE_SPEC") | |
| files=() | |
| for name in "${binaries[@]}" SHA256SUMS SBOM.spdx.json IMAGE-DIGESTS RELEASE-MANIFEST.json; do | |
| files+=("notify/dist/$name") | |
| done | |
| release_json=$(resolve_release) | |
| test "$(jq -r type <<<"$release_json")" = object | |
| release_is_draft=$(jq -r .draft <<<"$release_json") | |
| case $release_is_draft in true|false) ;; *) exit 1 ;; esac | |
| while IFS= read -r existing_name; do | |
| jq -e --arg name "$existing_name" '.release_assets | index($name) != null' "$RELEASE_SPEC" >/dev/null | |
| done < <(jq -r '.assets[].name' <<<"$release_json") | |
| for file in "${files[@]}"; do | |
| name=$(basename "$file") | |
| expected="sha256:$(sha256sum "$file" | awk '{print $1}')" | |
| existing=$(jq -r --arg name "$name" '[.assets[] | select(.name == $name) | .digest][0] // empty' <<<"$release_json") | |
| if [ -n "$existing" ]; then | |
| if [ "$existing" != "$expected" ]; then | |
| echo "immutable release asset digest mismatch for $name: expected $expected, found $existing" >&2 | |
| exit 1 | |
| fi | |
| else | |
| test "$release_is_draft" = true | |
| gh release upload "$RELEASE_TAG" "$file" --repo "$GITHUB_REPOSITORY" | |
| release_json=$(resolve_release) | |
| test "$(jq -r --arg name "$name" '.assets[] | select(.name == $name) | .digest' <<<"$release_json")" = "$expected" | |
| fi | |
| done | |
| rollout-verify: | |
| name: Published Helper Rollout | |
| if: >- | |
| always() && | |
| github.event_name == 'workflow_dispatch' && | |
| ((github.ref_type == 'tag' && inputs.release_tag == github.ref_name && inputs.recovery_run_id == '') || | |
| (github.ref == 'refs/heads/main' && inputs.recovery_run_id != '')) && | |
| inputs.confirmation == 'PUBLISH_NOTIFY_RELEASE' && | |
| github.actor == github.repository_owner && | |
| github.triggering_actor == github.repository_owner && | |
| needs.publish-safety-policy.result == 'success' && | |
| needs.notify-guard.result == 'success' && | |
| needs.publish-gate.result == 'success' && | |
| needs.image-ready.result == 'success' && | |
| needs.release-binaries.result == 'success' | |
| needs: | |
| - publish-safety-policy | |
| - notify-guard | |
| - publish-gate | |
| - image-ready | |
| - release-binaries | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 30 | |
| permissions: | |
| # GitHub exposes draft releases only to tokens with push access. The | |
| # publish-safety policy confines this token to the read-only API calls | |
| # below and rejects release mutation commands in this job. | |
| contents: write | |
| packages: read | |
| steps: | |
| - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 | |
| with: | |
| ref: ${{ needs.publish-gate.outputs.release_sha }} | |
| fetch-depth: 0 | |
| persist-credentials: false | |
| - name: Verify staged binaries and manifest read-only | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| RELEASE_TAG: ${{ needs.publish-gate.outputs.release_tag }} | |
| RELEASE_SHA: ${{ needs.publish-gate.outputs.release_sha }} | |
| IMAGE_DIGEST: ${{ needs.image-ready.outputs.digest }} | |
| run: | | |
| set -euo pipefail | |
| resolve_release() { | |
| repository_owner=${GITHUB_REPOSITORY%%/*} | |
| repository_name=${GITHUB_REPOSITORY#*/} | |
| # GraphQL includes drafts, unlike the REST release-by-tag endpoint. | |
| # Keep the query literal so shell expansion cannot alter its variables. | |
| # shellcheck disable=SC2016 | |
| lookup=$(gh api graphql \ | |
| -F owner="$repository_owner" -F name="$repository_name" -F tag="$RELEASE_TAG" \ | |
| -f query='query($owner: String!, $name: String!, $tag: String!) { | |
| repository(owner: $owner, name: $name) { | |
| release(tagName: $tag) { databaseId } | |
| } | |
| }') || { | |
| echo "::error::Draft release GraphQL lookup failed." >&2 | |
| return 1 | |
| } | |
| jq -e '.data.repository != null and ((.errors // []) | length == 0)' <<<"$lookup" >/dev/null || { | |
| echo "::error::Draft release GraphQL response was invalid." >&2 | |
| return 1 | |
| } | |
| release_id=$(jq -er '.data.repository.release.databaseId // empty' <<<"$lookup") || { | |
| echo "::error::Draft release database ID could not be parsed." >&2 | |
| return 1 | |
| } | |
| if ! printf '%s\n' "$release_id" | grep -Eq '^[1-9][0-9]*$'; then | |
| echo "::error::Draft release is not visible to the rollout token." >&2 | |
| return 1 | |
| fi | |
| gh api "repos/${GITHUB_REPOSITORY}/releases/${release_id}" || { | |
| echo "::error::Draft release metadata could not be read." >&2 | |
| return 1 | |
| } | |
| } | |
| release_json=$(resolve_release) | |
| test "$(jq -r .tag_name <<<"$release_json")" = "$RELEASE_TAG" | |
| release_is_draft=$(jq -r .draft <<<"$release_json") | |
| case $release_is_draft in | |
| true|false) ;; | |
| *) | |
| echo "::error::Release draft state was invalid." >&2 | |
| exit 1 | |
| ;; | |
| esac | |
| test "$(jq -r .prerelease <<<"$release_json")" = false | |
| while IFS= read -r existing_name; do | |
| jq -e --arg name "$existing_name" '.release_assets | index($name) != null' "$RELEASE_SPEC" >/dev/null | |
| done < <(jq -r '.assets[].name' <<<"$release_json") | |
| mkdir -p /tmp/vaultsync-notify-release | |
| mapfile -t required_names < <( | |
| jq -r '.release_assets[] | select(. != "ROLLOUT-EVIDENCE.txt")' "$RELEASE_SPEC" | |
| ) | |
| for name in "${required_names[@]}"; do | |
| matches=$(jq -r --arg name "$name" '[.assets[] | select(.name == $name)] | length' <<<"$release_json") | |
| test "$matches" = 1 | |
| asset_id=$(jq -r --arg name "$name" '.assets[] | select(.name == $name) | .id' <<<"$release_json") | |
| asset_digest=$(jq -r --arg name "$name" '.assets[] | select(.name == $name) | .digest' <<<"$release_json") | |
| printf '%s\n' "$asset_id" | grep -Eq '^[1-9][0-9]*$' | |
| printf '%s\n' "$asset_digest" | grep -Eq '^sha256:[0-9a-f]{64}$' | |
| target="/tmp/vaultsync-notify-release/$name" | |
| gh api --method GET -H 'Accept: application/octet-stream' \ | |
| "repos/${GITHUB_REPOSITORY}/releases/assets/${asset_id}" > "$target" | |
| test "sha256:$(sha256sum "$target" | awk '{print $1}')" = "$asset_digest" | |
| done | |
| (cd /tmp/vaultsync-notify-release && sha256sum -c SHA256SUMS) | |
| sbom_sha=$(sha256sum /tmp/vaultsync-notify-release/SBOM.spdx.json | awk '{print $1}') | |
| image_digests_sha=$(sha256sum /tmp/vaultsync-notify-release/IMAGE-DIGESTS | awk '{print $1}') | |
| jq -e --arg sha "$RELEASE_SHA" --arg digest "$IMAGE_DIGEST" \ | |
| --arg sbom_sha "$sbom_sha" --arg image_digests_sha "$image_digests_sha" ' | |
| .source_commit == $sha and | |
| .image_index_digest == $digest and | |
| .binary_sbom == {"name": "SBOM.spdx.json", "sha256": $sbom_sha} and | |
| .image_digests_sha256 == $image_digests_sha | |
| ' \ | |
| /tmp/vaultsync-notify-release/RELEASE-MANIFEST.json >/dev/null | |
| - name: Prove published upgrade, rollback, and forward recovery | |
| env: | |
| OLD_IMAGE: ${{ needs.publish-gate.outputs.rollback_image }} | |
| NEW_IMAGE: ${{ needs.publish-gate.outputs.image }}@${{ needs.image-ready.outputs.digest }} | |
| NEW_COMMIT: ${{ needs.publish-gate.outputs.release_sha }} | |
| NEW_VERSION: ${{ needs.publish-gate.outputs.version }} | |
| run: | | |
| set -euo pipefail | |
| sudo env \ | |
| VAULTSYNC_RUNTIME_PACKAGING_OLD_IMAGE="$OLD_IMAGE" \ | |
| VAULTSYNC_RUNTIME_PACKAGING_NEW_IMAGE="$NEW_IMAGE" \ | |
| VAULTSYNC_RUNTIME_PACKAGING_NEW_COMMIT="$NEW_COMMIT" \ | |
| VAULTSYNC_RUNTIME_PACKAGING_NEW_VERSION="$NEW_VERSION" \ | |
| notify/tests/runtime-packaging/run-linux-host.sh | tee /tmp/vaultsync-rollout-proof.txt | |
| grep -qx 'publication_mode=published-digests' /tmp/vaultsync-rollout-proof.txt | |
| grep -qx 'installer_upgrade_new=pass' /tmp/vaultsync-rollout-proof.txt | |
| grep -qx 'installer_downgrade_old=pass' /tmp/vaultsync-rollout-proof.txt | |
| grep -qx 'installer_forward_recovery_new=pass' /tmp/vaultsync-rollout-proof.txt | |
| grep -qx 'credential_state=byte-identical' /tmp/vaultsync-rollout-proof.txt | |
| grep -qx 'rollback_baseline_endpoint_logging=observed-known-1.8.0' /tmp/vaultsync-rollout-proof.txt | |
| grep -qx 'candidate_sensitive_logging=not-observed' /tmp/vaultsync-rollout-proof.txt | |
| grep -qx 'upload_evidence=unset' /tmp/vaultsync-rollout-proof.txt | |
| grep -qx 'download_evidence=unset' /tmp/vaultsync-rollout-proof.txt | |
| grep -qx 'roundtrip_evidence=unset' /tmp/vaultsync-rollout-proof.txt | |
| finalize-release: | |
| name: Finalize Helper Release | |
| if: >- | |
| always() && | |
| github.event_name == 'workflow_dispatch' && | |
| ((github.ref_type == 'tag' && inputs.release_tag == github.ref_name && inputs.recovery_run_id == '') || | |
| (github.ref == 'refs/heads/main' && inputs.recovery_run_id != '')) && | |
| inputs.confirmation == 'PUBLISH_NOTIFY_RELEASE' && | |
| github.actor == github.repository_owner && | |
| github.triggering_actor == github.repository_owner && | |
| needs.publish-safety-policy.result == 'success' && | |
| needs.notify-guard.result == 'success' && | |
| needs.publish-gate.result == 'success' && | |
| needs.image-ready.result == 'success' && | |
| needs.release-binaries.result == 'success' && | |
| needs.rollout-verify.result == 'success' | |
| needs: | |
| - publish-safety-policy | |
| - notify-guard | |
| - publish-gate | |
| - image-ready | |
| - release-binaries | |
| - rollout-verify | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| permissions: | |
| contents: write | |
| steps: | |
| - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 | |
| with: | |
| ref: ${{ needs.publish-gate.outputs.release_sha }} | |
| fetch-depth: 0 | |
| persist-credentials: false | |
| - name: Revalidate the tag and publish the complete release once | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| RELEASE_TAG: ${{ needs.publish-gate.outputs.release_tag }} | |
| RELEASE_VERSION: ${{ needs.publish-gate.outputs.version }} | |
| RELEASE_SHA: ${{ needs.publish-gate.outputs.release_sha }} | |
| IMAGE: ${{ needs.publish-gate.outputs.image }} | |
| IMAGE_DIGEST: ${{ needs.image-ready.outputs.digest }} | |
| ROLLBACK_IMAGE: ${{ needs.publish-gate.outputs.rollback_image }} | |
| run: | | |
| set -euo pipefail | |
| git fetch origin main --tags | |
| test "$(git rev-parse "refs/tags/${RELEASE_TAG}^{commit}")" = "$RELEASE_SHA" | |
| git merge-base --is-ancestor "$RELEASE_SHA" refs/remotes/origin/main | |
| test "$(gh api "repos/${GITHUB_REPOSITORY}/commits/${RELEASE_SHA}" --jq .commit.verification.verified)" = true | |
| resolve_release() { | |
| repository_owner=${GITHUB_REPOSITORY%%/*} | |
| repository_name=${GITHUB_REPOSITORY#*/} | |
| # GraphQL includes drafts, unlike the REST release-by-tag endpoint. | |
| # Keep the query literal so shell expansion cannot alter its variables. | |
| # shellcheck disable=SC2016 | |
| lookup=$(gh api graphql \ | |
| -F owner="$repository_owner" -F name="$repository_name" -F tag="$RELEASE_TAG" \ | |
| -f query='query($owner: String!, $name: String!, $tag: String!) { | |
| repository(owner: $owner, name: $name) { | |
| release(tagName: $tag) { databaseId } | |
| } | |
| }') || return 1 | |
| jq -e '.data.repository != null and ((.errors // []) | length == 0)' <<<"$lookup" >/dev/null || return 1 | |
| release_id=$(jq -r '.data.repository.release.databaseId // empty' <<<"$lookup") || return 1 | |
| printf '%s\n' "$release_id" | grep -Eq '^[1-9][0-9]*$' || return 1 | |
| gh api "repos/${GITHUB_REPOSITORY}/releases/${release_id}" || return 1 | |
| } | |
| release_json=$(resolve_release) | |
| release_is_draft=$(jq -r .draft <<<"$release_json") | |
| case $release_is_draft in true|false) ;; *) exit 1 ;; esac | |
| evidence_name=ROLLOUT-EVIDENCE.txt | |
| evidence_digest=$(jq -r --arg name "$evidence_name" '[.assets[] | select(.name == $name) | .digest][0] // empty' <<<"$release_json") | |
| if [ -n "$evidence_digest" ]; then | |
| rm -rf /tmp/existing-rollout | |
| mkdir -p /tmp/existing-rollout | |
| evidence_id=$(jq -r --arg name "$evidence_name" '.assets[] | select(.name == $name) | .id' <<<"$release_json") | |
| printf '%s\n' "$evidence_id" | grep -Eq '^[1-9][0-9]*$' | |
| gh api --method GET -H 'Accept: application/octet-stream' \ | |
| "repos/${GITHUB_REPOSITORY}/releases/assets/${evidence_id}" > "/tmp/existing-rollout/$evidence_name" | |
| test "sha256:$(sha256sum "/tmp/existing-rollout/$evidence_name" | awk '{print $1}')" = "$evidence_digest" | |
| grep -qx "source_commit=${RELEASE_SHA}" /tmp/existing-rollout/$evidence_name | |
| grep -qx "release_tag=${RELEASE_TAG}" /tmp/existing-rollout/$evidence_name | |
| grep -Eq "^publication_run=https://github.com/${GITHUB_REPOSITORY}/actions/runs/[0-9]+$" /tmp/existing-rollout/$evidence_name | |
| grep -qx 'host=github-hosted-standard-linux-rootful-docker' /tmp/existing-rollout/$evidence_name | |
| grep -qx "old_image=${ROLLBACK_IMAGE}" /tmp/existing-rollout/$evidence_name | |
| grep -qx "new_image=${IMAGE}@${IMAGE_DIGEST}" /tmp/existing-rollout/$evidence_name | |
| grep -qx 'installer_upgrade_new=pass' /tmp/existing-rollout/$evidence_name | |
| grep -qx 'installer_downgrade_old=pass' /tmp/existing-rollout/$evidence_name | |
| grep -qx 'installer_forward_recovery_new=pass' /tmp/existing-rollout/$evidence_name | |
| grep -qx 'credential_state=byte-identical' /tmp/existing-rollout/$evidence_name | |
| grep -qx 'tls_spki=stable-across-forward-recovery' /tmp/existing-rollout/$evidence_name | |
| grep -qx 'rollback_baseline_endpoint_logging=observed-known-1.8.0' /tmp/existing-rollout/$evidence_name | |
| grep -qx 'candidate_sensitive_logging=not-observed' /tmp/existing-rollout/$evidence_name | |
| grep -qx 'upload_evidence=unset' /tmp/existing-rollout/$evidence_name | |
| grep -qx 'download_evidence=unset' /tmp/existing-rollout/$evidence_name | |
| grep -qx 'roundtrip_evidence=unset' /tmp/existing-rollout/$evidence_name | |
| test "$(wc -l < "/tmp/existing-rollout/$evidence_name" | tr -d ' ')" = 16 | |
| else | |
| test "$release_is_draft" = true | |
| cat > /tmp/$evidence_name <<EOF | |
| source_commit=${RELEASE_SHA} | |
| release_tag=${RELEASE_TAG} | |
| publication_run=https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID} | |
| host=github-hosted-standard-linux-rootful-docker | |
| old_image=${ROLLBACK_IMAGE} | |
| new_image=${IMAGE}@${IMAGE_DIGEST} | |
| installer_upgrade_new=pass | |
| installer_downgrade_old=pass | |
| installer_forward_recovery_new=pass | |
| credential_state=byte-identical | |
| tls_spki=stable-across-forward-recovery | |
| rollback_baseline_endpoint_logging=observed-known-1.8.0 | |
| candidate_sensitive_logging=not-observed | |
| upload_evidence=unset | |
| download_evidence=unset | |
| roundtrip_evidence=unset | |
| EOF | |
| gh release upload "$RELEASE_TAG" /tmp/$evidence_name --repo "$GITHUB_REPOSITORY" | |
| fi | |
| release_json=$(resolve_release) | |
| mapfile -t expected_names < <(jq -r '.release_assets[]' "$RELEASE_SPEC" | sort) | |
| mapfile -t actual_names < <(jq -r '.assets[].name' <<<"$release_json" | sort) | |
| test "${expected_names[*]}" = "${actual_names[*]}" | |
| test "$(jq '[.assets[] | select(.digest | test("^sha256:[0-9a-f]{64}$"))] | length' <<<"$release_json")" = "${#expected_names[@]}" | |
| cat > /tmp/release-notes.md <<EOF | |
| Helper-first VaultSync diagnostics release from commit ${RELEASE_SHA}. | |
| Image | |
| - ${IMAGE}@${IMAGE_DIGEST} | |
| - version tag ${IMAGE}:${RELEASE_VERSION} is published once; no latest tag is moved | |
| - Linux amd64 and arm64 digests are recorded in IMAGE-DIGESTS | |
| - repository-bound provenance and SPDX SBOM attestations are required | |
| Binaries and integrity | |
| - Linux amd64/arm64, macOS amd64/arm64, and Windows amd64 | |
| - verify against SHA256SUMS and RELEASE-MANIFEST.json | |
| - SBOM.spdx.json and GitHub provenance/SBOM attestations cover the binary set | |
| Helper-first rollout | |
| The real explicit installer passed published 1.8.0 -> ${RELEASE_VERSION} -> 1.8.0 -> the same ${RELEASE_VERSION} digest on a standard Linux rootful-Docker runner. Credential-state bytes and the TLS SPKI pin survived rollback and forward recovery. The proof also records the known 1.8.0 endpoint-log behavior and requires ${RELEASE_VERSION} to emit none of the configured test credential, identifier, path, or URL values. See ROLLOUT-EVIDENCE.txt. | |
| Compatibility and limits | |
| Existing installs remain dormant unless diagnostics is explicitly configured. Trigger v1 and Relay v1 remain compatible. Docker Host-Bind on standard Linux/rootful Docker is the only supported diagnostics packaging row. Named volumes, rootless Docker, NAS/FUSE/remote storage, Docker Desktop, WSL, macOS, Windows, and systemd packaging remain diagnostics-unsupported. Upload, download, and roundtrip product evidence remain unset; cleanup remains evidence-orthogonal. | |
| EOF | |
| if [ "$release_is_draft" = false ]; then | |
| test "$(jq -r .name <<<"$release_json")" = "vaultsync-notify ${RELEASE_VERSION}" | |
| body=$(jq -er .body <<<"$release_json") | |
| test "$body" = "$(cat /tmp/release-notes.md)" | |
| exit 0 | |
| fi | |
| gh release edit "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --verify-tag \ | |
| --title "vaultsync-notify ${RELEASE_VERSION}" --notes-file /tmp/release-notes.md \ | |
| --draft=false --latest=false | |
| release_json=$(resolve_release) | |
| test "$(jq -r .draft <<<"$release_json")" = false | |
| test "$(jq -r .name <<<"$release_json")" = "vaultsync-notify ${RELEASE_VERSION}" | |
| body=$(jq -er .body <<<"$release_json") | |
| test "$body" = "$(cat /tmp/release-notes.md)" | |
| verify-published: | |
| name: Verify Published Helper | |
| if: >- | |
| always() && | |
| github.event_name == 'workflow_dispatch' && | |
| ((github.ref_type == 'tag' && inputs.release_tag == github.ref_name && inputs.recovery_run_id == '') || | |
| (github.ref == 'refs/heads/main' && inputs.recovery_run_id != '')) && | |
| inputs.confirmation == 'PUBLISH_NOTIFY_RELEASE' && | |
| github.actor == github.repository_owner && | |
| github.triggering_actor == github.repository_owner | |
| needs: | |
| - publish-safety-policy | |
| - notify-guard | |
| - publish-gate | |
| - image-ready | |
| - release-binaries | |
| - rollout-verify | |
| - finalize-release | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 | |
| permissions: | |
| contents: read | |
| packages: read | |
| steps: | |
| - name: Require every publication predecessor to succeed | |
| env: | |
| PUBLISH_SAFETY_RESULT: ${{ needs.publish-safety-policy.result }} | |
| NOTIFY_GUARD_RESULT: ${{ needs.notify-guard.result }} | |
| PUBLISH_GATE_RESULT: ${{ needs.publish-gate.result }} | |
| IMAGE_READY_RESULT: ${{ needs.image-ready.result }} | |
| RELEASE_BINARIES_RESULT: ${{ needs.release-binaries.result }} | |
| ROLLOUT_RESULT: ${{ needs.rollout-verify.result }} | |
| FINALIZE_RESULT: ${{ needs.finalize-release.result }} | |
| run: | | |
| set -euo pipefail | |
| for result in \ | |
| "$PUBLISH_SAFETY_RESULT" \ | |
| "$NOTIFY_GUARD_RESULT" \ | |
| "$PUBLISH_GATE_RESULT" \ | |
| "$IMAGE_READY_RESULT" \ | |
| "$RELEASE_BINARIES_RESULT" \ | |
| "$ROLLOUT_RESULT" \ | |
| "$FINALIZE_RESULT" | |
| do | |
| test "$result" = success | |
| done | |
| - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 | |
| with: | |
| ref: ${{ needs.publish-gate.outputs.release_sha }} | |
| fetch-depth: 0 | |
| persist-credentials: false | |
| - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 | |
| - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.repository_owner }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Verify public release, assets, digests, and attestations read-only | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| RELEASE_TAG: ${{ needs.publish-gate.outputs.release_tag }} | |
| RELEASE_VERSION: ${{ needs.publish-gate.outputs.version }} | |
| RELEASE_SHA: ${{ needs.publish-gate.outputs.release_sha }} | |
| IMAGE: ${{ needs.publish-gate.outputs.image }} | |
| IMAGE_DIGEST: ${{ needs.image-ready.outputs.digest }} | |
| run: | | |
| set -euo pipefail | |
| release_json=$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${RELEASE_TAG}") | |
| test "$(jq -r .draft <<<"$release_json")" = false | |
| test "$(jq -r .prerelease <<<"$release_json")" = false | |
| test "$(jq -r .tag_name <<<"$release_json")" = "$RELEASE_TAG" | |
| test "$(git rev-parse "refs/tags/${RELEASE_TAG}^{commit}")" = "$RELEASE_SHA" | |
| test "$(gh api "repos/${GITHUB_REPOSITORY}/commits/${RELEASE_SHA}" --jq .commit.verification.verified)" = true | |
| mkdir -p /tmp/published-release | |
| gh release download "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --dir /tmp/published-release | |
| mapfile -t expected_names < <(jq -r '.release_assets[]' "$RELEASE_SPEC" | sort) | |
| mapfile -t actual_names < <(find /tmp/published-release -maxdepth 1 -type f -exec basename {} \; | sort) | |
| test "${expected_names[*]}" = "${actual_names[*]}" | |
| for file in /tmp/published-release/*; do | |
| name=$(basename "$file") | |
| actual="sha256:$(sha256sum "$file" | awk '{print $1}')" | |
| expected=$(jq -er --arg name "$name" '.assets[] | select(.name == $name) | .digest' <<<"$release_json") | |
| test "$actual" = "$expected" | |
| url=$(jq -er --arg name "$name" '.assets[] | select(.name == $name) | .browser_download_url' <<<"$release_json") | |
| curl -fsIL "$url" >/dev/null | |
| done | |
| (cd /tmp/published-release && sha256sum -c SHA256SUMS) | |
| jq -e --arg sha "$RELEASE_SHA" --arg digest "$IMAGE_DIGEST" \ | |
| '.source_commit == $sha and .image_index_digest == $digest' \ | |
| /tmp/published-release/RELEASE-MANIFEST.json >/dev/null | |
| grep -qx "index_digest=${IMAGE_DIGEST}" /tmp/published-release/IMAGE-DIGESTS | |
| grep -qx "source_commit=${RELEASE_SHA}" /tmp/published-release/ROLLOUT-EVIDENCE.txt | |
| grep -qx 'installer_upgrade_new=pass' /tmp/published-release/ROLLOUT-EVIDENCE.txt | |
| grep -qx 'installer_downgrade_old=pass' /tmp/published-release/ROLLOUT-EVIDENCE.txt | |
| grep -qx 'installer_forward_recovery_new=pass' /tmp/published-release/ROLLOUT-EVIDENCE.txt | |
| grep -qx 'rollback_baseline_endpoint_logging=observed-known-1.8.0' /tmp/published-release/ROLLOUT-EVIDENCE.txt | |
| grep -qx 'candidate_sensitive_logging=not-observed' /tmp/published-release/ROLLOUT-EVIDENCE.txt | |
| manifest=$(docker buildx imagetools inspect "${IMAGE}:${RELEASE_VERSION}" --format '{{json .Manifest}}') | |
| test "$(jq -r .digest <<<"$manifest")" = "$IMAGE_DIGEST" | |
| test "$(jq '[.manifests[] | select(.platform.os == "linux" and .platform.architecture == "amd64")] | length' <<<"$manifest")" = 1 | |
| test "$(jq '[.manifests[] | select(.platform.os == "linux" and .platform.architecture == "arm64")] | length' <<<"$manifest")" = 1 | |
| test "$(jq '[.manifests[] | select( | |
| (.platform.os == "linux" and (.platform.architecture == "amd64" or .platform.architecture == "arm64")) or | |
| (.platform.os == "unknown" and .platform.architecture == "unknown" and | |
| .annotations["vnd.docker.reference.type"] == "attestation-manifest") | |
| )] | length' <<<"$manifest")" = "$(jq '.manifests | length' <<<"$manifest")" | |
| docker pull "${IMAGE}@${IMAGE_DIGEST}" >/dev/null | |
| test "$(docker run --rm --network none --read-only --cap-drop ALL --security-opt no-new-privileges "${IMAGE}@${IMAGE_DIGEST}" --version)" = "vaultsync-notify $RELEASE_VERSION" | |
| gh attestation verify "oci://${IMAGE}@${IMAGE_DIGEST}" --repo "$GITHUB_REPOSITORY" \ | |
| --source-digest "$RELEASE_SHA" --source-ref "refs/tags/${RELEASE_TAG}" \ | |
| --signer-workflow "${GITHUB_REPOSITORY}/.github/workflows/docker.yml" >/dev/null | |
| gh attestation verify "oci://${IMAGE}@${IMAGE_DIGEST}" --repo "$GITHUB_REPOSITORY" \ | |
| --predicate-type https://spdx.dev/Document/v2.3 \ | |
| --source-digest "$RELEASE_SHA" --source-ref "refs/tags/${RELEASE_TAG}" \ | |
| --signer-workflow "${GITHUB_REPOSITORY}/.github/workflows/docker.yml" >/dev/null | |
| while IFS= read -r name; do | |
| file="/tmp/published-release/$name" | |
| gh attestation verify "$file" --repo "$GITHUB_REPOSITORY" \ | |
| --source-digest "$RELEASE_SHA" --source-ref "refs/tags/${RELEASE_TAG}" \ | |
| --signer-workflow "${GITHUB_REPOSITORY}/.github/workflows/docker.yml" >/dev/null | |
| gh attestation verify "$file" --repo "$GITHUB_REPOSITORY" \ | |
| --predicate-type https://spdx.dev/Document/v2.3 \ | |
| --source-digest "$RELEASE_SHA" --source-ref "refs/tags/${RELEASE_TAG}" \ | |
| --signer-workflow "${GITHUB_REPOSITORY}/.github/workflows/docker.yml" >/dev/null | |
| done < <(jq -r '.binaries[]' "$RELEASE_SPEC") | |
| { | |
| echo "### Verified helper publication" | |
| echo | |
| echo "- source: ${RELEASE_SHA}" | |
| echo "- image: ${IMAGE}@${IMAGE_DIGEST}" | |
| echo "- public assets: ${#actual_names[@]}" | |
| echo "- repository immutable-release setting: $(jq -r '.immutable // false' <<<"$release_json")" | |
| echo "- published upgrade/rollback/forward recovery: pass" | |
| echo "- upload/download/roundtrip evidence: unset" | |
| } >> "$GITHUB_STEP_SUMMARY" |