diff --git a/.github/workflows/release-publish-qualification.yml b/.github/workflows/release-publish-qualification.yml new file mode 100644 index 0000000..f1d00d8 --- /dev/null +++ b/.github/workflows/release-publish-qualification.yml @@ -0,0 +1,40 @@ +name: Release publish qualification + +on: + pull_request: + workflow_dispatch: + +permissions: + contents: read + packages: read + +env: + QUALIFICATION_VERSION: '0.2.3' + +jobs: + qualify: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Install Skopeo + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends skopeo + - name: Install Cosign + uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0 + - name: Validate release scripts and workflows + shell: bash + run: | + set -euo pipefail + bash -n scripts/qualify_signature.sh + grep -q 'skopeo inspect --no-tags' .github/workflows/sign-release.yml + ! grep -q 'docker buildx imagetools inspect' .github/workflows/sign-release.yml + grep -q 'skopeo inspect --no-tags' scripts/qualify_signature.sh + ! grep -q 'docker buildx imagetools inspect' scripts/qualify_signature.sh + - name: Qualify existing signed release through current verification path + env: + VERSION: ${{ env.QUALIFICATION_VERSION }} + WAIT_ATTEMPTS: '12' + WAIT_SECONDS: '5' + run: bash scripts/qualify_signature.sh diff --git a/.github/workflows/sign-release.yml b/.github/workflows/sign-release.yml index f8f479c..b42e324 100644 --- a/.github/workflows/sign-release.yml +++ b/.github/workflows/sign-release.yml @@ -19,8 +19,10 @@ jobs: sign-release: runs-on: ubuntu-latest steps: - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + - name: Install Skopeo + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends skopeo - name: Install Cosign uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0 - name: Log in to GHCR @@ -38,7 +40,7 @@ jobs: ref="$IMAGE:$version" digest="" for attempt in $(seq 1 "$WAIT_ATTEMPTS"); do - digest="$(docker buildx imagetools inspect "$ref" 2>/dev/null | awk '/^Digest:/ {print $2; exit}' || true)" + digest="$(skopeo inspect --no-tags --format '{{.Digest}}' "docker://$ref" 2>/dev/null || true)" if [[ "$digest" =~ ^sha256:[0-9a-f]{64}$ ]]; then echo "Found $ref at $digest (attempt $attempt/$WAIT_ATTEMPTS)" break diff --git a/.github/workflows/signature-verification.yml b/.github/workflows/signature-verification.yml index 6f44ace..6aa5a6f 100644 --- a/.github/workflows/signature-verification.yml +++ b/.github/workflows/signature-verification.yml @@ -34,8 +34,10 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + - name: Install Skopeo + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends skopeo - name: Install Cosign uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0 - name: Log in to GHCR diff --git a/docs/M0_15_RELEASE_PUBLISH_QUALIFICATION.md b/docs/M0_15_RELEASE_PUBLISH_QUALIFICATION.md new file mode 100644 index 0000000..4e6fa73 --- /dev/null +++ b/docs/M0_15_RELEASE_PUBLISH_QUALIFICATION.md @@ -0,0 +1,34 @@ +# M0.15 release/publish qualification + +M0.15 qualifies the repository's release path before publishing the next immutable tag. + +## Scope + +The next release candidate is `v0.2.4`, created from the post-M0.14 `main` state. Existing tags are immutable and must not be moved. + +The container workflow remains responsible for publishing the multi-platform OCI image and its provenance/SBOM attestations. The release-signing workflow waits for the versioned image, resolves its OCI digest, and signs that immutable digest with GitHub Actions OIDC and Cosign. + +## M0.15 hardening + +Release digest resolution now uses Skopeo instead of piping `docker buildx imagetools inspect` into an early-exiting `awk`. This removes the previously identified SIGPIPE/pipefail race from the release-signing and signature-qualification paths and keeps digest resolution consistent with the runtime-neutral verifier. + +The `Release publish qualification` workflow runs on pull requests and requires: + +- release/signature scripts to pass shell syntax validation; +- `sign-release.yml` to use Skopeo digest resolution and not the old Buildx/awk path; +- `scripts/qualify_signature.sh` to use the same Skopeo path; +- the current verification implementation to successfully verify the known-good signed `v0.2.3` release. + +## v0.2.4 acceptance + +After this change is merged, create `v0.2.4` at the exact qualified `main` commit. The release is accepted only when the tag-triggered workflows prove all of the following: + +1. the `0.2.4` OCI index is published; +2. `linux/amd64` and `linux/arm64` manifests are present; +3. provenance and SBOM attestations are present; +4. the immutable index digest is signed by the exact `sign-release.yml@refs/tags/v0.2.4` GitHub Actions identity; +5. signature verification passes for that exact digest; +6. release qualification confirms semantic aliases and `latest` resolve to the expected release digest; +7. runtime qualification passes on the published image. + +Only after these gates are green should a GitHub Release object for `v0.2.4` be published. diff --git a/scripts/qualify_signature.sh b/scripts/qualify_signature.sh index 2471592..2952882 100644 --- a/scripts/qualify_signature.sh +++ b/scripts/qualify_signature.sh @@ -19,12 +19,19 @@ if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then exit 1 fi +for command in skopeo cosign; do + if ! command -v "$command" >/dev/null 2>&1; then + echo "required command not found: $command" >&2 + exit 1 + fi +done + primary="$IMAGE:$VERSION" identity="https://github.com/Ploos-AS/glowing-bear/.github/workflows/sign-release.yml@refs/tags/$TAG" image_digest="" for attempt in $(seq 1 "$WAIT_ATTEMPTS"); do - image_digest="$(docker buildx imagetools inspect "$primary" 2>/dev/null | awk '/^Digest:/ {print $2; exit}' || true)" + image_digest="$(skopeo inspect --no-tags --format '{{.Digest}}' "docker://$primary" 2>/dev/null || true)" if [[ "$image_digest" =~ ^sha256:[0-9a-f]{64}$ ]]; then echo "Found $primary at $image_digest (attempt $attempt/$WAIT_ATTEMPTS)" break