Files
craft-common-actions/.github/workflows/sonarcloud.yml
T
FredApps 3951f6df01 sonarcloud: merge each shard's profiles before the scan downloads them (#210)
Each coverage shard now merges its own raw profiles into one sparse
.profdata with the archive toolchain's llvm-profdata and uploads only
that. The scan job merges the shard profiles instead of downloading
every raw profile beside the test archive, which on 11 Nov 2026 filled
the runner for vectorcraft (four ~7 GB zipped profraw artifacts).

A raw profile llvm-profdata cannot read, such as one cut short by a
test process that crashed while writing it (lightcraft, 11 Nov 2026),
now fails its shard with an error naming the file, instead of being
uploaded and failing the scan's merge.

Single-shard callers keep the cargo llvm-cov path unchanged.
2026-11-11 19:55:14 -05:00

745 lines
34 KiB
YAML

name: SonarCloud
on:
workflow_call:
inputs:
project-key:
description: "SonarCloud project key (defaults to storytold_<repo>)"
type: string
required: false
default: ""
fonts-ref:
description: "craft-fonts commit for tests that lay out text (empty skips the checkout)"
type: string
required: false
default: ""
pdf-test-fonts:
description: "Provision the caller's pinned PDF test fonts with scripts/fetch-pdf-test-fonts.py"
type: boolean
required: false
default: false
python-coverage-source:
description: "Optional Python source directory to measure; requires python-coverage-script"
type: string
required: false
default: ""
python-coverage-script:
description: "Optional Python test script; requires python-coverage-source"
type: string
required: false
default: ""
sqlx-offline:
description: >-
Build with SQLX_OFFLINE=true, so sqlx's query macros read the committed .sqlx metadata
instead of connecting to the DATABASE_URL a repository's .env may name.
type: boolean
required: false
default: false
cargo-features:
description: >-
Extra cargo features for the coverage build, e.g. some-crate/skip_database_tests
(comma separated; empty adds none).
type: string
required: false
default: ""
node-coverage-script:
description: >-
Optional npm script that runs the JavaScript/TypeScript tests with coverage (e.g. test:coverage),
after `npm ci --ignore-scripts`. Point sonar.javascript.lcov.reportPaths at its lcov report in
sonar-project.properties.
type: string
required: false
default: ""
node-version:
description: "Node.js version for node-coverage-script"
type: string
required: false
default: "22"
timeout-minutes:
description: "Maximum job runtime, including coverage generation and the scan"
type: number
required: false
default: 90
shards:
description: >-
Run the Rust tests as this many parallel partitions of one prebuilt instrumented
archive (1 keeps the single `cargo llvm-cov` job; default 4). Doctests are not run when sharded.
type: number
required: false
default: 4
secrets:
SONAR_TOKEN:
description: "Legacy or repository-level SonarCloud analysis token"
required: false
SONAR_CLOUD_PAT:
description: "Organization-level SonarCloud personal access token"
required: false
permissions:
contents: read
env:
# Vendored code and xtask, plus the test code cargo llvm-cov leaves out on its own (tests/,
# examples/, benches/, tests.rs, *_tests.rs): the sharded merge calls llvm-cov export directly,
# which would otherwise report that test code as covered source.
COVERAGE_IGNORE: '(^|/)(vendor|xtask|tests|examples|benches)/|/(tests\.rs|[0-9a-zA-Z_-]+[_-]tests\.rs)$'
jobs:
plan:
name: Plan coverage shards
if: inputs.shards > 1
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
matrix: ${{ steps.matrix.outputs.matrix }}
steps:
- name: Harden runner
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
with:
egress-policy: block
# Ubuntu Pro APT hooks use esm.ubuntu.com for security-update metadata.
# Keep this explicit alongside the archive hosts instead of allowing all Ubuntu subdomains.
allowed-endpoints: >
*.amazonaws.com:443
*.crates.io:443
*.s3.amazonaws.com:443
*.s3.eu-central-1.amazonaws.com:443
*.github.com:443
*.githubusercontent.com:443
*.microsoft.com:443
*.openpgp.org:443
*.pythonhosted.org:443
*.sonarcloud.io:443
*.sonarsource.com:443
api.github.com:443
archive.ubuntu.com:80
archive.ubuntu.com:443
azure.archive.ubuntu.com:80
azure.archive.ubuntu.com:443
binaries.sonarsource.com:443
crates.io:443
esm.ubuntu.com:443
files.pythonhosted.org:443
github.com:443
index.crates.io:443
keys.openpgp.org:443
keyserver.ubuntu.com:443
*.dotsrc.org:443
*.kernel.org:443
mirrors.dotsrc.org:443
mirrors.edge.kernel.org:443
objects.githubusercontent.com:443
packages.microsoft.com:443
pypi.org:443
release-assets.githubusercontent.com:443
scanner.sonarcloud.io:443
security.ubuntu.com:80
security.ubuntu.com:443
sonarcloud.io:443
static.crates.io:443
static.rust-lang.org:443
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Build shard matrix
id: matrix
env:
SONAR_TOKEN: ${{ secrets.SONAR_CLOUD_PAT || secrets.SONAR_TOKEN }}
SHARDS: ${{ inputs.shards }}
run: |
# The matrix caps at 256 jobs, and nextest partitions need whole numbers.
case "$SHARDS" in
''|*[!0-9]*) echo "::error::shards must be a whole number, got '$SHARDS'"; exit 1 ;;
esac
if [ "$SHARDS" -gt 256 ]; then
echo "::error::shards must be at most 256 (the GitHub Actions matrix limit), got $SHARDS"
exit 1
fi
# Same gate as the scan job: no token or no Rust workspace means no coverage to shard.
if [ -n "$SONAR_TOKEN" ] && [ -f Cargo.toml ]; then
echo "matrix={\"shard\":[$(seq -s, 1 "$SHARDS")]}" >>"$GITHUB_OUTPUT"
else
echo 'matrix={"shard":[]}' >>"$GITHUB_OUTPUT"
fi
build:
name: Build instrumented test archive
needs: plan
if: needs.plan.outputs.matrix != '{"shard":[]}'
runs-on: ubuntu-latest
timeout-minutes: ${{ inputs.timeout-minutes }}
steps:
- name: Harden runner
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
with:
egress-policy: block
# Ubuntu Pro APT hooks use esm.ubuntu.com for security-update metadata.
# Keep this explicit alongside the archive hosts instead of allowing all Ubuntu subdomains.
allowed-endpoints: >
*.amazonaws.com:443
*.crates.io:443
*.s3.amazonaws.com:443
*.s3.eu-central-1.amazonaws.com:443
*.github.com:443
*.githubusercontent.com:443
*.microsoft.com:443
*.openpgp.org:443
*.pythonhosted.org:443
*.sonarcloud.io:443
*.sonarsource.com:443
api.github.com:443
archive.ubuntu.com:80
archive.ubuntu.com:443
azure.archive.ubuntu.com:80
azure.archive.ubuntu.com:443
binaries.sonarsource.com:443
crates.io:443
esm.ubuntu.com:443
files.pythonhosted.org:443
github.com:443
index.crates.io:443
keys.openpgp.org:443
keyserver.ubuntu.com:443
*.dotsrc.org:443
*.kernel.org:443
mirrors.dotsrc.org:443
mirrors.edge.kernel.org:443
objects.githubusercontent.com:443
packages.microsoft.com:443
pypi.org:443
release-assets.githubusercontent.com:443
scanner.sonarcloud.io:443
security.ubuntu.com:80
security.ubuntu.com:443
sonarcloud.io:443
static.crates.io:443
static.rust-lang.org:443
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Check out craft-fonts
uses: storytold/craft-common-actions/actions/checkout-craft-fonts@main
with:
ref: ${{ inputs.fonts-ref }}
- name: Require requested craft-fonts
if: inputs.fonts-ref != ''
shell: bash
env:
CRAFT_FONTS_PATH: ${{ github.workspace }}/craft-fonts
run: |
echo "CRAFT_FONTS_DIR=$CRAFT_FONTS_PATH" >> "$GITHUB_ENV"
echo "CRAFT_FONTS_REQUIRED=1" >> "$GITHUB_ENV"
- name: Free disk space
run: sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL
- name: Install Linux build dependencies
uses: storytold/craft-common-actions/actions/install-apt-packages@main
with:
packages: >-
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev libxrandr-dev
libxi-dev libgtk-3-dev libfontconfig1-dev libfreetype-dev libasound2-dev libsoup-3.0-dev libwebkit2gtk-4.1-dev
libgl1-mesa-dev mesa-vulkan-drivers
- name: Install Rust and cargo-llvm-cov
run: |
rustup toolchain install stable --profile minimal --component llvm-tools-preview
rustup default stable
- uses: step-security/taiki-e-install-action@76747d22a7567cc4438546a84f11bdf18e670ab8 # v2.87.32
with:
tool: cargo-llvm-cov,cargo-nextest@0.9.150
- uses: step-security/rust-cache@8871c8ba063416da7c32246932ea6ae08700453e # v2.9.2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Provision pinned PDF test fonts
if: inputs.pdf-test-fonts
shell: bash
run: |
python3 scripts/fetch-pdf-test-fonts.py "$RUNNER_TEMP/craft-pdf-test-fonts"
echo "CRAFT_PDF_TEST_FONT_DIR=$RUNNER_TEMP/craft-pdf-test-fonts" >> "$GITHUB_ENV"
- name: Build instrumented test archive
env:
CARGO_TERM_COLOR: always
# Coverage comes from instrumentation, not DWARF; debuginfo is most of target/.
CARGO_PROFILE_DEV_DEBUG: "0"
SQLX_OFFLINE_INPUT: ${{ inputs.sqlx-offline }}
CARGO_FEATURES: ${{ inputs.cargo-features }}
run: |
# Opt-in build settings; validated because they reach the cargo command line.
if [[ ! "$CARGO_FEATURES" =~ ^[A-Za-z0-9_/,-]*$ ]]; then
echo "::error::cargo-features may only hold feature names, '/', '-', '_' and ',', got '$CARGO_FEATURES'"
exit 1
fi
if [ "$SQLX_OFFLINE_INPUT" = true ]; then export SQLX_OFFLINE=true; fi
features=()
if [ -n "$CARGO_FEATURES" ]; then features=(--features "$CARGO_FEATURES"); fi
# show-env sets the instrumentation flags the same way the unsharded run gets them.
# shellcheck disable=SC1090 # generated by cargo-llvm-cov at runtime
source <(cargo llvm-cov show-env --sh)
cargo nextest archive --workspace "${features[@]}" --archive-file nextest-archive.tar.zst
- name: Record the toolchain that built the archive
run: |
rustc -vV | sed -n 's/^release: //p' > rustc-release.txt
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: nextest-archive
path: |
nextest-archive.tar.zst
rustc-release.txt
retention-days: 1
coverage:
name: Tests ${{ matrix.shard }}/${{ inputs.shards }}
needs: [plan, build]
if: needs.plan.outputs.matrix != '{"shard":[]}'
runs-on: ubuntu-latest
timeout-minutes: ${{ inputs.timeout-minutes }}
strategy:
fail-fast: false
matrix: ${{ fromJSON(needs.plan.outputs.matrix) }}
steps:
- name: Harden runner
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
with:
egress-policy: block
# Ubuntu Pro APT hooks use esm.ubuntu.com for security-update metadata.
# Keep this explicit alongside the archive hosts instead of allowing all Ubuntu subdomains.
allowed-endpoints: >
*.amazonaws.com:443
*.crates.io:443
*.s3.amazonaws.com:443
*.s3.eu-central-1.amazonaws.com:443
*.github.com:443
*.githubusercontent.com:443
*.microsoft.com:443
*.openpgp.org:443
*.pythonhosted.org:443
*.sonarcloud.io:443
*.sonarsource.com:443
api.github.com:443
archive.ubuntu.com:80
archive.ubuntu.com:443
azure.archive.ubuntu.com:80
azure.archive.ubuntu.com:443
binaries.sonarsource.com:443
crates.io:443
esm.ubuntu.com:443
files.pythonhosted.org:443
github.com:443
index.crates.io:443
keys.openpgp.org:443
keyserver.ubuntu.com:443
*.dotsrc.org:443
*.kernel.org:443
mirrors.dotsrc.org:443
mirrors.edge.kernel.org:443
objects.githubusercontent.com:443
packages.microsoft.com:443
pypi.org:443
release-assets.githubusercontent.com:443
scanner.sonarcloud.io:443
security.ubuntu.com:80
security.ubuntu.com:443
sonarcloud.io:443
static.crates.io:443
static.rust-lang.org:443
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Check out craft-fonts
uses: storytold/craft-common-actions/actions/checkout-craft-fonts@main
with:
ref: ${{ inputs.fonts-ref }}
- name: Require requested craft-fonts
if: inputs.fonts-ref != ''
shell: bash
env:
CRAFT_FONTS_PATH: ${{ github.workspace }}/craft-fonts
run: |
echo "CRAFT_FONTS_DIR=$CRAFT_FONTS_PATH" >> "$GITHUB_ENV"
echo "CRAFT_FONTS_REQUIRED=1" >> "$GITHUB_ENV"
- name: Install Linux build dependencies
uses: storytold/craft-common-actions/actions/install-apt-packages@main
with:
packages: >-
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev libxrandr-dev
libxi-dev libgtk-3-dev libfontconfig1-dev libfreetype-dev libasound2-dev libsoup-3.0-dev libwebkit2gtk-4.1-dev
libgl1-mesa-dev mesa-vulkan-drivers
- uses: step-security/taiki-e-install-action@76747d22a7567cc4438546a84f11bdf18e670ab8 # v2.87.32
with:
tool: cargo-nextest@0.9.150
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: nextest-archive
path: archive
- name: Provision pinned PDF test fonts
if: inputs.pdf-test-fonts
shell: bash
run: |
python3 scripts/fetch-pdf-test-fonts.py "$RUNNER_TEMP/craft-pdf-test-fonts"
echo "CRAFT_PDF_TEST_FONT_DIR=$RUNNER_TEMP/craft-pdf-test-fonts" >> "$GITHUB_ENV"
- name: Run this shard's tests
env:
# Each instrumented binary writes its profile here; the scan job merges them.
LLVM_PROFILE_FILE: ${{ github.workspace }}/profraw/shard-${{ matrix.shard }}-%p-%m.profraw
SHARD: ${{ matrix.shard }}
TOTAL_SHARDS: ${{ inputs.shards }}
run: |
mkdir -p profraw
# nextest exits 100 when tests fail. A red test must not hide the static analysis, so
# that alone keeps the job green. Any other exit means the shard could not run its
# tests at all (for example a test binary nextest cannot list): fail the job, because a
# shard that runs nothing would scan an empty coverage report.
rc=0
cargo nextest run --archive-file archive/nextest-archive.tar.zst \
--partition "count:$SHARD/$TOTAL_SHARDS" --no-fail-fast || rc=$?
if [ "$rc" -ne 0 ] && [ "$rc" -ne 100 ]; then
echo "::error::nextest could not run shard $SHARD (exit $rc)"
exit "$rc"
fi
if ! ls profraw/*.profraw >/dev/null 2>&1; then
echo "::error::shard $SHARD produced no coverage profiles"
exit 1
fi
- name: Install LLVM tools for the archive's toolchain
run: rustup toolchain install "$(cat archive/rustc-release.txt)" --profile minimal --component llvm-tools-preview
- name: Merge this shard's profiles
# Raw profiles of a large workspace run to tens of gigabytes per shard; the scan job cannot
# hold every shard's at once. One sparse indexed profile per shard is a small fraction of that.
shell: bash
env:
SHARD: ${{ matrix.shard }}
run: |
set -euo pipefail
toolchain="$(cat archive/rustc-release.txt)"
llvm_bin="$(rustup run "$toolchain" rustc --print sysroot)/lib/rustlib/$(rustup run "$toolchain" rustc -vV | sed -n 's/^host: //p')/bin"
echo "Raw profiles: $(du -sh profraw | cut -f1) in $(find profraw -name '*.profraw' | wc -l) files"
df -h .
mkdir -p profdata
# failure-mode=any stops on any unreadable input. A profile cut short (a test process that
# crashed while writing it, or a full disk) fails this shard and names the file, instead of
# being uploaded and failing the scan's merge later with no shard to look at.
status=0
"$llvm_bin/llvm-profdata" merge -sparse --failure-mode=any profraw/*.profraw \
-o "profdata/shard-$SHARD.profdata" 2>merge-errors.txt || status=$?
if [ "$status" -ne 0 ]; then
cat merge-errors.txt >&2
sed -n 's/^warning: \(.*\.profraw\): \(.*\)$/\1: \2/p' merge-errors.txt | while IFS= read -r bad; do
echo "::error::shard $SHARD wrote a truncated or corrupt coverage profile: $bad"
done
echo "::error::llvm-profdata could not merge shard $SHARD's profiles (exit $status)"
rm -rf profdata
exit "$status"
fi
rm -rf profraw merge-errors.txt
echo "Merged profile: $(du -sh "profdata/shard-$SHARD.profdata" | cut -f1)"
df -h .
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: profdata-${{ matrix.shard }}
path: profdata/
if-no-files-found: error
retention-days: 1
sonarcloud:
name: SonarCloud Scan
needs: [plan, build, coverage]
# Scan unless a prerequisite failed outright; a red test shard still lets the scan measure what ran.
if: ${{ !cancelled() && contains(fromJSON('["success","skipped"]'), needs.plan.result) && contains(fromJSON('["success","skipped"]'), needs.build.result) && contains(fromJSON('["success","skipped"]'), needs.coverage.result) }}
runs-on: ubuntu-latest
timeout-minutes: ${{ inputs.timeout-minutes }}
steps:
- name: Harden runner
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
with:
egress-policy: block
# Ubuntu Pro APT hooks use esm.ubuntu.com for security-update metadata.
# Keep this explicit alongside the archive hosts instead of allowing all Ubuntu subdomains.
allowed-endpoints: >
*.amazonaws.com:443
*.crates.io:443
*.s3.amazonaws.com:443
*.s3.eu-central-1.amazonaws.com:443
*.github.com:443
*.githubusercontent.com:443
*.microsoft.com:443
*.openpgp.org:443
*.pythonhosted.org:443
*.sonarcloud.io:443
*.sonarsource.com:443
api.github.com:443
archive.ubuntu.com:80
archive.ubuntu.com:443
azure.archive.ubuntu.com:80
azure.archive.ubuntu.com:443
binaries.sonarsource.com:443
crates.io:443
esm.ubuntu.com:443
files.pythonhosted.org:443
github.com:443
index.crates.io:443
keys.openpgp.org:443
keyserver.ubuntu.com:443
*.dotsrc.org:443
*.kernel.org:443
mirrors.dotsrc.org:443
mirrors.edge.kernel.org:443
objects.githubusercontent.com:443
packages.microsoft.com:443
pypi.org:443
registry.npmjs.org:443
release-assets.githubusercontent.com:443
scanner.sonarcloud.io:443
security.ubuntu.com:80
security.ubuntu.com:443
sonarcloud.io:443
static.crates.io:443
static.rust-lang.org:443
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
- name: Check for SonarCloud token
id: token
env:
SONAR_TOKEN: ${{ secrets.SONAR_CLOUD_PAT || secrets.SONAR_TOKEN }}
run: |
if [ -n "$SONAR_TOKEN" ]; then
echo "available=true" >>"$GITHUB_OUTPUT"
else
echo "available=false" >>"$GITHUB_OUTPUT"
echo "::notice::SONAR_TOKEN is not available (e.g. a fork pull request); skipping SonarCloud scan."
fi
- name: Validate SonarCloud project key
id: project
if: steps.token.outputs.available == 'true'
shell: bash
env:
PROJECT_KEY: ${{ inputs.project-key != '' && inputs.project-key || format('storytold_{0}', github.event.repository.name) }}
LC_ALL: C
run: |
# The scan action splits args; reject characters that could introduce properties.
if [[ ! "$PROJECT_KEY" =~ ^[a-zA-Z0-9_][a-zA-Z0-9_.:-]*$ || ${#PROJECT_KEY} -gt 400 ]]; then
echo "::error::Invalid SonarCloud project key: must start with an alphanumeric character or underscore and contain only letters, digits, dash, underscore, period or colon (max 400 characters)."
exit 1
fi
# A -D argument replaces the same property in sonar-project.properties, so the repository's
# own duplication exclusions are appended to the shared one here.
cpd='**/i18n/**'
own=""
if [[ -f sonar-project.properties ]]; then
# Properties syntax: optional blanks around `=` or `:`, and a CRLF file leaves a trailing \r.
own=$(sed -n 's/^[ \t]*sonar\.cpd\.exclusions[ \t]*[=:][ \t]*//p' sonar-project.properties | tail -n 1 | sed 's/[ \t\r]*$//')
fi
if [[ -n "$own" ]]; then
if [[ ! "$own" =~ ^[a-zA-Z0-9_.*?/,-]+$ ]]; then
echo "::error::Invalid sonar.cpd.exclusions in sonar-project.properties: only letters, digits, and _ . * ? / , - are allowed."
exit 1
fi
cpd="$cpd,$own"
fi
printf 'key=%s\ncpd=%s\n' "$PROJECT_KEY" "$cpd" >>"$GITHUB_OUTPUT"
- name: Check out craft-fonts
if: steps.token.outputs.available == 'true' && inputs.shards <= 1
uses: storytold/craft-common-actions/actions/checkout-craft-fonts@main
with:
ref: ${{ inputs.fonts-ref }}
- name: Require requested craft-fonts
if: inputs.fonts-ref != '' && steps.token.outputs.available == 'true' && inputs.shards <= 1
shell: bash
env:
CRAFT_FONTS_PATH: ${{ github.workspace }}/craft-fonts
run: |
echo "CRAFT_FONTS_DIR=$CRAFT_FONTS_PATH" >> "$GITHUB_ENV"
echo "CRAFT_FONTS_REQUIRED=1" >> "$GITHUB_ENV"
- name: Free disk space
# Both a local coverage build and downloaded shard profiles plus the extracted test
# archive can overflow the free space on a stock runner. Clean before either path.
if: steps.token.outputs.available == 'true' && hashFiles('Cargo.toml') != ''
run: |
df -h . "$RUNNER_TEMP"
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL
df -h . "$RUNNER_TEMP"
- name: Install Linux build dependencies
if: steps.token.outputs.available == 'true' && hashFiles('Cargo.toml') != '' && inputs.shards <= 1
uses: storytold/craft-common-actions/actions/install-apt-packages@main
with:
packages: >-
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev libxrandr-dev
libxi-dev libgtk-3-dev libfontconfig1-dev libfreetype-dev libasound2-dev libsoup-3.0-dev libwebkit2gtk-4.1-dev
libgl1-mesa-dev mesa-vulkan-drivers
- name: Install Rust and cargo-llvm-cov
if: steps.token.outputs.available == 'true' && hashFiles('Cargo.toml') != '' && inputs.shards <= 1
run: |
rustup toolchain install stable --profile minimal --component llvm-tools-preview
rustup default stable
- uses: step-security/taiki-e-install-action@76747d22a7567cc4438546a84f11bdf18e670ab8 # v2.87.32
if: steps.token.outputs.available == 'true' && hashFiles('Cargo.toml') != '' && inputs.shards <= 1
with:
tool: cargo-llvm-cov
- uses: step-security/rust-cache@8871c8ba063416da7c32246932ea6ae08700453e # v2.9.2
if: steps.token.outputs.available == 'true' && hashFiles('Cargo.toml') != '' && inputs.shards <= 1
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Provision pinned PDF test fonts
if: inputs.pdf-test-fonts && steps.token.outputs.available == 'true' && hashFiles('Cargo.toml') != '' && inputs.shards <= 1
shell: bash
run: |
python3 scripts/fetch-pdf-test-fonts.py "$RUNNER_TEMP/craft-pdf-test-fonts"
echo "CRAFT_PDF_TEST_FONT_DIR=$RUNNER_TEMP/craft-pdf-test-fonts" >> "$GITHUB_ENV"
- name: Generate Rust coverage
id: coverage
if: steps.token.outputs.available == 'true' && hashFiles('Cargo.toml') != '' && inputs.shards <= 1
# A red test must not hide the static analysis, so keep going and scan what was measured.
continue-on-error: true
env:
CARGO_TERM_COLOR: always
# Coverage comes from instrumentation, not DWARF; debuginfo is most of target/.
CARGO_PROFILE_DEV_DEBUG: "0"
SQLX_OFFLINE_INPUT: ${{ inputs.sqlx-offline }}
CARGO_FEATURES: ${{ inputs.cargo-features }}
run: |
# Opt-in build settings; validated because they reach the cargo command line.
if [[ ! "$CARGO_FEATURES" =~ ^[A-Za-z0-9_/,-]*$ ]]; then
echo "::error::cargo-features may only hold feature names, '/', '-', '_' and ',', got '$CARGO_FEATURES'"
exit 1
fi
if [ "$SQLX_OFFLINE_INPUT" = true ]; then export SQLX_OFFLINE=true; fi
features=()
if [ -n "$CARGO_FEATURES" ]; then features=(--features "$CARGO_FEATURES"); fi
cargo llvm-cov --workspace "${features[@]}" --ignore-run-fail --lcov --output-path lcov.info \
--ignore-filename-regex "$COVERAGE_IGNORE"
- name: Download shard profiles and archive
if: steps.token.outputs.available == 'true' && inputs.shards > 1 && hashFiles('Cargo.toml') != ''
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
# One merged profile per shard (shard-N.profdata), never the raw profiles.
pattern: profdata-*
path: profdata
merge-multiple: true
- name: Download test archive
if: steps.token.outputs.available == 'true' && inputs.shards > 1 && hashFiles('Cargo.toml') != ''
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: nextest-archive
path: archive
- name: Install LLVM tools for the archive's toolchain
if: steps.token.outputs.available == 'true' && inputs.shards > 1 && hashFiles('Cargo.toml') != ''
run: rustup toolchain install "$(cat archive/rustc-release.txt)" --profile minimal --component llvm-tools-preview
- name: Combine shard coverage
if: steps.token.outputs.available == 'true' && inputs.shards > 1 && hashFiles('Cargo.toml') != ''
shell: bash
run: |
set -euo pipefail
toolchain="$(cat archive/rustc-release.txt)"
llvm_bin="$(rustup run "$toolchain" rustc --print sysroot)/lib/rustlib/$(rustup run "$toolchain" rustc -vV | sed -n 's/^host: //p')/bin"
df -h . "$RUNNER_TEMP"
# Each shard merged its own raw profiles; combine those. Retain the shard profiles
# until the merge succeeds, then release them before unpacking the test binaries.
"$llvm_bin/llvm-profdata" merge -sparse profdata/*.profdata -o coverage.profdata
rm -f profdata/*.profdata
df -h . "$RUNNER_TEMP"
# Unpack outside the checkout: the scanner analyses everything under the workspace, and build
# output (generated sources) there would be counted as duplicated code.
extracted="$(mktemp -d "$RUNNER_TEMP/coverage-archive.XXXXXX")"
trap 'rm -rf "$extracted"' EXIT
tar --zstd -xf archive/nextest-archive.tar.zst -C "$extracted"
rm -f archive/nextest-archive.tar.zst
# Only test binaries run on the target; helpers and proc-macros are not instrumented profile sources.
objects=()
while IFS= read -r name; do
while IFS= read -r -d '' file; do objects+=("$file"); done < <(find "$extracted" -type f -name "$name" -print0)
done < <(jq -r '."rust-binaries"[] | select(."build-platform" == "target") | ."binary-path" | split("/") | last' "$extracted/target/nextest/binaries-metadata.json")
if [ "${#objects[@]}" -eq 0 ]; then
echo "::error::No test binaries found in the archive metadata"
exit 1
fi
extra=()
for object in "${objects[@]:1}"; do extra+=(-object "$object"); done
"$llvm_bin/llvm-cov" export -format=lcov -instr-profile=coverage.profdata \
"${objects[0]}" "${extra[@]}" -ignore-filename-regex="$COVERAGE_IGNORE" >lcov.info
rm -f coverage.profdata
- name: Generate Python coverage
if: steps.token.outputs.available == 'true' && (inputs.python-coverage-source != '' || inputs.python-coverage-script != '')
shell: bash
env:
PYTHON_COVERAGE_SOURCE: ${{ inputs.python-coverage-source }}
PYTHON_COVERAGE_SCRIPT: ${{ inputs.python-coverage-script }}
run: |
set -euo pipefail
if [[ -z "$PYTHON_COVERAGE_SOURCE" || -z "$PYTHON_COVERAGE_SCRIPT" ]]; then
echo '::error::Python coverage requires both source and test script inputs'
exit 1
fi
if [[ ! -d "$PYTHON_COVERAGE_SOURCE" || ! -f "$PYTHON_COVERAGE_SCRIPT" ]]; then
echo '::error::Python coverage source directory or test script is missing'
exit 1
fi
source_dir=$(realpath -- "$PYTHON_COVERAGE_SOURCE")
test_script=$(realpath -- "$PYTHON_COVERAGE_SCRIPT")
workspace_dir=$(pwd -P)
case "$source_dir" in
"$workspace_dir"|"$workspace_dir"/*) ;;
*) echo '::error::Python coverage source must be inside the checkout'; exit 1 ;;
esac
case "$test_script" in
"$workspace_dir"/*) ;;
*) echo '::error::Python test script must be inside the checkout'; exit 1 ;;
esac
case "$source_dir$test_script" in
*','*|*'['*|*']'*|*'*'*|*'?'*) echo '::error::Python coverage paths must not contain commas or glob characters'; exit 1 ;;
esac
coverage_env=$(mktemp -d "$RUNNER_TEMP/python-coverage.XXXXXX")
python3 -m venv "$coverage_env"
"$coverage_env/bin/python" -m pip install --disable-pip-version-check coverage==7.10.7
export COVERAGE_FILE="$coverage_env/.coverage"
export PYTHON_COVERAGE_SOURCE_REAL="$source_dir"
export PYTHON_COVERAGE_SCRIPT_REAL="$test_script"
coverage_config="$coverage_env/coverage.ini"
# Environment substitution keeps paths literal and shares selection with reporting.
# shellcheck disable=SC2016
printf '%s\n' '[run]' 'branch = true' 'source = ${PYTHON_COVERAGE_SOURCE_REAL}' 'omit = ${PYTHON_COVERAGE_SCRIPT_REAL}' > "$coverage_config"
status=0
"$coverage_env/bin/python" -m coverage run --rcfile="$coverage_config" "$test_script" || status=$?
"$coverage_env/bin/python" -m coverage xml --rcfile="$coverage_config" -o python-coverage.xml
exit "$status"
- name: Set up Node.js for coverage
if: steps.token.outputs.available == 'true' && inputs.node-coverage-script != ''
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: ${{ inputs.node-version }}
- name: Generate JavaScript/TypeScript coverage
id: node-coverage
if: steps.token.outputs.available == 'true' && inputs.node-coverage-script != ''
shell: bash
env:
NODE_COVERAGE_SCRIPT: ${{ inputs.node-coverage-script }}
run: |
set -euo pipefail
# An npm script name only: the input is passed to `npm run`, never to a shell.
if [[ ! "$NODE_COVERAGE_SCRIPT" =~ ^[A-Za-z0-9][A-Za-z0-9:_.-]*$ ]]; then
echo "::error::node-coverage-script must be an npm script name, got '$NODE_COVERAGE_SCRIPT'"
exit 1
fi
npm ci --ignore-scripts --no-audit --no-fund
# Like the Rust coverage, a red test still lets the scan measure what ran; the job fails after it.
status=0
npm run --silent -- "$NODE_COVERAGE_SCRIPT" || status=$?
echo "status=$status" >>"$GITHUB_OUTPUT"
- name: SonarCloud Scan
if: steps.token.outputs.available == 'true'
uses: step-security/sonarqube-scan-action@c2312f8888f2bff4220bd815febb9d4a7cb32433 # v8.2.1
env:
SONAR_TOKEN: ${{ secrets.SONAR_CLOUD_PAT || secrets.SONAR_TOKEN }}
SONAR_HOST_URL: https://sonarcloud.io
with:
args: >
"-Dsonar.projectKey=${{ steps.project.outputs.key }}"
-Dsonar.organization=storytold
-Dsonar.rust.lcov.reportPaths=lcov.info
"-Dsonar.cpd.exclusions=${{ steps.project.outputs.cpd }}"
- name: Fail on JavaScript/TypeScript test failures
if: steps.node-coverage.outputs.status != '' && steps.node-coverage.outputs.status != '0'
env:
NODE_COVERAGE_STATUS: ${{ steps.node-coverage.outputs.status }}
run: |
echo "::error::node-coverage-script exited with status $NODE_COVERAGE_STATUS"
exit 1