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
15 changes: 4 additions & 11 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,4 @@
# Monthly update PRs into develop: one batch a month, merged as one PR so develop deploys once.
# The cooldown holds a release back for 7 days so a broken or hijacked version is caught upstream
# first; security updates ignore both the schedule and the cooldown.
#
# Every ignore rule below is a deliberate hold; its reason is the
# comment next to it.
# The 7-day cooldown lets a broken or hijacked release be caught upstream first.
version: 2
updates:
- package-ecosystem: "nuget"
Expand All @@ -21,7 +16,7 @@ updates:
patterns: ["*"]
exclude-patterns: ["QuestPDF"]
update-types: [minor, patch]
# Always alone: a QuestPDF bump must pass .github/scripts/questpdf-cpu-check.sh before it is merged
# Alone: a QuestPDF bump must pass .github/scripts/questpdf-cpu-check.sh before merging
questpdf:
patterns: ["QuestPDF"]
ignore:
Expand Down Expand Up @@ -79,8 +74,7 @@ updates:
- dependency-name: "@types/node"
versions: [">=23"]

# One entry for all three Dockerfiles: the SDK and runtime references are shared between
# Backend and the QuestPDF harness and must move in one PR (see "SDK image" in the register)
# One entry so the SDK/runtime references shared across Dockerfiles move in one PR
- package-ecosystem: "docker"
directories:
- "/src/backend"
Expand All @@ -95,8 +89,7 @@ updates:
commit-message:
prefix: "chore(deps)"
groups:
# Dependabot names a Docker dependency by its image path without the registry:
# dotnet/sdk and dotnet/aspnet, not mcr.microsoft.com/dotnet/...
# Dependabot names Docker images without the registry (dotnet/sdk, not mcr.microsoft.com/...)
dotnet-images:
patterns: ["dotnet/*"]
other-images:
Expand Down
73 changes: 8 additions & 65 deletions .github/scripts/check-deps.sh
Original file line number Diff line number Diff line change
@@ -1,32 +1,6 @@
#!/usr/bin/env bash
# Supply-chain gate for pkgdiff: NuGet, npm and (optionally) the built images.
#
# bash .github/scripts/check-deps.sh run from any directory
#
# TRIVY=1 also scan ${BACKEND_IMAGE:-ghcr.io/pkgdiff-dev/pkgdiff-backend:local} and
# ${FRONTEND_IMAGE:-ghcr.io/pkgdiff-dev/pkgdiff-frontend:local} with the
# pinned Trivy image (the defaults are what docker-compose.build.yml builds)
# CHECK_DEPS_DOCKER=auto auto (default): use dotnet / npm from PATH, and the pinned SDK / Node
# image for a tool that is missing; 1: always containers; 0: never
# PKGDIFF_ROOT=<dir> check another checkout (default: the checkout this script is in)
#
# Checks (every check runs, also after a failure):
# sdk-image-pin the SDK image is pinned by digest and is the same reference in
# src/backend/Dockerfile, src/backend/Directory.Build.props and the QuestPDF harness
# nuget-locked-restore dotnet restore --locked-mode with the audit gate (lock drift, NU1901-NU1904)
# nuget-vulnerable dotnet list package --vulnerable --include-transitive, read as JSON
# npm-audit-prod npm audit --omit=dev (production dependencies must be clean)
# npm-audit-high .github/scripts/npm-audit-gate.mjs with src/frontend/audit-allowlist.json
# trivy-backend TRIVY=1 only: fixable HIGH/CRITICAL in the backend image
# trivy-frontend TRIVY=1 only: fixable HIGH/CRITICAL in the frontend image
#
# Output: one "PASS <check>" or "FAIL <check>: <detail>" line per check (evidence is indented
# above it), then "check-deps: N passed, M failed". A check that could not be carried out is a
# FAIL whose detail starts with "could not check"; it is never reported as a pass.
# Exit status: 0 when every check passed, otherwise 1.
#
# The checkout is never modified: the manifests are copied to a temporary directory and the
# tools run on that copy, either on this machine or inside a container.
# Supply-chain gate: NuGet and npm audits, plus image scans with TRIVY=1. Exit 0 when all pass.
# Env: TRIVY=1, CHECK_DEPS_DOCKER=auto|1|0, PKGDIFF_ROOT=<dir>, BACKEND_IMAGE, FRONTEND_IMAGE.
set -uo pipefail

export DOTNET_CLI_UI_LANGUAGE=en DOTNET_NOLOGO=1 DOTNET_CLI_TELEMETRY_OPTOUT=1
Expand All @@ -51,8 +25,7 @@ case "$MODE" in
;;
esac

# Git Bash rewrites arguments that look like POSIX paths (/work, /var/run/docker.sock) before
# docker.exe sees them; turn that off and use the socket form Docker Desktop accepts.
# Git Bash rewrites POSIX-looking arguments before docker.exe sees them; turn that off.
DOCKER_SOCKET='/var/run/docker.sock'
case "$(uname -s 2>/dev/null)" in
MINGW* | MSYS* | CYGWIN*)
Expand Down Expand Up @@ -121,21 +94,18 @@ one_line() {
tr '\r\n' ' ' | sed 's/[[:space:]][[:space:]]*/ /g; s/^ //; s/ $//'
}

# Prints the image of the "AS build" stage of a Dockerfile.
build_stage_image() {
[ -f "$1" ] || return 1
tr -d '\r' <"$1" |
sed -n 's/^FROM[[:space:]][[:space:]]*\([^[:space:]][^[:space:]]*\)[[:space:]][[:space:]]*[Aa][Ss][[:space:]][[:space:]]*build[[:space:]]*$/\1/p' |
head -n 1
}

# A status that decides something never comes from `writer | grep -q`: under pipefail a writer
# killed by SIGPIPE makes a match read as "no match". These tests read a here-string or a file.
# No `writer | grep -q` for decisions: under pipefail a SIGPIPE'd writer turns a match into a miss.
is_pinned() {
grep -Eq '^[A-Za-z0-9][A-Za-z0-9./_-]*:[A-Za-z0-9._-]+@sha256:[0-9a-f]{64}$' <<<"$1"
}

# Decides whether a tool set runs in a container: 0 = container, 1 = this machine.
use_container() {
local tool
case "$MODE" in
Expand All @@ -148,9 +118,7 @@ use_container() {
return 1
}

# run_in_container <image> <dir> [docker create options]
# Copies <dir> to /work in a fresh container, runs /work/run.sh there and copies /work/out back.
# No bind mount is used, so nothing depends on how this machine shares its file system with Docker.
# Copies files in and out instead of bind-mounting, so host file sharing with Docker doesn't matter.
run_in_container() {
local image="$1" dir="$2" cid rc
shift 2
Expand All @@ -169,8 +137,6 @@ run_in_container() {
return "$rc"
}

# ---------------------------------------------------------------- sdk-image-pin

check_sdk_image_pin() {
local name='sdk-image-pin'
local dockerfile="$ROOT/src/backend/Dockerfile"
Expand Down Expand Up @@ -205,8 +171,6 @@ check_sdk_image_pin() {
pass "$name"
}

# ---------------------------------------------------------------- NuGet

prepare_nuget() {
local dir="$1" rel
mkdir -p "$dir" || return 1
Expand Down Expand Up @@ -240,7 +204,6 @@ echo "$?" >out/vulnerable.rc
EOF
}

# Unique "error XX0000: message" lines of a restore log, without the trailing "[project path]".
restore_errors() {
tr -d '\r' <"$1" | sed -n 's/.*\(error [A-Z][A-Z]*[0-9][0-9]*: .*\)$/\1/p' | sed 's/ \[[^][]*\]$//' | sort -u
}
Expand Down Expand Up @@ -286,11 +249,9 @@ check_nuget() {
return
fi

# nuget-locked-restore
rc="$(read_rc "$out/locked-restore.rc")"
if [ "$rc" = "0" ] && grep -Eq 'NU190[05]' "$out/locked-restore.log"; then
# With packages in the local cache, a restore still succeeds when the advisory data cannot be
# downloaded; it only warns (NU1900 / NU1905). The audit did not run, so this is not a pass.
# A cached restore succeeds with only a warning when advisory data can't load: no audit ran.
tr -d '\r' <"$out/locked-restore.log" | grep -oE 'warning NU190[05]: [^[]*' | sort -u | evidence
unchecked nuget-locked-restore "the restore succeeded, but the advisory data could not be loaded (NU1900/NU1905), so the audit did not run"
elif [ "$rc" = "0" ]; then
Expand All @@ -315,7 +276,6 @@ check_nuget() {
fi
fi

# nuget-vulnerable
rc="$(read_rc "$out/plain-restore.rc")"
if [ "$rc" != "0" ]; then
restore_errors "$out/plain-restore.log" 2>/dev/null | head -n 10 | evidence
Expand Down Expand Up @@ -343,7 +303,6 @@ check_nuget() {
return
fi
if grep -q '"vulnerabilities"' "$out/vulnerable.json"; then
# The report lists only affected packages, so every id / resolvedVersion pair in it is a finding.
findings="$(grep -oE '"(id|resolvedVersion|severity|advisoryurl)": *"[^"]*"' "$out/vulnerable.json" |
awk -F'"' '
$2 == "id" { id = $4 }
Expand All @@ -358,8 +317,6 @@ check_nuget() {
pass nuget-vulnerable
}

# ---------------------------------------------------------------- npm

prepare_npm() {
local dir="$1" file
mkdir -p "$dir/Frontend" "$dir/scripts" || return 1
Expand Down Expand Up @@ -437,7 +394,7 @@ check_npm() {
return
fi

# npm-audit-prod: npm audit --omit=dev exits non-zero on findings and on errors; the JSON tells them apart.
# npm audit exits non-zero on findings and on errors; only the JSON tells them apart.
rc="$(read_rc "$out/audit-prod.rc")"
if ! grep -q '"auditReportVersion"' "$out/audit-prod.json" 2>/dev/null; then
cat "$out/audit-prod.json" "$out/audit-prod.err" 2>/dev/null | tail -n 10 | evidence
Expand All @@ -451,13 +408,9 @@ check_npm() {
fail npm-audit-prod "production dependencies have advisories: $(printf '%s' "$packages" | one_line)"
fi

# npm-audit-high: replaces a bare `npm audit --audit-level=high`, which cannot tell a reviewed,
# time-boxed exception from a new advisory. The gate keeps its own 0 / 1 / 2 exit status visible.
rc="$(read_rc "$out/gate.rc")"
case "$rc" in
0)
# Only the entries of the "Accepted exceptions" block are reviewed exceptions. The "Not gated"
# blocks list advisories below high that nobody reviewed; they keep their own label.
tr -d '\r' <"$out/gate.log" | sed -n \
-e '1p' \
-e '/^Accepted exceptions/,/^$/s/^ - \(GHSA-.*\)$/accepted exception: \1/p' \
Expand All @@ -481,8 +434,6 @@ check_npm() {
esac
}

# ---------------------------------------------------------------- Trivy

check_trivy() {
local name="$1" image="$2" log="$WORK/$1.log" rc detail

Expand Down Expand Up @@ -513,24 +464,16 @@ check_trivy() {
unchecked "$name" "trivy exit 0, but it detected no OS packages in $image, so nothing was scanned"
fi
elif [ "$rc" -eq 1 ] && grep -Eq '^Total: [1-9][0-9]* \(' "$log"; then
# Exit 1 together with a report is the only result that means "findings".
# grep reads the file itself: behind a pipe, `grep -q` stops at the first match, the writer
# dies of SIGPIPE on a large report, and pipefail turns the match into "false". The pattern
# has no end anchor, so a trailing CR does not matter.
# The whole report: whoever fixes the image needs the package and the fixed version.
# Only exit 1 together with a report means findings.
grep -v "$(printf '\tINFO\t')" "$log" | evidence
fail "$name" "fixable HIGH/CRITICAL findings in $image: $(tr -d '\r' <"$log" | grep -E '^Total: ' | one_line)"
else
# No report: the scanner never started (docker run exits 125, 126 or 127 when the image cannot
# be pulled or run) or it stopped before the scan finished. Nothing is known about the image.
tail -n 10 "$log" | evidence
detail="$(grep -E 'FATAL|[Ee]rror' "$log" | head -n 1 | one_line)"
unchecked "$name" "trivy did not complete (exit $rc)${detail:+: $detail}"
fi
}

# ---------------------------------------------------------------- run

echo "check-deps: root $ROOT"

check_sdk_image_pin
Expand Down
23 changes: 4 additions & 19 deletions .github/scripts/questpdf-cpu-check.sh
Original file line number Diff line number Diff line change
@@ -1,15 +1,5 @@
#!/usr/bin/env bash
# QuestPDF CPU gate: renders the PDF export document inside a Linux container and reports
# whether the QuestPDF native library works on a CPU without AVX2 (the production CPU is
# an Ivy Bridge Xeon: AVX, but no AVX2, FMA or BMI2).
#
# --native render on the real CPU of this machine. Run it on the production
# server before the backend is recreated with a new QuestPDF version.
# --emulate render under QEMU with the IvyBridge CPU model, after a self-test
# proves that the emulator really traps AVX2, FMA and BMI2.
# --version <x> QuestPDF version to test (default: src/backend/Directory.Packages.props)
#
# Everything runs in containers; nothing is executed on the host itself.
# Checks in containers that QuestPDF renders on a CPU without AVX2, FMA or BMI2 (production).
# Exit status: 0 = PASS, 1 = FAIL, 2 = usage error.
set -euo pipefail

Expand Down Expand Up @@ -67,8 +57,7 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
CONTEXT="$ROOT/.github/scripts/questpdf-cpu-check"

if [ -z "$VERSION" ]; then
# sed stops at the first match itself. With `| head -n 1`, a second matching line could kill
# sed with SIGPIPE, and under `set -e -o pipefail` that ends the script without a message.
# sed quits itself: `| head -n 1` could SIGPIPE sed and silently abort under pipefail.
VERSION="$(sed -n '/<PackageVersion Include="QuestPDF" Version="/{s/.*<PackageVersion Include="QuestPDF" Version="\([^"]*\)".*/\1/p;q;}' "$ROOT/src/backend/Directory.Packages.props")"
fi

Expand Down Expand Up @@ -107,8 +96,6 @@ fi
OUT=""
STATUS=0

# Runs a command in the harness image with a hard time limit, no network and capped memory.
# The output lands in OUT and the exit status in STATUS.
capture() {
local seconds="$1"
shift
Expand Down Expand Up @@ -146,8 +133,7 @@ if [ "$MODE" = "native" ]; then
exit 0
fi

# 1. Detector self-test: a passing Ivy Bridge render only means something if the emulator
# kills a process that uses an instruction Ivy Bridge does not have.
# A pass only means something if the emulator kills instructions Ivy Bridge lacks.
for PROBE in avx2 fma bmi2; do
MARKER="$(echo "$PROBE" | tr '[:lower:]' '[:upper:]')_EXECUTED"

Expand All @@ -167,14 +153,13 @@ for PROBE in avx2 fma bmi2; do
fi
done

# 2. The emulator must be able to host .NET at all, otherwise a failure below says nothing.
# The emulator must host .NET at all, or a failure below says nothing.
capture 600 qemu-x86_64 -cpu max "$DOTNET" "$HARNESS"
show "render under -cpu max"
if [ "$STATUS" -ne 0 ] || ! has "^PDF_OK "; then
fail "the emulator could not host the render under -cpu max (exit $STATUS); this says nothing about Ivy Bridge"
fi

# 3. The actual gate: no AVX2 and a valid PDF.
capture 600 qemu-x86_64 -cpu IvyBridge "$DOTNET" "$HARNESS"
show "render under -cpu IvyBridge"
if ! has "AVX2=False"; then
Expand Down
8 changes: 2 additions & 6 deletions .github/scripts/questpdf-cpu-check/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,26 +1,22 @@
# Harness images for .github/scripts/questpdf-cpu-check.sh. They are never deployed or pushed.
# Same SDK and runtime references as src/backend/Dockerfile.
# Harness for .github/scripts/questpdf-cpu-check.sh; same SDK/runtime refs as src/backend/Dockerfile.
FROM mcr.microsoft.com/dotnet/sdk:10.0.402-noble@sha256:0d99ebf1ccad15037b5c98c9fe8979d6b8dbac23812feae2277a0b277b5c5ffa AS build
ARG QUESTPDF_VERSION
WORKDIR /src
COPY QuestPdfCpuCheck.csproj Program.cs ./
RUN test -n "${QUESTPDF_VERSION}" \
&& dotnet publish QuestPdfCpuCheck.csproj -c Release -r linux-x64 --self-contained false -o /app -p:QuestPdfVersion=${QUESTPDF_VERSION}

# Renders on the real CPU of the machine that runs the container
FROM mcr.microsoft.com/dotnet/aspnet:10.0.13-noble@sha256:d5633778c754e89a386882eddf8dbee6942447a8a4b8267c19cf1e1158d0b0ac AS native
WORKDIR /app
COPY --from=build /app .
ENTRYPOINT ["dotnet", "/app/QuestPdfCpuCheck.dll"]

# QEMU 8.2 from Ubuntu 24.04 crashes as soon as .NET reads /proc/self/maps.
# Ubuntu 26.04 ships QEMU 10.2 as a static binary, which also runs on the 24.04 runtime image.
# Older QEMU crashes when .NET reads /proc/self/maps; this static build also runs on 24.04.
FROM ubuntu:26.04@sha256:f144425ff09be612d6d9ad965196e9cdc23dae1f42110a8a11a3e9a8198759f7 AS qemu
RUN apt-get update \
&& apt-get install -y --no-install-recommends qemu-user \
&& rm -rf /var/lib/apt/lists/*

# Renders under QEMU user-mode emulation with a chosen CPU model
FROM mcr.microsoft.com/dotnet/aspnet:10.0.13-noble@sha256:d5633778c754e89a386882eddf8dbee6942447a8a4b8267c19cf1e1158d0b0ac AS emulate
RUN apt-get update \
&& apt-get install -y --no-install-recommends gcc libc6-dev \
Expand Down
2 changes: 1 addition & 1 deletion .github/scripts/questpdf-cpu-check/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

QuestPDF.Settings.License = LicenseType.Community;

// The missing-glyph switch was renamed in 2026.9; reflection keeps one source for every version
// The missing-glyph switch was renamed between versions; reflection handles both names
var glyphSetting = new[] { "ThrowOnMissingTextGlyphs", "CheckIfAllTextGlyphsAreAvailable" }
.Select(name => typeof(QuestPDF.Settings).GetProperty(name, BindingFlags.Public | BindingFlags.Static))
.FirstOrDefault(property => property is { CanWrite: true });
Expand Down
7 changes: 2 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
# Runs on every pull request into develop or master, and as the gate of the publish workflow.
# No toolchain is installed on the runner: the tests run inside the SDK and Node images pinned in
# src/backend/Dockerfile and src/frontend/Dockerfile, so CI uses exactly what the published images use.
# PR gate and publish gate; runs inside the SDK and Node images the Dockerfiles pin.
name: CI

on:
Expand All @@ -25,8 +23,7 @@ jobs:
with:
persist-credentials: false

# The database tests run against the PostgreSQL image docker-compose.yml pins; without a server
# they would be skipped. The password belongs to this throwaway container only.
# Without a database server the database tests would be skipped
- name: Run the tests in the pinned SDK image, against the pinned PostgreSQL
run: |
sdk="$(sed -n 's/^FROM \(mcr\.microsoft\.com\/dotnet\/sdk:[^ ]*\) AS build.*/\1/p' src/backend/Dockerfile)"
Expand Down
13 changes: 2 additions & 11 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,4 @@
# Runs deploy/remote.sh on the server over SSH.
# - Automatically: publish.yml calls it after the images of a push to develop are published,
# while the repository variable PKGDIFF_AUTO_DEPLOY is "true".
# - By hand: Actions > Deploy > Run workflow (deploy a tag, roll back, or show the status).
# It does nothing until these repository secrets exist (docs/ops/deploy-ghcr.md):
# DEPLOY_HOST, DEPLOY_USER, DEPLOY_SSH_KEY, DEPLOY_KNOWN_HOSTS; optional DEPLOY_PORT, DEPLOY_PATH
# Runs deploy/remote.sh on the server over SSH; called by publish.yml or run by hand.
name: Deploy

on:
Expand Down Expand Up @@ -39,7 +34,6 @@ jobs:
environment: production
runs-on: ubuntu-24.04
timeout-minutes: 20
# One deploy at a time, in the order they were started; a running one is never cancelled
concurrency:
group: deploy-production
cancel-in-progress: false
Expand Down Expand Up @@ -87,10 +81,7 @@ jobs:
request="$ACTION"
if [ "$ACTION" = deploy ]; then request="deploy $TAG $REVISION"; fi

# Every word of the request was checked above. A key installed with remote.sh as its forced
# command ignores the script path given here and reads only the request.
# The log of this job may be public: it shows the "deploy:" lines of the server only, the full
# output (paths, container state) stays in deploy.log on the runner and on the server
# The job log may be public: show only the server's "deploy:" lines
status=0
ssh -i ~/.ssh/deploy_key -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes \
-o ServerAliveInterval=30 -o ServerAliveCountMax=10 \
Expand Down
Loading