supply-chain.yml 12 KB

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