ci.yml
Snapshot 2263a414fe628107f433d708f83dd82cc29e55a6 from main.
1# Lint, test, build, SBOM, and vulnerability scan.
2# This workflow does not deploy anywhere. It uses the automatic GITHUB_TOKEN only.
3name: ci
4
5on:
6 push:
7 branches: [main]
8 pull_request:
9 workflow_dispatch:
10 workflow_call:
11
12permissions:
13 contents: read
14
15concurrency:
16 # Hard-coded prefix: github.workflow inside a reusable call is the CALLER name,
17 # which deadlocks with release.yml's concurrency group on the same ref.
18 group: pipeforge-ci-${{ github.event_name }}-${{ github.ref }}
19 cancel-in-progress: ${{ github.event_name == 'pull_request' }}
20
21defaults:
22 run:
23 shell: bash
24
25jobs:
26 lint:
27 runs-on: ubuntu-latest
28 timeout-minutes: 10
29 steps:
30 - name: Checkout
31 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
32 with:
33 persist-credentials: false
34
35 - name: Setup Python
36 uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
37 with:
38 python-version: "3.12"
39
40 - name: Ruff and mypy
41 run: |
42 set -euo pipefail
43 python -m pip install --disable-pip-version-check -e ".[dev]"
44 ruff check .
45 ruff format --check .
46 mypy app
47
48 - name: Install pinned actionlint and shellcheck
49 env:
50 ACTIONLINT_VERSION: "1.7.12"
51 ACTIONLINT_SHA256: "8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8"
52 SHELLCHECK_VERSION: "0.11.0"
53 SHELLCHECK_SHA256: "b7af85e41cc99489dcc21d66c6d5f3685138f06d34651e6d34b42ec6d54fe6f6"
54 run: |
55 set -euo pipefail
56 tmpdir="${RUNNER_TEMP}/pf-lint-tools"
57 mkdir -p "${tmpdir}"
58 curl -fsSL -o "${tmpdir}/actionlint.tar.gz" \
59 "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz"
60 printf '%s %s\n' "${ACTIONLINT_SHA256}" "${tmpdir}/actionlint.tar.gz" | sha256sum -c -
61 tar -xzf "${tmpdir}/actionlint.tar.gz" -C "${tmpdir}" actionlint
62 curl -fsSL -o "${tmpdir}/shellcheck.tar.gz" \
63 "https://github.com/koalaman/shellcheck/releases/download/v${SHELLCHECK_VERSION}/shellcheck-v${SHELLCHECK_VERSION}.linux.x86_64.tar.gz"
64 printf '%s %s\n' "${SHELLCHECK_SHA256}" "${tmpdir}/shellcheck.tar.gz" | sha256sum -c -
65 tar -xzf "${tmpdir}/shellcheck.tar.gz" -C "${tmpdir}"
66 install -m 0755 "${tmpdir}/actionlint" /usr/local/bin/actionlint
67 install -m 0755 "${tmpdir}/shellcheck-v${SHELLCHECK_VERSION}/shellcheck" /usr/local/bin/shellcheck
68 command -v shellcheck
69 actionlint -color .github/workflows/*.yml
70
71 test:
72 runs-on: ${{ matrix.os }}
73 timeout-minutes: 15
74 strategy:
75 fail-fast: false
76 matrix:
77 python-version: ["3.10", "3.11", "3.12", "3.13"]
78 os: [ubuntu-latest]
79 steps:
80 - name: Checkout
81 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
82 with:
83 persist-credentials: false
84
85 - name: Setup Python
86 uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
87 with:
88 python-version: ${{ matrix.python-version }}
89
90 - name: Pytest
91 env:
92 PY_VERSION: ${{ matrix.python-version }}
93 run: |
94 set -euo pipefail
95 python -m pip install --disable-pip-version-check -e ".[dev]"
96 python -m pytest \
97 --cov=app \
98 --cov-fail-under=90 \
99 --cov-report=xml:"coverage-${PY_VERSION}.xml" \
100 --junitxml="junit-${PY_VERSION}.xml"
101
102 - name: Upload test reports
103 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
104 with:
105 name: test-py${{ matrix.python-version }}
106 path: |
107 junit-${{ matrix.python-version }}.xml
108 coverage-${{ matrix.python-version }}.xml
109 if-no-files-found: error
110
111 build:
112 needs: [lint, test]
113 runs-on: ubuntu-latest
114 timeout-minutes: 20
115 outputs:
116 image_id: ${{ steps.inspect.outputs.image_id }}
117 steps:
118 - name: Checkout
119 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
120 with:
121 persist-credentials: false
122
123 - name: Setup Python
124 uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
125 with:
126 python-version: "3.12"
127
128 - name: Build the wheel and sdist
129 run: |
130 set -euo pipefail
131 python -m pip install --disable-pip-version-check build
132 python -m build
133
134 - name: Setup Buildx
135 uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
136 with:
137 driver: docker
138
139 - name: Build the image locally
140 uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
141 with:
142 context: .
143 push: false
144 load: true
145 tags: ratecard-api:ci
146 build-args: |
147 IMAGE_SOURCE=https://github.com/${{ github.repository }}
148 # The local load exporter cannot carry provenance. The release workflow attests the pushed image.
149 provenance: false
150 sbom: false
151
152 - name: Save the image archive
153 run: |
154 set -euo pipefail
155 docker save ratecard-api:ci -o image.tar
156
157 - name: Read the local image id
158 id: inspect
159 run: |
160 set -euo pipefail
161 image_id="$(docker image inspect ratecard-api:ci --format '{{.Id}}')"
162 test -n "${image_id}"
163 printf 'image_id=%s\n' "${image_id}" >> "${GITHUB_OUTPUT}"
164
165 - name: Upload distributions
166 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
167 with:
168 name: dist
169 path: dist/
170 if-no-files-found: error
171
172 - name: Upload the image archive
173 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
174 with:
175 name: image
176 path: image.tar
177 if-no-files-found: error
178
179 sbom:
180 needs: build
181 runs-on: ubuntu-latest
182 timeout-minutes: 15
183 outputs:
184 components: ${{ steps.count.outputs.components }}
185 steps:
186 - name: Checkout
187 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
188 with:
189 persist-credentials: false
190
191 - name: Download the image archive
192 uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
193 with:
194 name: image
195 path: /tmp/pf-image
196
197 - name: Load the image
198 run: |
199 set -euo pipefail
200 archive="$(find /tmp/pf-image -name 'image.tar' -print -quit)"
201 test -n "${archive}"
202 docker load -i "${archive}"
203
204 - name: Prepare the SBOM directory
205 run: |
206 set -euo pipefail
207 mkdir -p sbom-out
208
209 - name: Source SBOM, SPDX JSON
210 uses: anchore/sbom-action@66cbf4bc1f1c0d2edc94016e65bc221b6bb0ad6c # v0.24.3
211 with:
212 path: .
213 format: spdx-json
214 output-file: sbom-out/sbom-source.spdx.json
215 upload-artifact: false
216 dependency-snapshot: false
217
218 - name: Image SBOM, SPDX JSON
219 uses: anchore/sbom-action@66cbf4bc1f1c0d2edc94016e65bc221b6bb0ad6c # v0.24.3
220 with:
221 image: ratecard-api:ci
222 format: spdx-json
223 output-file: sbom-out/sbom-image.spdx.json
224 upload-artifact: false
225 dependency-snapshot: false
226
227 - name: Image SBOM, CycloneDX JSON
228 uses: anchore/sbom-action@66cbf4bc1f1c0d2edc94016e65bc221b6bb0ad6c # v0.24.3
229 with:
230 image: ratecard-api:ci
231 format: cyclonedx-json
232 output-file: sbom-out/sbom-image.cdx.json
233 upload-artifact: false
234 dependency-snapshot: false
235
236 - name: Count CycloneDX components
237 id: count
238 run: |
239 set -euo pipefail
240 components="$(python3 -c 'import json, pathlib; data = json.loads(pathlib.Path("sbom-out/sbom-image.cdx.json").read_text(encoding="utf-8")); print(len(data.get("components") or []))')"
241 printf 'components=%s\n' "${components}" >> "${GITHUB_OUTPUT}"
242
243 - name: Upload SBOMs
244 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
245 with:
246 name: sbom
247 path: sbom-out/
248 if-no-files-found: error
249
250 scan:
251 needs: build
252 runs-on: ubuntu-latest
253 timeout-minutes: 15
254 permissions:
255 actions: read
256 contents: read
257 security-events: write
258 steps:
259 - name: Download the image archive
260 uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
261 with:
262 name: image
263 path: /tmp/pf-image
264
265 - name: Locate the image archive
266 id: image
267 run: |
268 set -euo pipefail
269 archive="$(find /tmp/pf-image -name 'image.tar' -print -quit)"
270 test -n "${archive}"
271 printf 'archive=%s\n' "${archive}" >> "${GITHUB_OUTPUT}"
272
273 - name: Trivy scan
274 id: trivy
275 uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
276 with:
277 input: ${{ steps.image.outputs.archive }}
278 severity: CRITICAL,HIGH
279 ignore-unfixed: true
280 exit-code: "1"
281 format: sarif
282 output: trivy-results.sarif
283 cache: false
284
285 - name: Upload SARIF
286 if: always() && hashFiles('trivy-results.sarif') != ''
287 uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
288 with:
289 sarif_file: trivy-results.sarif
290 category: trivy
291
292 - name: Keep the scanner status
293 if: steps.trivy.outcome == 'failure'
294 run: exit 1
295
296 summary:
297 needs: [lint, test, build, sbom, scan]
298 if: always()
299 runs-on: ubuntu-latest
300 timeout-minutes: 5
301 steps:
302 - name: Write the job summary
303 env:
304 LINT_RESULT: ${{ needs.lint.result }}
305 TEST_RESULT: ${{ needs.test.result }}
306 BUILD_RESULT: ${{ needs.build.result }}
307 SBOM_RESULT: ${{ needs.sbom.result }}
308 SCAN_RESULT: ${{ needs.scan.result }}
309 IMAGE_ID: ${{ needs.build.outputs.image_id }}
310 SBOM_COMPONENTS: ${{ needs.sbom.outputs.components }}
311 run: |
312 set -euo pipefail
313 {
314 echo "## PipeForge CI summary"
315 echo
316 echo "| Job | Result |"
317 echo "| --- | --- |"
318 echo "| lint | ${LINT_RESULT} |"
319 echo "| test | ${TEST_RESULT} |"
320 echo "| build | ${BUILD_RESULT} |"
321 echo "| sbom | ${SBOM_RESULT} |"
322 echo "| scan | ${SCAN_RESULT} |"
323 echo
324 echo "Local image id: ${IMAGE_ID:-unavailable}"
325 echo
326 echo "Image CycloneDX component count: ${SBOM_COMPONENTS:-unavailable}"
327 } >> "${GITHUB_STEP_SUMMARY}"