mirror of
https://github.com/storytold/craft-common-actions.git
synced 2026-11-15 15:02:24 +00:00
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.
745 lines
34 KiB
YAML
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
|