supply-chain.yml 10 KB

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