Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
version: 2
updates:
- package-ecosystem: "npm"

Check warning on line 3 in .github/dependabot.yml

View workflow job for this annotation

GitHub Actions / zizmor (advisory)

dependabot-cooldown

dependabot.yml:3: insufficient cooldown in Dependabot updates: insufficient implicit default-days (less than 7)
directory: "/"
schedule:
interval: "weekly"
- package-ecosystem: "composer"

Check warning on line 7 in .github/dependabot.yml

View workflow job for this annotation

GitHub Actions / zizmor (advisory)

dependabot-cooldown

dependabot.yml:7: insufficient cooldown in Dependabot updates: insufficient implicit default-days (less than 7)
directory: "/"
schedule:
interval: "weekly"
- package-ecosystem: "docker"
# Keeps the SHA-pinned actions in .github/workflows up to date.
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
cooldown:
default-days: 7
152 changes: 152 additions & 0 deletions .github/scripts/coverage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
#!/usr/bin/env python3
"""Self-contained line coverage reporting for the Tests workflow (advisory).

Subcommands:
summarize COBERTURA [--json FILE] [--sha SHA] [--title TITLE]
Read total line coverage from a PHPUnit Cobertura report, write it to
$GITHUB_OUTPUT (lines_valid, lines_covered, percent) and the job
summary, and optionally to a baseline JSON file.
read-baseline JSON --sha SHA
Validate a baseline JSON file recorded by `summarize --json` for
commit SHA and write found=true and its counts to $GITHUB_OUTPUT.
compare
Print "main <sha7>: X% -> this PR: Y% (+Z pp)" into the job summary,
or "no baseline: <reason>". Inputs come from the environment (see
compare()). Never fails.

Percentages are computed from line counts and rounded to 2 decimals.
"""

import argparse
import json
import os
import re
import sys
import xml.etree.ElementTree as ET

SHA_RE = re.compile(r'[0-9a-f]{40}')


def percent(covered, valid):
return round(100 * covered / valid, 2) if valid else 0.0


def counts(valid, covered):
"""Validate line counts given as strings or numbers."""
valid, covered = int(valid), int(covered)
if valid < 0 or covered < 0 or covered > valid:
raise ValueError(f'invalid line counts {covered}/{valid}')
return valid, covered


def append(env_name, text):
path = os.environ.get(env_name)
if path:
with open(path, 'a', encoding='utf-8') as f:
f.write(text)


def set_outputs(**values):
append('GITHUB_OUTPUT', ''.join(f'{k}={v}\n' for k, v in values.items()))


def summarize(args):
root = ET.parse(args.cobertura).getroot()
valid, covered = counts(root.get('lines-valid'), root.get('lines-covered'))
pct = percent(covered, valid)
set_outputs(lines_valid=valid, lines_covered=covered, percent=f'{pct:.2f}')
text = f'Lines: {pct:.2f}% ({covered}/{valid})'
print(text)
append('GITHUB_STEP_SUMMARY', f'### {args.title}\n\n{text}\n\n')
if args.json:
if not SHA_RE.fullmatch(args.sha or ''):
raise SystemExit('--json requires --sha with a full commit SHA')
with open(args.json, 'w', encoding='utf-8') as f:
json.dump({
'sha': args.sha,
'lines_valid': valid,
'lines_covered': covered,
'percent': pct,
}, f)
f.write('\n')


def read_baseline(args):
with open(args.json, encoding='utf-8') as f:
data = json.load(f)
if data.get('sha') != args.sha:
raise SystemExit(f'baseline is for {data.get("sha")!r}, expected {args.sha}')
valid, covered = counts(data['lines_valid'], data['lines_covered'])
set_outputs(found='true', lines_valid=valid, lines_covered=covered)
print(f'Baseline for {args.sha}: {percent(covered, valid):.2f}% ({covered}/{valid})')


def compare(_args):
"""Inputs (environment):
BASE_SHA PR base commit
PR_VALID, PR_COVERED line counts of the PR (PHPUnit job)
FOUND, FOUND_VALID, FOUND_COVERED
baseline artifact from main for BASE_SHA
(FOUND is "true" only if it was valid)
COMPUTED_VALID, COMPUTED_COVERED, COMPUTED_REASON
baseline computed from the base commit
"""
env = os.environ.get
base_sha = env('BASE_SHA', '')
lines = ['### Coverage vs main', '']
try:
try:
pr_valid, pr_covered = counts(env('PR_VALID'), env('PR_COVERED'))
except (TypeError, ValueError):
raise LookupError('no coverage for this PR (PHPUnit job failed?)')
try:
if env('FOUND') == 'true':
source = 'artifact from main'
base_valid, base_covered = counts(env('FOUND_VALID'), env('FOUND_COVERED'))
else:
source = 'computed from the base commit'
base_valid, base_covered = counts(env('COMPUTED_VALID'), env('COMPUTED_COVERED'))
except (TypeError, ValueError):
raise LookupError(f'no baseline: {env("COMPUTED_REASON") or "baseline job did not run"}')
base_pct = percent(base_covered, base_valid)
pr_pct = percent(pr_covered, pr_valid)
lines.append(
f'main {base_sha[:7]}: {base_pct:.2f}% → this PR: {pr_pct:.2f}% '
f'({pr_pct - base_pct:+.2f} pp)'
)
lines.append('')
lines.append(f'Lines: {base_covered}/{base_valid} → {pr_covered}/{pr_valid}; baseline {source}.')
except LookupError as e:
lines.append(str(e))
except Exception as e: # advisory: never fail the job
lines.append(f'no baseline: unexpected error ({type(e).__name__})')
text = '\n'.join(lines) + '\n'
print(text)
append('GITHUB_STEP_SUMMARY', text + '\n')


def main():
parser = argparse.ArgumentParser(description=__doc__.split('\n')[0])
sub = parser.add_subparsers(dest='command', required=True)

p = sub.add_parser('summarize')
p.add_argument('cobertura')
p.add_argument('--json')
p.add_argument('--sha')
p.add_argument('--title', default='PHPUnit coverage')
p.set_defaults(func=summarize)

p = sub.add_parser('read-baseline')
p.add_argument('json')
p.add_argument('--sha', required=True)
p.set_defaults(func=read_baseline)

p = sub.add_parser('compare')
p.set_defaults(func=compare)

args = parser.parse_args()
args.func(args)


if __name__ == '__main__':
sys.exit(main())
95 changes: 95 additions & 0 deletions .github/workflows/container.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
name: Container

# Advisory: builds the application image the same way release.yml does
# (same Dockerfile, buildah, linux/amd64, OCI) without pushing, then scans it
# with Trivy. Only the application layer is verified: the build uses the
# published builder-latest / runtime-latest base images (the Dockerfile
# defaults), so changes to build/base/** and build/packages/** are not
# covered here. Until release.yml has published the Wolfi base images and
# moved builder-latest / runtime-latest to them, those tags still point at the
# legacy Alpine/nginx images and this build is expected to fail.

on:
pull_request:
push:
branches:
- main

permissions: {}

concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
build:
name: Container build
runs-on: ubuntu-24.04
timeout-minutes: 60
permissions:
contents: read
env:
# See the Trivy note in pr-checks.yml: official image pinned by digest
# instead of the (once tag-hijacked) trivy-action.
TRIVY_IMAGE: aquasec/trivy@sha256:790fd66d1697395d8693d1acaa1ea702406a20f109b6fc7bac8db623eca6c06c # 0.76.1
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

# A failing build is real breakage and fails the job.
- name: Build Cattr server
id: build
uses: redhat-actions/buildah-build@719e3c40d8af9790c23eca13f7daa339f2867034 # v3.1.0
with:
context: .
containerfiles: |
./Dockerfile
image: server
tags: ci
# Same as release.yml: Cattr images are linux/amd64 only, OCI format.
arch: amd64
oci: true
build-args: |
APP_VERSION=0.0.0-ci
layers: true

- name: Export image
env:
IMAGE: ${{ steps.build.outputs.image-with-tag }}
run: |
set -euo pipefail
buildah push "${IMAGE}" "oci:${RUNNER_TEMP}/image-oci"

# The scan is advisory: it never fails the job. Findings (HIGH/CRITICAL
# with an available fix) are written to the job summary.
- name: Scan image with Trivy
continue-on-error: true
run: |
set -euo pipefail

docker run --rm -v "${RUNNER_TEMP}:/scan" "${TRIVY_IMAGE}" \
image \
--input /scan/image-oci \
--scanners vuln \
--severity HIGH,CRITICAL \
--ignore-unfixed \
--format json \
--output /scan/trivy.json \
--exit-code 0 \
--no-progress

docker run --rm -v "${RUNNER_TEMP}:/scan" "${TRIVY_IMAGE}" \
convert --scanners vuln --format table --output /scan/trivy.txt /scan/trivy.json

cat "${RUNNER_TEMP}/trivy.txt"

{
echo '## Trivy image scan (advisory)'
echo
echo 'HIGH/CRITICAL vulnerabilities with an available fix:'
echo
echo '```'
cat "${RUNNER_TEMP}/trivy.txt"
echo '```'
} >> "${GITHUB_STEP_SUMMARY}"
83 changes: 83 additions & 0 deletions .github/workflows/image-scan.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
name: Image scan

# Scheduled Trivy scan of the published images. Wolfi packages in the base
# images receive CVEs between releases; new alerts here signal that a
# base-image rebuild (IMAGE_BUILD_REVISION / build/base/rebuild) is due.

on:
schedule:
- cron: '27 5 * * 1'
workflow_dispatch:

permissions: {}

concurrency:
group: ${{ github.workflow }}
cancel-in-progress: false

jobs:
scan:
name: Scan ${{ matrix.name }}
runs-on: ubuntu-24.04
timeout-minutes: 30
permissions:
contents: read
security-events: write # Upload SARIF to GitHub code scanning.
strategy:
fail-fast: false
matrix:
include:
# Latest stable application release (release.yml tags it `latest`).
- name: server
image: ghcr.io/cattr-app/server:latest
- name: server-runtime
image: ghcr.io/cattr-app/server-runtime:runtime-latest
env:
# See the Trivy note in pr-checks.yml: official image pinned by digest
# instead of the (once tag-hijacked) trivy-action.
TRIVY_IMAGE: aquasec/trivy@sha256:790fd66d1697395d8693d1acaa1ea702406a20f109b6fc7bac8db623eca6c06c # 0.76.1
steps:
# upload-sarif resolves the commit and ref from the checkout.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Scan image with Trivy
env:
IMAGE: ${{ matrix.image }}
run: |
set -euo pipefail

docker run --rm -v "${RUNNER_TEMP}:/scan" "${TRIVY_IMAGE}" \
image \
--image-src remote \
--scanners vuln \
--severity HIGH,CRITICAL \
--ignore-unfixed \
--format json \
--output /scan/trivy.json \
--exit-code 0 \
--no-progress \
"${IMAGE}"

docker run --rm -v "${RUNNER_TEMP}:/scan" "${TRIVY_IMAGE}" \
convert --format sarif --output /scan/trivy.sarif /scan/trivy.json

docker run --rm -v "${RUNNER_TEMP}:/scan" "${TRIVY_IMAGE}" \
convert --scanners vuln --format table --output /scan/trivy.txt /scan/trivy.json

{
echo "## Trivy scan of \`${IMAGE}\`"
echo
echo 'HIGH/CRITICAL vulnerabilities with an available fix:'
echo
echo '```'
cat "${RUNNER_TEMP}/trivy.txt"
echo '```'
} >> "${GITHUB_STEP_SUMMARY}"

- name: Upload SARIF
uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
with:
sarif_file: ${{ runner.temp }}/trivy.sarif
category: trivy-image-${{ matrix.name }}
10 changes: 5 additions & 5 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,12 @@ jobs:
pages: read
steps:
- name: Checkout repository
uses: actions/checkout@v7.0.1
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Set up Node.js
uses: actions/setup-node@v7
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version-file: .nvmrc

Expand All @@ -40,13 +40,13 @@ jobs:

- name: Configure GitHub Pages
id: pages
uses: actions/configure-pages@v5
uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5.0.0

- name: Build API and developer documentation
run: pnpm build:pages

- name: Upload Pages artifact
uses: actions/upload-pages-artifact@v4
uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4.0.0
with:
path: pages-dist

Expand All @@ -64,4 +64,4 @@ jobs:
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5
Loading
Loading