From 862fa87e28a261cb4272f5f12561abd1dae00415 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Sch=C3=A4fer?= Date: Sun, 2 Nov 2025 00:49:04 +0100 Subject: [PATCH] Refactor CI/CD workflow for improved release process Updated CI/CD workflow to include new job steps for release preparation, tagging, and Docker image handling. Added validation for version input and improved logging and error handling. --- .github/workflows/cicd.yml | 730 +++++++++++++++++++++++++++++++++---- 1 file changed, 657 insertions(+), 73 deletions(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index ab4f2f8..dfc725f 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -1,78 +1,662 @@ name: CI/CD Pipeline +# CI/CD workflow for building, publishing, mirroring, signing container images and building release binaries. +# Actions are pinned to specific SHAs to reduce supply-chain risk. This workflow triggers on tag push events. + +permissions: + contents: write # gh-release + packages: write # GHCR push + id-token: write # Keyless-Signatures & Attestations + attestations: write # actions/attest-build-provenance + security-events: write # upload-sarif + actions: read + +# Required secrets: +# - DOCKER_HUB_USERNAME / DOCKER_HUB_ACCESS_TOKEN: push to Docker Hub +# - GITHUB_TOKEN: used for GHCR login and OIDC keyless signing +# - COSIGN_PRIVATE_KEY / COSIGN_PASSWORD / COSIGN_PUBLIC_KEY: for key-based signing + on: - push: - tags: - - "[0-9]+.[0-9]+.[0-9]+" + push: + tags: + - "[0-9]+.[0-9]+.[0-9]+" + workflow_dispatch: + inputs: + version: + description: "SemVer version to release (e.g., 1.2.3, no leading 'v')" + required: true + type: string + publish_latest: + description: "Also publish the 'latest' image tag" + required: true + type: boolean + default: false + publish_minor: + description: "Also publish the 'major.minor' image tag (e.g., 1.2)" + required: true + type: boolean + default: false + target_branch: + description: "Branch to tag" + required: false + default: "main" + +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && github.event.inputs.version || github.ref_name }} + cancel-in-progress: true jobs: - release: - name: Build and Release - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v5 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Log in to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKER_HUB_USERNAME }} - password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - - - name: Extract tag name - id: get-tag - run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV - - - name: Install Go - uses: actions/setup-go@v6 - with: - go-version: 1.25 - - - name: Update version in package.json - run: | - TAG=${{ env.TAG }} - sed -i "s/export const APP_VERSION = \".*\";/export const APP_VERSION = \"$TAG\";/" server/lib/consts.ts - cat server/lib/consts.ts - - - name: Pull latest Gerbil version - id: get-gerbil-tag - run: | - LATEST_TAG=$(curl -s https://api.github.com/repos/fosrl/gerbil/tags | jq -r '.[0].name') - echo "LATEST_GERBIL_TAG=$LATEST_TAG" >> $GITHUB_ENV - - - name: Pull latest Badger version - id: get-badger-tag - run: | - LATEST_TAG=$(curl -s https://api.github.com/repos/fosrl/badger/tags | jq -r '.[0].name') - echo "LATEST_BADGER_TAG=$LATEST_TAG" >> $GITHUB_ENV - - - name: Update install/main.go - run: | - PANGOLIN_VERSION=${{ env.TAG }} - GERBIL_VERSION=${{ env.LATEST_GERBIL_TAG }} - BADGER_VERSION=${{ env.LATEST_BADGER_TAG }} - sed -i "s/config.PangolinVersion = \".*\"/config.PangolinVersion = \"$PANGOLIN_VERSION\"/" install/main.go - sed -i "s/config.GerbilVersion = \".*\"/config.GerbilVersion = \"$GERBIL_VERSION\"/" install/main.go - sed -i "s/config.BadgerVersion = \".*\"/config.BadgerVersion = \"$BADGER_VERSION\"/" install/main.go - echo "Updated install/main.go with Pangolin version $PANGOLIN_VERSION, Gerbil version $GERBIL_VERSION, and Badger version $BADGER_VERSION" - cat install/main.go - - - name: Build installer - working-directory: install - run: | - make go-build-release - - - name: Upload artifacts from /install/bin - uses: actions/upload-artifact@v5 - with: - name: install-bin - path: install/bin/ - - - name: Build and push Docker images - run: | - TAG=${{ env.TAG }} - make build-release tag=$TAG + prepare: + if: github.event_name == 'workflow_dispatch' + name: Prepare release (create tag) + runs-on: ubuntu-24.04 + permissions: + contents: write + steps: + - name: Checkout repository + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + fetch-depth: 0 + + - name: Validate version input + shell: bash + env: + INPUT_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + if ! [[ "$INPUT_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$ ]]; then + echo "Invalid version: $INPUT_VERSION (expected X.Y.Z or X.Y.Z-rc.N)" >&2 + exit 1 + fi + - name: Create and push tag + shell: bash + env: + TARGET_BRANCH: ${{ inputs.target_branch }} + VERSION: ${{ inputs.version }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git fetch --prune origin + git checkout "$TARGET_BRANCH" + git pull --ff-only origin "$TARGET_BRANCH" + if git rev-parse -q --verify "refs/tags/$VERSION" >/dev/null; then + echo "Tag $VERSION already exists" >&2 + exit 1 + fi + git tag -a "$VERSION" -m "Release $VERSION" + git push origin "refs/tags/$VERSION" + release: + if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.actor != 'github-actions[bot]') }} + name: Build and Release + runs-on: ubuntu-24.04 + timeout-minutes: 120 + env: + DOCKERHUB_IMAGE: docker.io/${{ vars.DOCKER_HUB_USERNAME }}/${{ github.event.repository.name }} + GHCR_IMAGE: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }} + + steps: + - name: Checkout code + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + fetch-depth: 0 + + - name: Capture created timestamp + run: echo "IMAGE_CREATED=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> $GITHUB_ENV + shell: bash + + - name: Set up QEMU + uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 + + - name: Log in to Docker Hub + uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + with: + registry: docker.io + username: ${{ vars.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + + - name: Log in to GHCR + uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Normalize image names to lowercase + run: | + set -euo pipefail + echo "GHCR_IMAGE=${GHCR_IMAGE,,}" >> "$GITHUB_ENV" + echo "DOCKERHUB_IMAGE=${DOCKERHUB_IMAGE,,}" >> "$GITHUB_ENV" + shell: bash + + - name: Extract tag name + env: + EVENT_NAME: ${{ github.event_name }} + INPUT_VERSION: ${{ inputs.version }} + run: | + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + echo "TAG=${INPUT_VERSION}" >> $GITHUB_ENV + else + echo "TAG=${{ github.ref_name }}" >> $GITHUB_ENV + fi + shell: bash + + - name: Validate pushed tag format (no leading 'v') + if: ${{ github.event_name == 'push' }} + shell: bash + env: + TAG_GOT: ${{ env.TAG }} + run: | + set -euo pipefail + if [[ "$TAG_GOT" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$ ]]; then + echo "Tag OK: $TAG_GOT" + exit 0 + fi + echo "ERROR: Tag '$TAG_GOT' is not allowed. Use 'X.Y.Z' or 'X.Y.Z-rc.N' (no leading 'v')." >&2 + exit 1 + - name: Wait for tag to be visible (dispatch only) + if: ${{ github.event_name == 'workflow_dispatch' }} + run: | + set -euo pipefail + for i in {1..90}; do + if git ls-remote --tags origin "refs/tags/${TAG}" | grep -qE "refs/tags/${TAG}$"; then + echo "Tag ${TAG} is visible on origin"; exit 0 + fi + echo "Tag not yet visible, retrying... ($i/90)" + sleep 2 + done + echo "Tag ${TAG} not visible after waiting"; exit 1 + shell: bash + + - name: Ensure repository is at the tagged commit (dispatch only) + if: ${{ github.event_name == 'workflow_dispatch' }} + run: | + set -euo pipefail + git fetch --tags --force + git checkout "refs/tags/${TAG}" + echo "Checked out $(git rev-parse --short HEAD) for tag ${TAG}" + shell: bash + + - name: Detect release candidate (rc) + run: | + set -euo pipefail + if [[ "${TAG}" =~ ^[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then + echo "IS_RC=true" >> $GITHUB_ENV + else + echo "IS_RC=false" >> $GITHUB_ENV + fi + shell: bash + + - name: Set up Node.js + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 + with: + node-version-file: .nvmrc + cache: npm + + - name: Install dependencies + run: npm ci + shell: bash + + - name: Copy config file + run: cp config/config.example.yml config/config.yml + shell: bash + + - name: Configure default build flavor + run: | + npm run set:oss + npm run set:sqlite + shell: bash + + - name: Update version references + env: + TAG_VALUE: ${{ env.TAG }} + run: | + set -euo pipefail + python3 - <<'PY' + from pathlib import Path + import os + + tag = os.environ["TAG_VALUE"] + file_path = Path('server/lib/consts.ts') + content = file_path.read_text() + marker = 'export const APP_VERSION = "' + if marker not in content: + raise SystemExit('APP_VERSION constant not found in server/lib/consts.ts') + start = content.index(marker) + len(marker) + end = content.index('"', start) + updated = content[:start] + tag + content[end:] + file_path.write_text(updated) + PY + shell: bash + + - name: Generate SQLite migrations + run: npm run db:sqlite:generate + shell: bash + + - name: Apply SQLite migrations + run: npm run db:sqlite:push + shell: bash + + - name: Generate SQLite init snapshot + run: npx drizzle-kit generate --dialect sqlite --schema ./server/db/sqlite/schema --out init + shell: bash + + - name: Type check + run: npx tsc --noEmit + shell: bash + + - name: Build SQLite distribution + env: + NODE_ENV: production + NEXT_TELEMETRY_DISABLED: "1" + CI: "true" + run: | + npm run build:sqlite + npm run build:cli + shell: bash + + - name: Include init artifacts in dist + run: | + set -euo pipefail + mkdir -p dist/init + if [ -d init ]; then + cp -a init/. dist/init/ + fi + shell: bash + + - name: Package distribution artifact + env: + TAG_VALUE: ${{ env.TAG }} + run: | + set -euo pipefail + tar -czf pangolin-${TAG_VALUE}-sqlite-dist.tar.gz dist + shell: bash + + - name: Resolve publish-latest flag + env: + EVENT_NAME: ${{ github.event_name }} + PL_INPUT: ${{ inputs.publish_latest }} + PL_VAR: ${{ vars.PUBLISH_LATEST }} + run: | + set -euo pipefail + val="false" + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + if [ "${PL_INPUT}" = "true" ]; then val="true"; fi + else + if [ "${PL_VAR}" = "true" ]; then val="true"; fi + fi + echo "PUBLISH_LATEST=$val" >> $GITHUB_ENV + shell: bash + + - name: Resolve publish-minor flag + env: + EVENT_NAME: ${{ github.event_name }} + PM_INPUT: ${{ inputs.publish_minor }} + PM_VAR: ${{ vars.PUBLISH_MINOR }} + run: | + set -euo pipefail + val="false" + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + if [ "${PM_INPUT}" = "true" ]; then val="true"; fi + else + if [ "${PM_VAR}" = "true" ]; then val="true"; fi + fi + echo "PUBLISH_MINOR=$val" >> $GITHUB_ENV + shell: bash + + - name: Resolve license fallback + run: echo "IMAGE_LICENSE=${{ github.event.repository.license.spdx_id || 'NOASSERTION' }}" >> $GITHUB_ENV + shell: bash + + - name: Resolve registries list (GHCR always, Docker Hub only if creds) + shell: bash + run: | + set -euo pipefail + images="${GHCR_IMAGE}" + if [ -n "${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}" ] && [ -n "${{ vars.DOCKER_HUB_USERNAME }}" ]; then + images="${images}\n${DOCKERHUB_IMAGE}" + fi + { + echo 'IMAGE_LIST<> "$GITHUB_ENV" + - name: Docker meta + id: meta + uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0 + with: + images: ${{ env.IMAGE_LIST }} + tags: | + type=semver,pattern={{version}},value=${{ env.TAG }} + type=semver,pattern={{major}}.{{minor}},value=${{ env.TAG }},enable=${{ env.PUBLISH_MINOR == 'true' && env.IS_RC != 'true' }} + type=raw,value=latest,enable=${{ env.PUBLISH_LATEST == 'true' && env.IS_RC != 'true' }} + flavor: | + latest=false + labels: | + org.opencontainers.image.title=${{ github.event.repository.name }} + org.opencontainers.image.version=${{ env.TAG }} + org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.source=${{ github.event.repository.html_url }} + org.opencontainers.image.url=${{ github.event.repository.html_url }} + org.opencontainers.image.documentation=${{ github.event.repository.html_url }} + org.opencontainers.image.description=${{ github.event.repository.description }} + org.opencontainers.image.licenses=${{ env.IMAGE_LICENSE }} + org.opencontainers.image.created=${{ env.IMAGE_CREATED }} + org.opencontainers.image.ref.name=${{ env.TAG }} + org.opencontainers.image.authors=${{ github.repository_owner }} + - name: Echo build config (non-secret) + shell: bash + env: + IMAGE_TITLE: ${{ github.event.repository.name }} + IMAGE_VERSION: ${{ env.TAG }} + IMAGE_REVISION: ${{ github.sha }} + IMAGE_SOURCE_URL: ${{ github.event.repository.html_url }} + IMAGE_URL: ${{ github.event.repository.html_url }} + IMAGE_DESCRIPTION: ${{ github.event.repository.description }} + IMAGE_LICENSE: ${{ env.IMAGE_LICENSE }} + DOCKERHUB_IMAGE: ${{ env.DOCKERHUB_IMAGE }} + GHCR_IMAGE: ${{ env.GHCR_IMAGE }} + DOCKER_HUB_USER: ${{ vars.DOCKER_HUB_USERNAME }} + REPO: ${{ github.repository }} + OWNER: ${{ github.repository_owner }} + WORKFLOW_REF: ${{ github.workflow_ref }} + REF: ${{ github.ref }} + REF_NAME: ${{ github.ref_name }} + RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -euo pipefail + echo "=== OCI Label Values ===" + echo "org.opencontainers.image.title=${IMAGE_TITLE}" + echo "org.opencontainers.image.version=${IMAGE_VERSION}" + echo "org.opencontainers.image.revision=${IMAGE_REVISION}" + echo "org.opencontainers.image.source=${IMAGE_SOURCE_URL}" + echo "org.opencontainers.image.url=${IMAGE_URL}" + echo "org.opencontainers.image.description=${IMAGE_DESCRIPTION}" + echo "org.opencontainers.image.licenses=${IMAGE_LICENSE}" + echo + echo "=== Images ===" + echo "DOCKERHUB_IMAGE=${DOCKERHUB_IMAGE}" + echo "GHCR_IMAGE=${GHCR_IMAGE}" + echo "DOCKER_HUB_USERNAME=${DOCKER_HUB_USER}" + echo + echo "=== GitHub Kontext ===" + echo "repository=${REPO}" + echo "owner=${OWNER}" + echo "workflow_ref=${WORKFLOW_REF}" + echo "ref=${REF}" + echo "ref_name=${REF_NAME}" + echo "run_url=${RUN_URL}" + echo + echo "=== docker/metadata-action outputs (Tags/Labels), raw ===" + echo "::group::tags" + echo "${{ steps.meta.outputs.tags }}" + echo "::endgroup::" + echo "::group::labels" + echo "${{ steps.meta.outputs.labels }}" + echo "::endgroup::" + - name: Build and push (Docker Hub + GHCR) + id: build + uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 + with: + context: . + push: true + platforms: linux/amd64,linux/arm64 + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha,scope=${{ github.repository }} + cache-to: type=gha,mode=max,scope=${{ github.repository }} + provenance: mode=max + sbom: true + + - name: Compute image digest refs + run: | + echo "DIGEST=${{ steps.build.outputs.digest }}" >> $GITHUB_ENV + echo "GHCR_REF=$GHCR_IMAGE@${{ steps.build.outputs.digest }}" >> $GITHUB_ENV + echo "DH_REF=$DOCKERHUB_IMAGE@${{ steps.build.outputs.digest }}" >> $GITHUB_ENV + echo "Built digest: ${{ steps.build.outputs.digest }}" + shell: bash + + - name: Attest build provenance (GHCR) + id: attest-ghcr + uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0 + with: + subject-name: ${{ env.GHCR_IMAGE }} + subject-digest: ${{ steps.build.outputs.digest }} + push-to-registry: true + show-summary: true + + - name: Attest build provenance (Docker Hub) + continue-on-error: true + id: attest-dh + uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0 + with: + subject-name: index.docker.io/${{ vars.DOCKER_HUB_USERNAME }}/${{ github.event.repository.name }} + subject-digest: ${{ steps.build.outputs.digest }} + push-to-registry: true + show-summary: true + + - name: Install cosign + uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0 + with: + cosign-release: 'v3.0.2' + + - name: Sanity check cosign private key + env: + COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} + COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} + run: | + set -euo pipefail + cosign public-key --key env://COSIGN_PRIVATE_KEY >/dev/null + shell: bash + + - name: Sign GHCR image (digest) with key (recursive) + env: + COSIGN_YES: "true" + COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} + COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} + run: | + set -euo pipefail + echo "Signing ${GHCR_REF} (digest) recursively with provided key" + cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${GHCR_REF}" + shell: bash + + - name: Generate SBOM (SPDX JSON) + uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # v0.33.1 + with: + image-ref: ${{ env.GHCR_IMAGE }}@${{ steps.build.outputs.digest }} + format: spdx-json + output: sbom.spdx.json + + - name: Validate SBOM JSON + run: jq -e . sbom.spdx.json >/dev/null + shell: bash + + - name: Minify SBOM JSON (optional hardening) + run: jq -c . sbom.spdx.json > sbom.min.json && mv sbom.min.json sbom.spdx.json + shell: bash + + - name: Create SBOM attestation (GHCR, private key) + env: + COSIGN_YES: "true" + COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} + COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} + run: | + set -euo pipefail + cosign attest \ + --key env://COSIGN_PRIVATE_KEY \ + --type spdxjson \ + --predicate sbom.spdx.json \ + "${GHCR_REF}" + shell: bash + + - name: Create SBOM attestation (Docker Hub, private key) + continue-on-error: true + env: + COSIGN_YES: "true" + COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} + COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} + COSIGN_DOCKER_MEDIA_TYPES: "1" + run: | + set -euo pipefail + cosign attest \ + --key env://COSIGN_PRIVATE_KEY \ + --type spdxjson \ + --predicate sbom.spdx.json \ + "${DH_REF}" + shell: bash + + - name: Keyless sign & verify GHCR digest (OIDC) + env: + COSIGN_YES: "true" + WORKFLOW_REF: ${{ github.workflow_ref }} # owner/repo/.github/workflows/@refs/tags/ + ISSUER: https://token.actions.githubusercontent.com + run: | + set -euo pipefail + echo "Keyless signing ${GHCR_REF}" + cosign sign --rekor-url https://rekor.sigstore.dev --recursive "${GHCR_REF}" + echo "Verify keyless (OIDC) signature policy on ${GHCR_REF}" + cosign verify \ + --certificate-oidc-issuer "${ISSUER}" \ + --certificate-identity "https://github.com/${WORKFLOW_REF}" \ + "${GHCR_REF}" -o text + shell: bash + + - name: Sign Docker Hub image (digest) with key (recursive) + continue-on-error: true + env: + COSIGN_YES: "true" + COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} + COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} + COSIGN_DOCKER_MEDIA_TYPES: "1" + run: | + set -euo pipefail + echo "Signing ${DH_REF} (digest) recursively with provided key (Docker media types fallback)" + cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${DH_REF}" + shell: bash + + - name: Keyless sign & verify Docker Hub digest (OIDC) + continue-on-error: true + env: + COSIGN_YES: "true" + ISSUER: https://token.actions.githubusercontent.com + COSIGN_DOCKER_MEDIA_TYPES: "1" + run: | + set -euo pipefail + echo "Keyless signing ${DH_REF} (force public-good Rekor)" + cosign sign --rekor-url https://rekor.sigstore.dev --recursive "${DH_REF}" + echo "Keyless verify via Rekor (strict identity)" + if ! cosign verify \ + --rekor-url https://rekor.sigstore.dev \ + --certificate-oidc-issuer "${ISSUER}" \ + --certificate-identity "https://github.com/${{ github.workflow_ref }}" \ + "${DH_REF}" -o text; then + echo "Rekor verify failed — retry offline bundle verify (no Rekor)" + if ! cosign verify \ + --offline \ + --certificate-oidc-issuer "${ISSUER}" \ + --certificate-identity "https://github.com/${{ github.workflow_ref }}" \ + "${DH_REF}" -o text; then + echo "Offline bundle verify failed — ignore tlog (TEMP for debugging)" + cosign verify \ + --insecure-ignore-tlog=true \ + --certificate-oidc-issuer "${ISSUER}" \ + --certificate-identity "https://github.com/${{ github.workflow_ref }}" \ + "${DH_REF}" -o text || true + fi + fi + - name: Verify signature (public key) GHCR digest + tag + env: + COSIGN_PUBLIC_KEY: ${{ secrets.COSIGN_PUBLIC_KEY }} + run: | + set -euo pipefail + TAG_VAR="${TAG}" + echo "Verifying (digest) ${GHCR_REF}" + cosign verify --key env://COSIGN_PUBLIC_KEY "$GHCR_REF" -o text + echo "Verifying (tag) $GHCR_IMAGE:$TAG_VAR" + cosign verify --key env://COSIGN_PUBLIC_KEY "$GHCR_IMAGE:$TAG_VAR" -o text + shell: bash + + - name: Verify SBOM attestation (GHCR) + env: + COSIGN_PUBLIC_KEY: ${{ secrets.COSIGN_PUBLIC_KEY }} + run: cosign verify-attestation --key env://COSIGN_PUBLIC_KEY --type spdxjson "$GHCR_REF" -o text + shell: bash + + - name: Verify SLSA provenance (GHCR) + env: + ISSUER: https://token.actions.githubusercontent.com + WFREF: ${{ github.workflow_ref }} + run: | + set -euo pipefail + cosign download attestation "$GHCR_REF" \ + | jq -r '.payload | @base64d | fromjson | .predicateType' | sort -u || true + cosign verify-attestation \ + --type 'https://slsa.dev/provenance/v1' \ + --certificate-oidc-issuer "$ISSUER" \ + --certificate-identity "https://github.com/${WFREF}" \ + --rekor-url https://rekor.sigstore.dev \ + "$GHCR_REF" -o text + shell: bash + + - name: Verify signature (public key) Docker Hub digest + continue-on-error: true + env: + COSIGN_PUBLIC_KEY: ${{ secrets.COSIGN_PUBLIC_KEY }} + COSIGN_DOCKER_MEDIA_TYPES: "1" + run: | + set -euo pipefail + echo "Verifying (digest) ${DH_REF} with Docker media types" + cosign verify --key env://COSIGN_PUBLIC_KEY "${DH_REF}" -o text + shell: bash + + - name: Verify signature (public key) Docker Hub tag + continue-on-error: true + env: + COSIGN_PUBLIC_KEY: ${{ secrets.COSIGN_PUBLIC_KEY }} + COSIGN_DOCKER_MEDIA_TYPES: "1" + run: | + set -euo pipefail + echo "Verifying (tag) $DOCKERHUB_IMAGE:$TAG with Docker media types" + cosign verify --key env://COSIGN_PUBLIC_KEY "$DOCKERHUB_IMAGE:$TAG" -o text + shell: bash + + - name: Trivy scan (GHCR image) + id: trivy + uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # v0.33.1 + with: + image-ref: ${{ env.GHCR_IMAGE }}@${{ steps.build.outputs.digest }} + format: sarif + output: trivy-ghcr.sarif + ignore-unfixed: true + vuln-type: os,library + severity: CRITICAL,HIGH + exit-code: ${{ (vars.TRIVY_FAIL || '0') }} + + - name: Upload SARIF + if: ${{ always() && hashFiles('trivy-ghcr.sarif') != '' }} + uses: github/codeql-action/upload-sarif@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0 + with: + sarif_file: trivy-ghcr.sarif + category: Image Vulnerability Scan + + - name: Create GitHub Release + uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2.4.1 + with: + tag_name: ${{ env.TAG }} + generate_release_notes: true + prerelease: ${{ env.IS_RC == 'true' }} + files: | + pangolin-${{ env.TAG }}-sqlite-dist.tar.gz + fail_on_unmatched_files: true + body: | + ## Container Images + - GHCR: `${{ env.GHCR_REF }}` + - Docker Hub: `${{ env.DH_REF || 'N/A' }}` + **Digest:** `${{ steps.build.outputs.digest }}` + + ## Application Bundles + - SQLite build: `pangolin-${{ env.TAG }}-sqlite-dist.tar.gz`