Files
craft-common-actions/.github/workflows/sonarcloud.yml
T
FredApps 804f702057 fix(sonar): surface archived Rust test failures after analysis (#222)
* fix(sonar): report archived Rust test failures after scanning

* test(sonar): cover shard status production and document result semantics
2026-11-12 23:38:33 +01:00

777 lines
36 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: >
*.amazontrust.com:80
motd.ubuntu.com:443
*.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: >
*.amazontrust.com:80
motd.ubuntu.com:443
*.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: >
*.amazontrust.com:80
motd.ubuntu.com:443
*.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
# record that status for the scan job to fail after uploading analysis. 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
mkdir -p profdata
printf '%s\n' "$rc" > "profdata/shard-$SHARD.status"
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: >
*.amazontrust.com:80
motd.ubuntu.com:443
*.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
- name: Fail on Rust shard test failures
if: steps.token.outputs.available == 'true' && inputs.shards > 1 && hashFiles('Cargo.toml') != ''
shell: bash
env:
TOTAL_SHARDS: ${{ inputs.shards }}
run: |
set -euo pipefail
failed=0
for ((shard=1; shard<=TOTAL_SHARDS; shard++)); do
path="profdata/shard-$shard.status"
if [ ! -f "$path" ]; then
echo "::error::Missing test status for coverage shard $shard"
failed=1
continue
fi
status="$(cat "$path")"
if [ "$status" != 0 ]; then
echo "::error::Coverage shard $shard tests failed (exit $status); analysis was still uploaded"
failed=1
fi
done
exit "$failed"