supply-chain.yml 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  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. outputs:
  113. digest: ${{ steps.merge.outputs.digest }}
  114. steps:
  115. - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
  116. - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
  117. with:
  118. registry: ghcr.io
  119. username: ${{ github.actor }}
  120. password: ${{ secrets.GITHUB_TOKEN }}
  121. - name: Download per-arch digests
  122. uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
  123. with:
  124. path: ${{ runner.temp }}/digests
  125. pattern: digests-*
  126. merge-multiple: true
  127. - name: Resolve publish tags
  128. id: tags
  129. run: |
  130. set -euo pipefail
  131. if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
  132. version="${GITHUB_REF_NAME#v}"
  133. if ! [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
  134. echo "::error::release tag '${GITHUB_REF_NAME}' is not semver (expected vX.Y.Z or vX.Y.Z-pre)"
  135. exit 1
  136. fi
  137. tags="${REGISTRY_IMAGE}:${version}"
  138. if [[ "${version}" != *-* ]]; then
  139. major="${version%%.*}"
  140. minor="${version#*.}"; minor="${minor%%.*}"
  141. tags+=",${REGISTRY_IMAGE}:${major}.${minor},${REGISTRY_IMAGE}:${major},${REGISTRY_IMAGE}:latest"
  142. fi
  143. tags+=",${REGISTRY_IMAGE}:sha-${GITHUB_SHA::12}"
  144. else
  145. tags="${REGISTRY_IMAGE}:sha-${GITHUB_SHA::12}"
  146. fi
  147. echo "tags=${tags}" >> "${GITHUB_OUTPUT}"
  148. - name: Create multi-arch manifest (imagetools create)
  149. id: merge
  150. working-directory: ${{ runner.temp }}/digests
  151. run: |
  152. set -euo pipefail
  153. tag_args=()
  154. IFS=',' read -ra tags <<< "${{ steps.tags.outputs.tags }}"
  155. for tag in "${tags[@]}"; do tag_args+=(-t "${tag}"); done
  156. docker buildx imagetools create "${tag_args[@]}" \
  157. $(printf "${REGISTRY_IMAGE}@sha256:%s " *)
  158. digest=$(docker buildx imagetools inspect "${tags[0]}" \
  159. --format '{{json .Manifest.Digest}}' | tr -d '"')
  160. echo "digest=${digest}" >> "${GITHUB_OUTPUT}"
  161. - name: SBOM (syft, committed as artifact)
  162. uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0
  163. with:
  164. image: ${{ env.REGISTRY_IMAGE }}@${{ steps.merge.outputs.digest }}
  165. artifact-name: sbom.spdx.json
  166. - name: Scan (trivy, gate HIGH/CRITICAL)
  167. uses: aquasecurity/trivy-action@6e7b7d1fd3e4fef0c5fa8cce1229c54b2c9bd0d8 # v0.24.0
  168. with:
  169. image-ref: ${{ env.REGISTRY_IMAGE }}@${{ steps.merge.outputs.digest }}
  170. format: sarif
  171. output: trivy.sarif
  172. severity: HIGH,CRITICAL
  173. exit-code: "1"
  174. - name: Scan (grype, gate high/critical)
  175. uses: anchore/scan-action@64a33b277ea7a1215a3c142735a1091341939ff5 # v4
  176. with:
  177. image: ${{ env.REGISTRY_IMAGE }}@${{ steps.merge.outputs.digest }}
  178. fail-build: true
  179. severity-cutoff: high
  180. - uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3
  181. - name: Keyless sign (cosign, OIDC)
  182. env:
  183. COSIGN_YES: "true"
  184. run: |
  185. cosign sign "${REGISTRY_IMAGE}@${{ steps.merge.outputs.digest }}"
  186. pin-digest:
  187. name: pin-digest
  188. needs: [merge]
  189. # Only master/scheduled builds advance the committed digest; a release tag
  190. # must not rewrite kustomization.
  191. if: github.event_name != 'push' || !startsWith(github.ref, 'refs/tags/')
  192. runs-on: ubuntu-24.04
  193. steps:
  194. - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
  195. - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
  196. with:
  197. python-version: "3.x"
  198. - name: Pin kustomization to the built digest + strict lint
  199. env:
  200. DIGEST: ${{ needs.merge.outputs.digest }}
  201. run: |
  202. pip install pyyaml
  203. python3 - "${DIGEST}" <<'EOF'
  204. import sys
  205. from pathlib import Path
  206. import yaml
  207. digest = sys.argv[1]
  208. assert digest.startswith("sha256:"), f"bad digest: {digest}"
  209. path = Path("deploy/k8s/kustomization.yaml")
  210. doc = yaml.safe_load(path.read_text())
  211. for img in doc.get("images", []):
  212. if img.get("name") == "ghcr.io/syavne/d-oauth-server":
  213. img.pop("newTag", None)
  214. img["digest"] = digest
  215. path.write_text(yaml.safe_dump(doc, sort_keys=False))
  216. EOF
  217. python3 scripts/check_manifests.py --strict-digest
  218. release:
  219. name: release
  220. needs: [merge]
  221. if: startsWith(github.ref, 'refs/tags/v')
  222. runs-on: ubuntu-24.04
  223. permissions:
  224. contents: write
  225. steps:
  226. - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
  227. - name: Create GitHub Release
  228. env:
  229. GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
  230. DIGEST: ${{ needs.merge.outputs.digest }}
  231. run: |
  232. flags=()
  233. if [[ "${GITHUB_REF_NAME}" == *-* ]]; then
  234. flags+=(--prerelease)
  235. fi
  236. gh release create "${GITHUB_REF_NAME}" \
  237. --verify-tag \
  238. --generate-notes \
  239. --title "${GITHUB_REF_NAME}" \
  240. "${flags[@]}" \
  241. --notes "Container image \`${REGISTRY_IMAGE}@${DIGEST}\` (linux/amd64 + linux/arm64), signed with cosign; the SBOM and provenance attestations are attached to the image."