supply-chain.yml 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191
  1. # Supply-chain gate (task 46). Coordinates with task 42's `ci.yml`.
  2. #
  3. # Per-PR legs: `manifest-lint` (kustomize build + scripts/check_manifests.py,
  4. # including the L7-vs-L4 mTLS pairing check).
  5. # Per-push-to-master legs: multi-arch `docker buildx bake` with SBOM +
  6. # provenance, trivy/grype gating HIGH/CRITICAL, keyless cosign signing, and
  7. # digest pinning of `deploy/k8s/kustomization.yaml` (strict lint re-run).
  8. # Release tags (`v*`): the same signed multi-arch build is published under
  9. # semver tags (X.Y.Z, X.Y, X, `latest`; a pre-release tag gets only X.Y.Z) and
  10. # a GitHub Release is created. `pin-digest` does not run on a tag (it tracks
  11. # master).
  12. # Scheduled legs (weekly rebuild, Monday 04:00 UTC): rebuild + rescan + resign
  13. # so base-image digests advance even without code changes.
  14. #
  15. # The L7 mTLS constraint is DOCUMENTED (docs/DEPLOYMENT.md) and lint-checked
  16. # here; it is explicitly NOT exercised end-to-end in CI.
  17. name: supply-chain
  18. on:
  19. push:
  20. branches: ["master"]
  21. tags: ["v*"]
  22. pull_request: {}
  23. schedule:
  24. # Weekly rebuild (Monday 04:00 UTC).
  25. - cron: "0 4 * * 1"
  26. workflow_dispatch: {}
  27. permissions:
  28. contents: read
  29. env:
  30. REGISTRY_IMAGE: ghcr.io/syavne/d-oauth-server
  31. SQLX_OFFLINE: "true"
  32. jobs:
  33. manifest-lint:
  34. name: manifest-lint
  35. runs-on: ubuntu-24.04
  36. steps:
  37. - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
  38. - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
  39. with:
  40. python-version: "3.x"
  41. - name: Install pyyaml + kustomize
  42. run: |
  43. pip install pyyaml
  44. curl -sSfL https://github.com/kubernetes-sigs/kustomize/releases/download/kustomize%2Fv5.4.3/kustomize_v5.4.3_linux_amd64.tar.gz \
  45. | tar -xzf - kustomize && sudo mv kustomize /usr/local/bin/
  46. - name: Kustomize build
  47. run: kustomize build deploy/k8s
  48. - name: Manifest policy lint (probes, PDB, no-baked-secrets, L7-vs-L4 mTLS)
  49. run: python3 scripts/check_manifests.py
  50. build:
  51. name: build
  52. if: github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
  53. runs-on: ubuntu-24.04
  54. permissions:
  55. contents: read
  56. packages: write
  57. id-token: write
  58. outputs:
  59. digest: ${{ steps.bake.outputs.digest }}
  60. steps:
  61. - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
  62. - uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3
  63. - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
  64. - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
  65. with:
  66. registry: ghcr.io
  67. username: ${{ github.actor }}
  68. password: ${{ secrets.GITHUB_TOKEN }}
  69. - name: Multi-arch bake with SBOM + provenance
  70. id: bake
  71. run: |
  72. set -euo pipefail
  73. if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
  74. version="${GITHUB_REF_NAME#v}"
  75. if ! [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
  76. echo "::error::release tag '${GITHUB_REF_NAME}' is not semver (expected vX.Y.Z or vX.Y.Z-pre)"
  77. exit 1
  78. fi
  79. tags="${REGISTRY_IMAGE}:${version}"
  80. if [[ "${version}" != *-* ]]; then
  81. major="${version%%.*}"
  82. minor="${version#*.}"; minor="${minor%%.*}"
  83. tags+=",${REGISTRY_IMAGE}:${major}.${minor},${REGISTRY_IMAGE}:${major},${REGISTRY_IMAGE}:latest"
  84. fi
  85. tags+=",${REGISTRY_IMAGE}:sha-${GITHUB_SHA::12}"
  86. else
  87. tags="${REGISTRY_IMAGE}:sha-${GITHUB_SHA::12}"
  88. fi
  89. echo "tags=${tags}" >> "${GITHUB_OUTPUT}"
  90. IMAGE_TAGS="${tags}" docker buildx bake image \
  91. --set image.output="type=registry" \
  92. --sbom=true \
  93. --provenance=mode=max \
  94. --metadata-file /tmp/bake-metadata.json
  95. python3 -c "
  96. import json
  97. meta = json.load(open('/tmp/bake-metadata.json'))
  98. node = meta['image']['containerimage.digest']
  99. open('${GITHUB_OUTPUT}', 'a').write(f'digest={node}\n')
  100. "
  101. - name: SBOM (syft, committed as artifact)
  102. uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0
  103. with:
  104. image: ${{ env.REGISTRY_IMAGE }}@${{ steps.bake.outputs.digest }}
  105. artifact-name: sbom.spdx.json
  106. - name: Scan (trivy, gate HIGH/CRITICAL)
  107. uses: aquasecurity/trivy-action@6e7b7d1fd3e4fef0c5fa8cce1229c54b2c9bd0d8 # v0.24.0
  108. with:
  109. image-ref: ${{ env.REGISTRY_IMAGE }}@${{ steps.bake.outputs.digest }}
  110. format: sarif
  111. output: trivy.sarif
  112. severity: HIGH,CRITICAL
  113. exit-code: "1"
  114. - name: Scan (grype, gate high/critical)
  115. uses: anchore/scan-action@64a33b277ea7a1215a3c142735a1091341939ff5 # v4
  116. with:
  117. image: ${{ env.REGISTRY_IMAGE }}@${{ steps.bake.outputs.digest }}
  118. fail-build: true
  119. severity-cutoff: high
  120. - uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3
  121. - name: Keyless sign (cosign, OIDC)
  122. env:
  123. COSIGN_YES: "true"
  124. run: |
  125. cosign sign "${REGISTRY_IMAGE}@${{ steps.bake.outputs.digest }}"
  126. cosign attest --predicate /tmp/bake-metadata.json \
  127. "${REGISTRY_IMAGE}@${{ steps.bake.outputs.digest }}"
  128. pin-digest:
  129. name: pin-digest
  130. needs: [build]
  131. # Only master/scheduled builds advance the committed digest; a release tag
  132. # must not rewrite kustomization.
  133. if: github.event_name != 'push' || !startsWith(github.ref, 'refs/tags/')
  134. runs-on: ubuntu-24.04
  135. steps:
  136. - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
  137. - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
  138. with:
  139. python-version: "3.x"
  140. - name: Pin kustomization to the built digest + strict lint
  141. env:
  142. DIGEST: ${{ needs.build.outputs.digest }}
  143. run: |
  144. pip install pyyaml
  145. python3 - "${DIGEST}" <<'EOF'
  146. import sys
  147. from pathlib import Path
  148. import yaml
  149. digest = sys.argv[1]
  150. assert digest.startswith("sha256:"), f"bad digest: {digest}"
  151. path = Path("deploy/k8s/kustomization.yaml")
  152. doc = yaml.safe_load(path.read_text())
  153. for img in doc.get("images", []):
  154. if img.get("name") == "ghcr.io/syavne/d-oauth-server":
  155. img.pop("newTag", None)
  156. img["digest"] = digest
  157. path.write_text(yaml.safe_dump(doc, sort_keys=False))
  158. EOF
  159. python3 scripts/check_manifests.py --strict-digest
  160. release:
  161. name: release
  162. needs: [build]
  163. if: startsWith(github.ref, 'refs/tags/v')
  164. runs-on: ubuntu-24.04
  165. permissions:
  166. contents: write
  167. steps:
  168. - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
  169. - name: Create GitHub Release
  170. env:
  171. GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
  172. DIGEST: ${{ needs.build.outputs.digest }}
  173. run: |
  174. flags=()
  175. if [[ "${GITHUB_REF_NAME}" == *-* ]]; then
  176. flags+=(--prerelease)
  177. fi
  178. gh release create "${GITHUB_REF_NAME}" \
  179. --verify-tag \
  180. --generate-notes \
  181. --title "${GITHUB_REF_NAME}" \
  182. "${flags[@]}" \
  183. --notes "Container image \`${REGISTRY_IMAGE}@${DIGEST}\` (linux/amd64 + linux/arm64), signed with cosign; the SBOM and provenance attestations are attached to the image."