mirror of
https://github.com/storytold/craft-common-actions.git
synced 2026-11-15 15:02:24 +00:00
842 lines
39 KiB
YAML
842 lines
39 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: Check out craft-i18n
|
|
uses: storytold/craft-common-actions/actions/checkout-craft-i18n@main
|
|
- 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: Check out craft-i18n
|
|
uses: storytold/craft-common-actions/actions/checkout-craft-i18n@main
|
|
- 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
|
|
profiles=true
|
|
else
|
|
profiles=false
|
|
fi
|
|
|
|
if [ "$rc" -eq 100 ]; then
|
|
echo "::warning::Coverage shard $SHARD/$TOTAL_SHARDS has failing tests; see this job's test log."
|
|
if [ "$profiles" = true ]; then
|
|
result="**Tests failed.** Coverage profiles are available, so the SonarCloud scan will continue. A green coverage job does not mean the tests passed; see this job's log for failed tests and diagnostics."
|
|
else
|
|
result='**Tests failed.** This shard produced no coverage profiles, so its tests cannot be included in the SonarCloud scan.'
|
|
fi
|
|
else
|
|
if [ "$profiles" = true ]; then
|
|
result='**Tests passed.** Coverage profiles are available for the SonarCloud scan.'
|
|
else
|
|
result='**Tests passed.** This shard produced no coverage profiles, so its tests cannot be included in the SonarCloud scan.'
|
|
fi
|
|
fi
|
|
printf '### Coverage shard %s/%s\n\n%s\n' "$SHARD" "$TOTAL_SHARDS" "$result" >> "$GITHUB_STEP_SUMMARY"
|
|
|
|
if [ "$profiles" != true ]; 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: Check out craft-i18n
|
|
if: steps.token.outputs.available == 'true' && inputs.shards <= 1
|
|
id: i18n
|
|
uses: storytold/craft-common-actions/actions/checkout-craft-i18n@main
|
|
- 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: Fetch locked Rust dependencies
|
|
if: steps.token.outputs.available == 'true' && hashFiles('Cargo.toml') != '' && inputs.shards <= 1
|
|
run: cargo fetch --locked
|
|
- 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
|
|
# Run every instrumented test and remember failures, but still create coverage so
|
|
# SonarCloud can publish diagnostics before this workflow reports a red result.
|
|
set +e
|
|
cargo llvm-cov --workspace "${features[@]}" --no-report --no-fail-fast \
|
|
--ignore-filename-regex "$COVERAGE_IGNORE"
|
|
test_status=$?
|
|
set -e
|
|
echo "test_status=$test_status" >> "$GITHUB_OUTPUT"
|
|
cargo llvm-cov report --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"
|
|
# The checkout is a build input, not caller source. Moving it after all coverage runs
|
|
# prevents an unsharded scan from counting another repository's files as this project.
|
|
- name: Move craft-i18n outside scan workspace
|
|
if: steps.i18n.outputs.rev != ''
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
destination="$(mktemp -d "$RUNNER_TEMP/craft-i18n.XXXXXX")"
|
|
mv -- "$GITHUB_WORKSPACE/craft-i18n" "$destination/checkout"
|
|
- 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 Rust coverage generation failure
|
|
if: steps.coverage.outcome == 'failure'
|
|
shell: bash
|
|
run: |
|
|
echo "::error::cargo llvm-cov failed to build the test archive or write lcov.info"
|
|
exit 1
|
|
- name: Fail on Rust instrumented test failures
|
|
if: steps.coverage.outputs.test_status != '' && steps.coverage.outputs.test_status != '0'
|
|
shell: bash
|
|
env:
|
|
RUST_TEST_STATUS: ${{ steps.coverage.outputs.test_status }}
|
|
run: |
|
|
echo "::error::instrumented Rust tests exited with status $RUST_TEST_STATUS"
|
|
exit 1
|
|
- 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"
|