# 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: multi-arch `docker buildx bake` with SBOM + # provenance, trivy/grype gating HIGH/CRITICAL, keyless cosign signing, and # digest pinning of `deploy/k8s/kustomization.yaml` (strict lint re-run). # Release tags (`v*`): the same signed multi-arch build 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). # 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@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 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.4.3/kustomize_v5.4.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 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 outputs: digest: ${{ steps.bake.outputs.digest }} steps: - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 - uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Multi-arch bake with SBOM + provenance id: bake 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 tags+=",${REGISTRY_IMAGE}:sha-${GITHUB_SHA::12}" else tags="${REGISTRY_IMAGE}:sha-${GITHUB_SHA::12}" fi echo "tags=${tags}" >> "${GITHUB_OUTPUT}" IMAGE_TAGS="${tags}" docker buildx bake image \ --set image.output="type=registry" \ --sbom=true \ --provenance=mode=max \ --metadata-file /tmp/bake-metadata.json python3 -c " import json meta = json.load(open('/tmp/bake-metadata.json')) node = meta['image']['containerimage.digest'] open('${GITHUB_OUTPUT}', 'a').write(f'digest={node}\n') " - name: SBOM (syft, committed as artifact) uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0 with: image: ${{ env.REGISTRY_IMAGE }}@${{ steps.bake.outputs.digest }} artifact-name: sbom.spdx.json - name: Scan (trivy, gate HIGH/CRITICAL) uses: aquasecurity/trivy-action@6e7b7d1fd3e4fef0c5fa8cce1229c54b2c9bd0d8 # v0.24.0 with: image-ref: ${{ env.REGISTRY_IMAGE }}@${{ steps.bake.outputs.digest }} format: sarif output: trivy.sarif severity: HIGH,CRITICAL exit-code: "1" - name: Scan (grype, gate high/critical) uses: anchore/scan-action@64a33b277ea7a1215a3c142735a1091341939ff5 # v4 with: image: ${{ env.REGISTRY_IMAGE }}@${{ steps.bake.outputs.digest }} fail-build: true severity-cutoff: high - uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3 - name: Keyless sign (cosign, OIDC) env: COSIGN_YES: "true" run: | cosign sign "${REGISTRY_IMAGE}@${{ steps.bake.outputs.digest }}" cosign attest --predicate /tmp/bake-metadata.json \ "${REGISTRY_IMAGE}@${{ steps.bake.outputs.digest }}" pin-digest: name: pin-digest needs: [build] # 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@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: "3.x" - name: Pin kustomization to the built digest + strict lint env: DIGEST: ${{ needs.build.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: [build] if: startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-24.04 permissions: contents: write steps: - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 - name: Create GitHub Release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} DIGEST: ${{ needs.build.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."