# Supply-chain gate (task 46). Coordinates with task 42's `ci.yml`. # # Per-PR legs: `manifest-lint` (kustomize build + scripts/check_manifests.py, # including the L7-vs-L4 mTLS pairing check). # Per-push-to-master legs: the image is built per-arch on native runners # (`build` matrix: linux/amd64 on ubuntu-24.04, linux/arm64 on ubuntu-24.04-arm, # no QEMU), each pushed by digest with SBOM + provenance; the `merge` job then # assembles the multi-arch manifest (`imagetools create`), publishes it under # the mutable `latest` tag, scans it (trivy/grype gate HIGH/CRITICAL, syft SBOM # artifact), keyless-signs the index with cosign, and `pin-digest` writes the # index digest into `deploy/k8s/kustomization.yaml` (strict lint re-run). # Release tags (`v*`): the merged multi-arch image is published under semver # tags (X.Y.Z, X.Y, X, `latest`; a pre-release tag gets only X.Y.Z) and a GitHub # Release is created. `pin-digest` does not run on a tag (it tracks master). # No `sha-` tag is published: a master push only moves `latest`, and # GHCR keeps the superseded (now-untagged) manifests until they are pruned. # Scheduled legs (weekly rebuild, Monday 04:00 UTC): rebuild + rescan + resign # so base-image digests advance even without code changes. # # The L7 mTLS constraint is DOCUMENTED (docs/DEPLOYMENT.md) and lint-checked # here; it is explicitly NOT exercised end-to-end in CI. name: supply-chain on: push: branches: ["master"] tags: ["v*"] pull_request: {} schedule: # Weekly rebuild (Monday 04:00 UTC). - cron: "0 4 * * 1" workflow_dispatch: {} permissions: contents: read env: REGISTRY_IMAGE: ghcr.io/syavne/d-oauth-server SQLX_OFFLINE: "true" jobs: manifest-lint: name: manifest-lint runs-on: ubuntu-24.04 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.x" - name: Install pyyaml + kustomize run: | pip install pyyaml curl -sSfL https://github.com/kubernetes-sigs/kustomize/releases/download/kustomize%2Fv5.8.3/kustomize_v5.8.3_linux_amd64.tar.gz \ | tar -xzf - kustomize && sudo mv kustomize /usr/local/bin/ - name: Kustomize build run: kustomize build deploy/k8s - name: Manifest policy lint (probes, PDB, no-baked-secrets, L7-vs-L4 mTLS) run: python3 scripts/check_manifests.py build: name: build (${{ matrix.arch }}) if: github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' strategy: fail-fast: false matrix: include: - runner: ubuntu-24.04 platform: linux/amd64 arch: amd64 - runner: ubuntu-24.04-arm platform: linux/arm64 arch: arm64 runs-on: ${{ matrix.runner }} permissions: contents: read packages: write id-token: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build ${{ matrix.arch }} natively and push by digest id: bake run: | set -euo pipefail IMAGE_TAGS="${REGISTRY_IMAGE}" docker buildx bake image \ --set "image.platform=${{ matrix.platform }}" \ --set "image.output=type=image,push=true,push-by-digest=true,name-canonical=true" \ --sbom=true \ --provenance=mode=max \ --metadata-file /tmp/bake-metadata.json digest=$(python3 -c " import json meta = json.load(open('/tmp/bake-metadata.json')) print(meta['image']['containerimage.digest']) ") mkdir -p "${RUNNER_TEMP}/digests" touch "${RUNNER_TEMP}/digests/${digest#sha256:}" - name: Upload ${{ matrix.arch }} digest uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: digests-${{ matrix.arch }} path: ${{ runner.temp }}/digests/* if-no-files-found: error retention-days: 1 merge: name: merge needs: [build] if: github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' runs-on: ubuntu-24.04 permissions: contents: read packages: write id-token: write security-events: write outputs: digest: ${{ steps.merge.outputs.digest }} steps: - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Download per-arch digests uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: path: ${{ runner.temp }}/digests pattern: digests-* merge-multiple: true - name: Resolve publish tags id: tags run: | set -euo pipefail if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then version="${GITHUB_REF_NAME#v}" if ! [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then echo "::error::release tag '${GITHUB_REF_NAME}' is not semver (expected vX.Y.Z or vX.Y.Z-pre)" exit 1 fi tags="${REGISTRY_IMAGE}:${version}" if [[ "${version}" != *-* ]]; then major="${version%%.*}" minor="${version#*.}"; minor="${minor%%.*}" tags+=",${REGISTRY_IMAGE}:${major}.${minor},${REGISTRY_IMAGE}:${major},${REGISTRY_IMAGE}:latest" fi else tags="${REGISTRY_IMAGE}:latest" fi echo "tags=${tags}" >> "${GITHUB_OUTPUT}" - name: Create multi-arch manifest (imagetools create) id: merge working-directory: ${{ runner.temp }}/digests run: | set -euo pipefail tag_args=() IFS=',' read -ra tags <<< "${{ steps.tags.outputs.tags }}" for tag in "${tags[@]}"; do tag_args+=(-t "${tag}"); done docker buildx imagetools create "${tag_args[@]}" \ $(printf "${REGISTRY_IMAGE}@sha256:%s " *) digest=$(docker buildx imagetools inspect "${tags[0]}" \ --format '{{json .Manifest.Digest}}' | tr -d '"') echo "digest=${digest}" >> "${GITHUB_OUTPUT}" - name: SBOM (syft, committed as artifact) uses: anchore/sbom-action@66cbf4bc1f1c0d2edc94016e65bc221b6bb0ad6c # v0.24.3 with: image: ${{ env.REGISTRY_IMAGE }}@${{ steps.merge.outputs.digest }} artifact-name: sbom.spdx.json - name: Scan (trivy, gate HIGH/CRITICAL) uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: image-ref: ${{ env.REGISTRY_IMAGE }}@${{ steps.merge.outputs.digest }} format: sarif output: trivy.sarif severity: HIGH,CRITICAL exit-code: "1" # v0.36.0 drops `--severity` for SARIF output unless this is true, so # without it the exit-code gate fires on every severity (not just # HIGH/CRITICAL); keep it true to preserve the gate. limit-severities-for-sarif: "true" - name: Upload trivy SARIF to code scanning # `if: always()` so the findings still reach the Security tab when the # gate above fails the job (exit-code 1). if: always() uses: github/codeql-action/upload-sarif@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 with: sarif_file: trivy.sarif category: trivy - name: Scan (grype, gate high/critical) id: grype uses: anchore/scan-action@27805bf3b4e84b4a5c980df22ed233c00390a439 # v7.4.2 with: image: ${{ env.REGISTRY_IMAGE }}@${{ steps.merge.outputs.digest }} fail-build: true severity-cutoff: high - name: Upload grype SARIF to code scanning # `if: always()` so grype findings reach the Security tab even when the # gate above fails the job. if: always() uses: github/codeql-action/upload-sarif@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 with: sarif_file: ${{ steps.grype.outputs.sarif }} category: grype - uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 with: cosign-release: "v3.0.6" - name: Keyless sign (cosign, OIDC) env: COSIGN_YES: "true" run: | cosign sign "${REGISTRY_IMAGE}@${{ steps.merge.outputs.digest }}" pin-digest: name: pin-digest needs: [merge] # Only master/scheduled builds advance the committed digest; a release tag # must not rewrite kustomization. if: github.event_name != 'push' || !startsWith(github.ref, 'refs/tags/') runs-on: ubuntu-24.04 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.x" - name: Pin kustomization to the built digest + strict lint env: DIGEST: ${{ needs.merge.outputs.digest }} run: | pip install pyyaml python3 - "${DIGEST}" <<'EOF' import sys from pathlib import Path import yaml digest = sys.argv[1] assert digest.startswith("sha256:"), f"bad digest: {digest}" path = Path("deploy/k8s/kustomization.yaml") doc = yaml.safe_load(path.read_text()) for img in doc.get("images", []): if img.get("name") == "ghcr.io/syavne/d-oauth-server": img.pop("newTag", None) img["digest"] = digest path.write_text(yaml.safe_dump(doc, sort_keys=False)) EOF python3 scripts/check_manifests.py --strict-digest release: name: release needs: [merge] if: startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-24.04 permissions: contents: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Create GitHub Release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} DIGEST: ${{ needs.merge.outputs.digest }} run: | flags=() if [[ "${GITHUB_REF_NAME}" == *-* ]]; then flags+=(--prerelease) fi gh release create "${GITHUB_REF_NAME}" \ --verify-tag \ --generate-notes \ --title "${GITHUB_REF_NAME}" \ "${flags[@]}" \ --notes "Container image \`${REGISTRY_IMAGE}@${DIGEST}\` (linux/amd64 + linux/arm64), signed with cosign; the SBOM and provenance attestations are attached to the image."