PipeForge

ci.yml

Snapshot 2263a414fe628107f433d708f83dd82cc29e55a6 from main.

GitHub blob for ci.yml

  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}"