supply-chain.yml 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263
  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: the image is built per-arch on native runners
  6. # (`build` matrix: linux/amd64 on ubuntu-24.04, linux/arm64 on ubuntu-24.04-arm,
  7. # no QEMU), each pushed by digest with SBOM + provenance; the `merge` job then
  8. # assembles the multi-arch manifest (`imagetools create`), publishes it under
  9. # the mutable `latest` tag, scans it (trivy/grype gate HIGH/CRITICAL, syft SBOM
  10. # artifact), and keyless-signs the index with cosign.
  11. # Release tags (`v*`): the merged multi-arch image is published under semver
  12. # tags (X.Y.Z, X.Y, X, `latest`; a pre-release tag gets only X.Y.Z) and a GitHub
  13. # Release is created.
  14. # No `sha-<commit>` tag is published: a master push only moves `latest`, and
  15. # GHCR keeps the superseded (now-untagged) manifests until they are pruned.
  16. # Scheduled legs (weekly rebuild, Monday 04:00 UTC): rebuild + rescan + resign
  17. # (advancing base-image digests), and `cleanup` prunes superseded untagged
  18. # manifests and the legacy `sha-*` tags, keeping `latest` and release tags.
  19. #
  20. # The L7 mTLS constraint is DOCUMENTED (docs/DEPLOYMENT.md) and lint-checked
  21. # here; it is explicitly NOT exercised end-to-end in CI.
  22. name: supply-chain
  23. on:
  24. push:
  25. branches: ["master"]
  26. tags: ["v*"]
  27. pull_request: {}
  28. schedule:
  29. # Weekly rebuild (Monday 04:00 UTC).
  30. - cron: "0 4 * * 1"
  31. workflow_dispatch: {}
  32. permissions:
  33. contents: read
  34. env:
  35. REGISTRY_IMAGE: ghcr.io/syavne/d-oauth-server
  36. SQLX_OFFLINE: "true"
  37. jobs:
  38. manifest-lint:
  39. name: manifest-lint
  40. runs-on: ubuntu-24.04
  41. steps:
  42. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
  43. - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
  44. with:
  45. python-version: "3.x"
  46. - name: Install pyyaml + kustomize
  47. run: |
  48. pip install pyyaml
  49. curl -sSfL https://github.com/kubernetes-sigs/kustomize/releases/download/kustomize%2Fv5.8.3/kustomize_v5.8.3_linux_amd64.tar.gz \
  50. | tar -xzf - kustomize && sudo mv kustomize /usr/local/bin/
  51. - name: Kustomize build
  52. run: kustomize build deploy/k8s
  53. - name: Manifest policy lint (probes, PDB, no-baked-secrets, L7-vs-L4 mTLS)
  54. run: python3 scripts/check_manifests.py
  55. build:
  56. name: build (${{ matrix.arch }})
  57. if: github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
  58. strategy:
  59. fail-fast: false
  60. matrix:
  61. include:
  62. - runner: ubuntu-24.04
  63. platform: linux/amd64
  64. arch: amd64
  65. - runner: ubuntu-24.04-arm
  66. platform: linux/arm64
  67. arch: arm64
  68. runs-on: ${{ matrix.runner }}
  69. permissions:
  70. contents: read
  71. packages: write
  72. id-token: write
  73. steps:
  74. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
  75. - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
  76. - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
  77. with:
  78. registry: ghcr.io
  79. username: ${{ github.actor }}
  80. password: ${{ secrets.GITHUB_TOKEN }}
  81. - name: Build ${{ matrix.arch }} natively and push by digest
  82. id: bake
  83. run: |
  84. set -euo pipefail
  85. IMAGE_TAGS="${REGISTRY_IMAGE}" docker buildx bake image \
  86. --set "image.platform=${{ matrix.platform }}" \
  87. --set "image.output=type=image,push=true,push-by-digest=true,name-canonical=true" \
  88. --sbom=true \
  89. --provenance=mode=max \
  90. --metadata-file /tmp/bake-metadata.json
  91. digest=$(python3 -c "
  92. import json
  93. meta = json.load(open('/tmp/bake-metadata.json'))
  94. print(meta['image']['containerimage.digest'])
  95. ")
  96. mkdir -p "${RUNNER_TEMP}/digests"
  97. touch "${RUNNER_TEMP}/digests/${digest#sha256:}"
  98. - name: Upload ${{ matrix.arch }} digest
  99. uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
  100. with:
  101. name: digests-${{ matrix.arch }}
  102. path: ${{ runner.temp }}/digests/*
  103. if-no-files-found: error
  104. retention-days: 1
  105. merge:
  106. name: merge
  107. needs: [build]
  108. if: github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
  109. runs-on: ubuntu-24.04
  110. permissions:
  111. contents: read
  112. packages: write
  113. id-token: write
  114. security-events: write
  115. outputs:
  116. digest: ${{ steps.merge.outputs.digest }}
  117. steps:
  118. - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
  119. - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
  120. with:
  121. registry: ghcr.io
  122. username: ${{ github.actor }}
  123. password: ${{ secrets.GITHUB_TOKEN }}
  124. - name: Download per-arch digests
  125. uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
  126. with:
  127. path: ${{ runner.temp }}/digests
  128. pattern: digests-*
  129. merge-multiple: true
  130. - name: Resolve publish tags
  131. id: tags
  132. run: |
  133. set -euo pipefail
  134. if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
  135. version="${GITHUB_REF_NAME#v}"
  136. if ! [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
  137. echo "::error::release tag '${GITHUB_REF_NAME}' is not semver (expected vX.Y.Z or vX.Y.Z-pre)"
  138. exit 1
  139. fi
  140. tags="${REGISTRY_IMAGE}:${version}"
  141. if [[ "${version}" != *-* ]]; then
  142. major="${version%%.*}"
  143. minor="${version#*.}"; minor="${minor%%.*}"
  144. tags+=",${REGISTRY_IMAGE}:${major}.${minor},${REGISTRY_IMAGE}:${major},${REGISTRY_IMAGE}:latest"
  145. fi
  146. else
  147. tags="${REGISTRY_IMAGE}:latest"
  148. fi
  149. echo "tags=${tags}" >> "${GITHUB_OUTPUT}"
  150. - name: Create multi-arch manifest (imagetools create)
  151. id: merge
  152. working-directory: ${{ runner.temp }}/digests
  153. run: |
  154. set -euo pipefail
  155. tag_args=()
  156. IFS=',' read -ra tags <<< "${{ steps.tags.outputs.tags }}"
  157. for tag in "${tags[@]}"; do tag_args+=(-t "${tag}"); done
  158. docker buildx imagetools create "${tag_args[@]}" \
  159. $(printf "${REGISTRY_IMAGE}@sha256:%s " *)
  160. digest=$(docker buildx imagetools inspect "${tags[0]}" \
  161. --format '{{json .Manifest.Digest}}' | tr -d '"')
  162. echo "digest=${digest}" >> "${GITHUB_OUTPUT}"
  163. - name: SBOM (syft, committed as artifact)
  164. uses: anchore/sbom-action@66cbf4bc1f1c0d2edc94016e65bc221b6bb0ad6c # v0.24.3
  165. with:
  166. image: ${{ env.REGISTRY_IMAGE }}@${{ steps.merge.outputs.digest }}
  167. artifact-name: sbom.spdx.json
  168. - name: Scan (trivy, gate HIGH/CRITICAL)
  169. uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
  170. with:
  171. image-ref: ${{ env.REGISTRY_IMAGE }}@${{ steps.merge.outputs.digest }}
  172. format: sarif
  173. output: trivy.sarif
  174. severity: HIGH,CRITICAL
  175. exit-code: "1"
  176. # v0.36.0 drops `--severity` for SARIF output unless this is true, so
  177. # without it the exit-code gate fires on every severity (not just
  178. # HIGH/CRITICAL); keep it true to preserve the gate.
  179. limit-severities-for-sarif: "true"
  180. - name: Upload trivy SARIF to code scanning
  181. # `if: always()` so the findings still reach the Security tab when the
  182. # gate above fails the job (exit-code 1).
  183. if: always()
  184. uses: github/codeql-action/upload-sarif@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
  185. with:
  186. sarif_file: trivy.sarif
  187. category: trivy
  188. - name: Scan (grype, gate high/critical)
  189. id: grype
  190. uses: anchore/scan-action@27805bf3b4e84b4a5c980df22ed233c00390a439 # v7.4.2
  191. with:
  192. image: ${{ env.REGISTRY_IMAGE }}@${{ steps.merge.outputs.digest }}
  193. fail-build: true
  194. severity-cutoff: high
  195. - name: Upload grype SARIF to code scanning
  196. # `if: always()` so grype findings reach the Security tab even when the
  197. # gate above fails the job.
  198. if: always()
  199. uses: github/codeql-action/upload-sarif@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
  200. with:
  201. sarif_file: ${{ steps.grype.outputs.sarif }}
  202. category: grype
  203. - uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
  204. with:
  205. cosign-release: "v3.0.6"
  206. - name: Keyless sign (cosign, OIDC)
  207. env:
  208. COSIGN_YES: "true"
  209. run: |
  210. cosign sign "${REGISTRY_IMAGE}@${{ steps.merge.outputs.digest }}"
  211. release:
  212. name: release
  213. needs: [merge]
  214. if: startsWith(github.ref, 'refs/tags/v')
  215. runs-on: ubuntu-24.04
  216. permissions:
  217. contents: write
  218. steps:
  219. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
  220. - name: Create GitHub Release
  221. env:
  222. GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
  223. DIGEST: ${{ needs.merge.outputs.digest }}
  224. run: |
  225. flags=()
  226. if [[ "${GITHUB_REF_NAME}" == *-* ]]; then
  227. flags+=(--prerelease)
  228. fi
  229. gh release create "${GITHUB_REF_NAME}" \
  230. --verify-tag \
  231. --generate-notes \
  232. --title "${GITHUB_REF_NAME}" \
  233. "${flags[@]}" \
  234. --notes "Container image \`${REGISTRY_IMAGE}@${DIGEST}\` (linux/amd64 + linux/arm64), signed with cosign; the SBOM and provenance attestations are attached to the image."
  235. cleanup:
  236. name: cleanup
  237. needs: [merge]
  238. # GHCR has no retention and keeps untagged manifests, so every `latest` move
  239. # leaves the previous one behind. Delete untagged manifests and the legacy
  240. # `sha-*` tags (keeping `latest` and every release tag); `needs: [merge]`
  241. # avoids racing the in-progress per-arch (untagged) pushes.
  242. if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
  243. runs-on: ubuntu-24.04
  244. permissions:
  245. contents: read
  246. packages: write
  247. steps:
  248. - uses: dataaxiom/ghcr-cleanup-action@d52806a0dc70b430571a37da1fde39733ffd640f # v1.2.2
  249. with:
  250. token: ${{ secrets.GITHUB_TOKEN }}
  251. package: d-oauth-server
  252. exclude-tags: latest
  253. delete-tags: "sha-*"
  254. delete-untagged: true