pcbjam/.github/workflows/wasm-build.yml
Istvan Matejcsok 6dfa6ebd15 tests: move screenshot baselines from git to R2 (content-addressed manifest v2)
Baseline PNGs (492, 35 MB) leave git: they now live in the private R2 bucket
pcbjam-ci-screenshots as immutable sha256/<hex>.png objects, pinned by the
committed screenshot-manifest.json (v2: name, engine, sha256, bytes, dims).
tests/baseline-screenshots/ becomes a gitignored cache materialized by the new
`npm run screenshots:fetch` (r2-sync.ts + aws4fetch r2-store.ts).

- promote.ts: requires the RW keypair (shell env or gitignored tests/.env),
  syncs the cache, uploads new hashes BEFORE rewriting the manifest; the
  manifest diff is the only git-visible output. --prune only edits the
  manifest — R2 objects are never deleted, old commits still resolve.
- compare.ts: skips the gate (exit 0, no report.json) when the manifest
  expects baselines but the cache is empty (secretless callers).
- changelog.ts: diffs the manifest between revs and fetches bytes from R2;
  guards against the migration commit (base manifest not v2 → skip).
- gen-manifest --check: v2 schema + resurrection guard (fails if baseline
  PNGs are ever re-committed); credential-free so every caller can gate.
- wasm-build.yml: declares optional read-only S3 secrets, caches + fetches
  baselines before the lint gate; release.yml/deploy-staging.yml pass them.
- screenshot-changelog.yml: triggers on the manifest path instead of PNGs.

All 492 objects are seeded and hash-verified in the bucket; fetch/compare
degrade to a warn-and-skip without credentials.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 09:21:28 +02:00

509 lines
24 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

name: wasm-build (reusable)
# THE single KiCad WASM build+test recipe, called by ci-ubicloud.yml (main/PR) and
# release.yml (tag). Both build the SAME way — the only build knob is the
# 3D-viewer flag, ON for both real callers — so the build can never diverge from
# what ships (the bug that once shipped a 3D-off demo while CI built 3D-on).
#
# ONE output cache around docker/build.sh's --compile-only / --postprocess-only
# split. Under JSPI the wasm links fully finalized in-container; the only host
# post-process left is the deterministic, seconds-cheap ENV merge shim
# (patch-env-shim.mjs). So the cache holds the post-processed output/, keyed on
# the compile inputs alone: the kicad/wx submodule SHAs, the sc source-content
# hash (scripts/deploy/wasm-cache-hash.mjs — build logic outside the
# submodules), the 3D-viewer flag (a 3D-on and 3D-off build can never poison
# each other's entry), and the manual .ci-cache-epoch escape hatch. On a hit
# the whole build is skipped (a tag release reuses main's entry for the same
# commit); on a miss the compile reruns and the shim is re-applied before the
# save.
on:
workflow_call:
inputs:
build_3d_viewer:
description: "Build the WASM 3D viewer into kicad_editor (ON/OFF)"
type: string
default: "ON"
run_tests:
description: "Run the wxWidgets + KiCad e2e suites after building"
type: boolean
default: true
no_cache:
description: "Bypass the WASM output cache (force a full rebuild this run)"
type: boolean
default: false
upload_output:
description: "Upload the publishable output/ subset as the 'wasm-output' artifact"
type: boolean
default: false
secrets:
# Declared so this reusable workflow may reference ${{ secrets.DISCORD_WEBHOOK_URL }}
# (an undeclared secret reference is a workflow startup failure). ci-ubicloud.yml
# passes it via `secrets: inherit`; release.yml doesn't (required: false) → the
# screenshot/perf report step just no-ops there.
DISCORD_WEBHOOK_URL:
required: false
# Read-only S3 keypair for the private R2 screenshot-baseline bucket
# (pcbjam-ci-screenshots). Optional for the same reason: without them the
# baseline fetch skips and the compare gate no-ops (report-only pipeline).
CI_SCREENSHOTS_S3_ACCESS_KEY_ID:
required: false
CI_SCREENSHOTS_S3_SECRET_ACCESS_KEY:
required: false
jobs:
build-and-test:
name: Build all tools + KiCad e2e (Ubicloud)
# Don't run untrusted fork PRs on the paid runner (push/dispatch always run;
# same-repo PRs run). github.event_name here is the CALLER's event.
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubicloud-standard-30
timeout-minutes: 300
env:
KICAD_LOG_NESTED: "1"
BUILD_3D_VIEWER: ${{ inputs.build_3d_viewer }}
# Stable docker-compose project → deterministic build-cache volume name.
COMPOSE_PROJECT_NAME: kicad-wasm-ci
steps:
- name: Install build toolchain
run: |
export DEBIAN_FRONTEND=noninteractive
sudo apt-get update
# xvfb: kicad e2e runs headed Firefox under a virtual display.
# autoconf/automake/make: host wxWidgets + wx test-app builds.
# cmake/ninja/g++: host GAL/3D test-app builds.
sudo apt-get install -y cmake ninja-build g++ xvfb autoconf automake make
- uses: actions/checkout@v4
with: { submodules: recursive }
- uses: actions/setup-node@v4
with: { node-version: 20 }
# --- cache key ---------------------------------------------------------
# ONE key, from the compile inputs only: kicad/wx submodule SHAs, the sc
# source-content hash (wasm-cache-hash.mjs — build logic outside the
# submodules), the 3D flag (3D-on/off never share an entry), and the
# manual .ci-cache-epoch. The host post-process (ENV merge shim) is
# deterministic and cheap, so the cached bytes are the fully
# post-processed output — no separate post-process tier.
- name: Compute build inputs
id: keys
run: |
KICAD=$(git -C kicad rev-parse HEAD)
WX=$(git -C wxwidgets rev-parse HEAD)
SC=$(node scripts/deploy/wasm-cache-hash.mjs)
EPOCH=$(cat .ci-cache-epoch 2>/dev/null || echo 0)
EMV=$(. scripts/common/versions.sh && echo "$EMSCRIPTEN_VERSION")
THREED='${{ inputs.build_3d_viewer }}'
KEY="kwasm-${{ runner.os }}-k${KICAD}-wx${WX}-sc${SC}-3d${THREED}-e${EPOCH}"
{
echo "kicad=$KICAD"; echo "wx=$WX"; echo "sc=$SC"; echo "epoch=$EPOCH"
echo "emv=$EMV"
echo "key=$KEY"
} >> "$GITHUB_OUTPUT"
- name: Cache control (commit message / dispatch)
id: cachectl
env:
HEAD_MSG: ${{ github.event.head_commit.message }}
PR_TITLE: ${{ github.event.pull_request.title }}
DISPATCH_NOCACHE: ${{ inputs.no_cache }}
run: |
SKIP=false
if printf '%s\n%s' "$HEAD_MSG" "$PR_TITLE" | grep -qiE '\[(no-cache|rebuild-wasm)\]'; then SKIP=true; fi
[ "$DISPATCH_NOCACHE" = "true" ] && SKIP=true
echo "skip=$SKIP" >> "$GITHUB_OUTPUT"
echo "WASM output-cache restore skip=$SKIP"
# The cached paths the e2e tests need: the post-processed wasms + the
# sysroot headers the host GAL build compiles against.
- name: Restore WASM output cache
id: wasm-cache
if: steps.cachectl.outputs.skip != 'true'
uses: actions/cache/restore@v4
with:
path: |
output/*.js
output/*.wasm
output/*.wasm.map
output/*.worker.js
output/images.tar.gz
build-wasm/sysroot/include
!output/*.wasm.debug.wasm
key: ${{ steps.keys.outputs.key }}
# deps (sysroot + stamps) are only needed when we must COMPILE (cache miss).
- name: Restore deps cache
id: deps-cache
if: steps.wasm-cache.outputs.cache-hit != 'true'
uses: actions/cache@v4
with:
path: deps-cache
key: deps-${{ runner.os }}-${{ hashFiles('scripts/deps/**','scripts/common/versions.sh','scripts/common/functions.sh','scripts/common/env.sh','docker/Dockerfile','docker/docker-compose.yml') }}
- name: Seed deps volume from cache
if: steps.wasm-cache.outputs.cache-hit != 'true' && steps.deps-cache.outputs.cache-hit == 'true'
run: |
docker volume create kicad-wasm-ci_kicad-build-cache
docker run --rm -v kicad-wasm-ci_kicad-build-cache:/bw -v "$PWD/deps-cache":/cache \
alpine sh -c 'tar xzf /cache/deps.tar.gz -C /bw'
# PHASE 1 (cache miss only): container compile of all bundles → output/
# wasm, fully finalized in-container (JSPI links with the real tools).
# 3D viewer per input.
- name: Compile all KiCad tools (container)
if: steps.wasm-cache.outputs.cache-hit != 'true'
run: |
export KICAD_DOCKER_CPUS="$(( $(nproc) - 1 ))" KICAD_DOCKER_MEM=110G
echo "Compiling ALL tools, 3D viewer=${BUILD_3D_VIEWER}, -j $(nproc)"
./docker/build.sh all --compile-only --build-deps -j "$(nproc)"
ls -lh output/*.wasm
- name: Package deps for cache
if: steps.wasm-cache.outputs.cache-hit != 'true' && steps.deps-cache.outputs.cache-hit != 'true'
run: |
mkdir -p deps-cache
docker run --rm -v kicad-wasm-ci_kicad-build-cache:/bw -v "$PWD/deps-cache":/cache \
alpine sh -c 'cd /bw && tar czf /cache/deps.tar.gz sysroot stamps'
# GAL test compiles against kicad headers from the docker sysroot volume —
# export them to the host so they ride in the output cache (and thus any hit).
- name: Expose docker sysroot headers to host builds
if: steps.wasm-cache.outputs.cache-hit != 'true'
run: |
VOL=kicad-wasm-ci_kicad-build-cache
mkdir -p build-wasm/sysroot
docker run --rm -v "$VOL":/bw -v "$PWD/build-wasm/sysroot":/host alpine \
sh -c 'cp -r /bw/sysroot/include /host/'
sudo chown -R "$(id -u):$(id -g)" build-wasm/sysroot
# PHASE 2 (cache miss only): the sole remaining host post-process — the
# deterministic ENV merge shim (patch-env-shim.mjs) on the editor glue.
# No container needed; the CLIs (kicad_tools/occ_service/ngspice_service)
# skip even this inside build.sh.
- name: Host post-process (ENV merge shim)
if: steps.wasm-cache.outputs.cache-hit != 'true'
run: |
./docker/build.sh all --postprocess-only
ls -lh output/*.wasm
- name: Save WASM output cache
if: steps.wasm-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@v4
with:
path: |
output/*.js
output/*.wasm
output/*.wasm.map
output/*.worker.js
output/images.tar.gz
build-wasm/sysroot/include
!output/*.wasm.debug.wasm
key: ${{ steps.keys.outputs.key }}
# --- publishable artifact (release path) -------------------------------
- name: Upload WASM output artifact
if: inputs.upload_output
uses: actions/upload-artifact@v4
with:
name: wasm-output
if-no-files-found: error
path: |
output/*.js
output/*.wasm
output/*.wasm.map
output/*.worker.js
output/images.tar.gz
!output/*.wasm.debug.wasm
# --- e2e tests (gated on run_tests) ------------------------------------
# Host emsdk toolchain (tools/emsdk): env.sh auto-installs it on first use
# (~23s: emsdk repo clone + ~340 MB from storage.googleapis.com, measured run
# 28577824366). Caching it is speed-neutral-to-slightly-positive; the real
# value is availability — without it a github.com/storage.googleapis.com
# hiccup fails every run. Keyed on the pinned EMSCRIPTEN_VERSION (versions.sh);
# the emscripten ports cache (zlib) rides along. The spent downloads/ tarballs
# are pruned below before the post-job save.
- name: Cache emsdk toolchain
if: inputs.run_tests
uses: actions/cache@v4
with:
path: tools/emsdk
key: emsdk-${{ runner.os }}-${{ steps.keys.outputs.emv }}
- name: Restore wx build cache
id: wx-cache
if: inputs.run_tests
uses: actions/cache@v4
with:
path: build-wasm/wxwidgets
key: wx-${{ runner.os }}-${{ steps.keys.outputs.wx }}-${{ hashFiles('scripts/build-wx-wasm.sh','scripts/common/versions.sh') }}
# One shared timestamp, not per-file "now": plain `touch {} +` stamps each file
# a few ns apart in readdir order, and GNU make 4.x compares ns mtimes — so any
# object touched before a generated header it depends on (wx/setup.h, pcre2.h
# via .deps/*.d) looks stale and a random subset recompiles every cache-hit run.
# Equal mtimes read as up to date.
- name: Mark restored wx objects current
if: inputs.run_tests && steps.wx-cache.outputs.cache-hit == 'true'
run: find build-wasm/wxwidgets -exec touch -d "@$(date +%s)" {} +
- name: Build wxWidgets (wxUniversal WASM)
if: inputs.run_tests
run: ./scripts/build-wx-wasm.sh
# Built wx test apps (tests/apps): without this every run recompiles and
# relinks all ~74 apps (~2m20s even 30-wide). Key = every build input: the
# wx lib identity (submodule SHA + the same script hashes as the wx cache
# key; the wx SHA also covers the Makefile's JS_FILES from
# wxwidgets/build/wasm), the KICAD submodule SHA (some apps compile real
# KiCad sources — thread_pool.cpp, libcontext, headers), the app sources
# (tracked cpp/h/html + Makefile), and the build scripts +
# JS shims (wasm/**). hashFiles runs at restore
# time, on a fresh checkout, so it sees only tracked sources — never build
# outputs. On a hit the build step is skipped entirely. Excluded:
# tests/apps/kicad (setup:kicad staging from output/) and gal-webgl (its own
# step below rebuilds it every run anyway).
# Gated on a wx cache HIT: the test-app build is what creates the
# libwx_*.a -> libwx_*-emscripten.a symlinks the GAL link (wx-config --libs)
# needs. A restored wx cache contains them (saved at job end, after they
# exist), but a freshly rebuilt wx tree does not — so on a wx miss the apps
# must rebuild too, or the GAL step breaks.
- name: Cache built wx test apps
id: testapps-cache
if: inputs.run_tests && steps.wx-cache.outputs.cache-hit == 'true'
uses: actions/cache@v4
with:
path: |
tests/apps
!tests/apps/kicad
!tests/apps/gal-webgl
!tests/apps/3d-webgl
key: testapps-${{ runner.os }}-wx${{ steps.keys.outputs.wx }}-k${{ steps.keys.outputs.kicad }}-${{ hashFiles('tests/apps/**/*.cpp', 'tests/apps/**/*.h', 'tests/apps/**/*.html', 'tests/apps/Makefile.wasm', 'scripts/build-wx-wasm.sh', 'scripts/build-wasm-test.sh', 'scripts/common/versions.sh', 'scripts/common/env.sh', 'scripts/common/functions.sh', 'scripts/common/shims/**', 'wasm/**') }}
- name: Build wxWidgets test apps
if: inputs.run_tests && steps.testapps-cache.outputs.cache-hit != 'true'
run: ./scripts/build-wasm-test.sh
- name: Build GAL WebGL test app
if: inputs.run_tests
run: ./scripts/build-gal-webgl-test.sh
# 3D renderer regression harness (tests/3d-regression): same recipe as GAL —
# compiles real KiCad 3D-viewer TUs + the wasm/gl1 GL1->WebGL2 layer against
# the wx build and the docker-exposed sysroot headers. The capture spec
# (e2e/3d-webgl.spec.ts) runs inside the wx e2e step below; it self-skips
# when this app is missing.
- name: Build 3D WebGL test app
if: inputs.run_tests
run: ./scripts/build-3d-webgl-test.sh
# The emsdk cache saves in the post-job phase; the downloads/ tarballs
# (~340 MB) are spent after install — drop them so they never ride in the
# cache. No-op on cache-hit runs (already pruned before the save).
- name: Prune emsdk download tarballs (cache hygiene)
if: inputs.run_tests
run: rm -rf tools/emsdk/downloads
- name: Install test deps
if: inputs.run_tests
working-directory: tests
run: npm ci
# Baseline screenshots live in a private R2 bucket, pinned by the committed
# manifest; this cache keyed on the manifest hash makes most fetches a
# no-op (r2-sync hash-verifies every restored file, so a stale restore
# costs only the changed downloads).
- name: Cache screenshot baselines
if: inputs.run_tests
uses: actions/cache@v4
with:
path: tests/baseline-screenshots
key: baselines-${{ hashFiles('tests/screenshot-manifest.json') }}
# continue-on-error: the screenshot pipeline is deliberately report-only
# (see the report step) — an R2 outage must not fail the build. Without
# credentials (release.yml, fork PRs) r2-sync skips with a warning and
# compare.ts later no-ops for the same reason.
- name: Fetch screenshot baselines from R2
if: inputs.run_tests
continue-on-error: true
working-directory: tests
env:
CI_SCREENSHOTS_S3_ENDPOINT: ${{ vars.CI_SCREENSHOTS_S3_ENDPOINT }}
CI_SCREENSHOTS_S3_ACCESS_KEY_ID: ${{ secrets.CI_SCREENSHOTS_S3_ACCESS_KEY_ID }}
CI_SCREENSHOTS_S3_SECRET_ACCESS_KEY: ${{ secrets.CI_SCREENSHOTS_S3_SECRET_ACCESS_KEY }}
run: npm run screenshots:fetch
# Cheap hygiene gates (no build needed): the determinism lint keeps the
# banned flake patterns out of the specs, the manifest check validates
# screenshot-manifest.json (v2 schema, sorted/unique hashes) and fails if
# baseline PNGs are ever re-committed to git (credential-free — it never
# touches R2, so it gates secretless callers identically), and
# the CI-coverage lint proves every spec file on disk is reachable from
# the npm scripts THIS workflow invokes (a spec/project that CI never
# runs is how the web suite once rotted unnoticed).
- name: Lint test determinism + screenshot manifest + CI coverage
if: inputs.run_tests
working-directory: tests
run: |
npm run lint:determinism
npm run screenshots:manifest -- --check
npm run lint:ci-coverage
- name: Install web workspace deps (collab bundle)
if: inputs.run_tests
working-directory: web
run: |
corepack enable
pnpm install --frozen-lockfile
# kicad_tools gates (tasks-runner 0001 R2): the corpus lint (fixtures +
# shared-codec round-trips — the wrapInBoardEnvelope-class E3 gate,
# kicad-validity 0001 §5) and the CLI contract the backend job runner
# keys off (exit codes, resave semantics). Both scripts skip when the
# artifact is absent, but "all" builds kicad_tools now, so here they
# bite. Needs tests npm deps (above) + web workspace deps (the corpus
# lint imports the shared codec).
- name: kicad_tools corpus lint + CLI contract
if: inputs.run_tests
working-directory: tests
run: |
npm run corpus:lint
npm run tools:contract
# Browser binaries keyed on the lockfile (which pins the playwright version).
# On a hit `playwright install` skips the downloads; --with-deps still
# apt-installs its small OS dep set either way.
- name: Cache Playwright browsers
if: inputs.run_tests
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: pw-${{ runner.os }}-${{ hashFiles('tests/package-lock.json') }}
- name: Install Playwright browsers
if: inputs.run_tests
working-directory: tests
run: npx playwright install --with-deps firefox chromium
- name: Stage KiCad WASM for tests
if: inputs.run_tests
working-directory: tests
run: npm run setup:kicad
# ONE merged Playwright invocation for every apps-server suite (wx,
# kicad×2 engines, jspi, coroutine — see playwright.config.ts). One
# invocation = one start-of-run outputDir wipe BEFORE anything rendered,
# so the engine-scoped screenshots in test-results/{chromium,firefox}/
# accumulate naturally for the offline compare. A failure fails the JOB
# (no continue-on-error); the report below still runs and shows exactly
# what broke or went missing. xvfb: the Firefox projects run headed on CI
# (GPU-less VMs have no headless GL).
- name: e2e (npm run test:e2e — all suites, both engines)
id: e2e
if: inputs.run_tests
working-directory: tests
run: xvfb-run -a npm run test:e2e
# The React web-app suite (web/) against the pnpm dev stack (frontend
# :3048 + backend :3060, cold-started by Playwright's webServer on CI).
# Runs even if the main e2e failed (`!cancelled()` + not-skipped guard) so
# its screenshots still reach the report.
- name: web e2e (npm run test:web:ci)
id: web_e2e
if: inputs.run_tests && !cancelled() && steps.e2e.outcome != 'skipped'
working-directory: tests
run: xvfb-run -a npm run test:web:ci
# Runtime-perf E2E (eeschema + pcbnew): measures the current build's
# load / open+render / FPS and writes tests/test-results/perf-*.json.
# Track-only — never gates the build (continue-on-error). CI is
# headless/SwiftShader so FPS is CPU-bound + noisy; openMs is the stable number.
- name: KiCad runtime perf (track-only, non-gating)
if: inputs.run_tests && !cancelled() && steps.e2e.outcome != 'skipped'
continue-on-error: true
working-directory: tests
run: xvfb-run -a npm run test:perf
# 3D renderer parity + browser-self regression (report-only). The wx e2e
# step above captured the 47 scenario renders into
# tests/3d-regression/output/webgl; compare them against the committed
# native goldens (parity — the port-correctness meter) and the committed
# browser goldens (webgl-self). continue-on-error while the committed
# baseline-webgl/ set is Mac-Chromium-sourced: if CI's SwiftShader
# rasterizes past the 0.005 floor, promote CI's renders into
# baseline-webgl/ and flip this step gating. 3d:review writes the full
# 47-pair triptych gallery for the artifact upload below.
- name: 3D renderer parity (report-only)
if: inputs.run_tests && !cancelled() && steps.e2e.outcome != 'skipped'
continue-on-error: true
working-directory: tests
run: |
npm run 3d:check:parity
npm run 3d:check:webgl
npm run 3d:review
# Screenshot drift gate + Discord report (perf + triptychs). Runs whenever
# the e2e suites ran — INCLUDING on e2e failure — so a wrong or missing
# screenshot is visible on Discord even on a red build (a spec that died
# before its page.screenshot() shows up as "removed"). The --e2e badge is
# computed from the suite outcomes. Report-only during rollout: compare.ts
# exits 0 without --fail-on-change and the step is continue-on-error, so it
# never blocks the build — flip to gating once the per-engine floors are
# calibrated (tests/tools/screenshots/config.ts, seeded by
# `npm run screenshots:noise`). Posts ONLY on push to main and no-ops
# without DISCORD_WEBHOOK_URL (inert on PRs/forks). No extra build — reads
# the already-produced test-results (screenshots + perf-*.json).
- name: Screenshot report + perf
id: report
if: inputs.run_tests && !cancelled() && steps.e2e.outcome != 'skipped'
continue-on-error: true
working-directory: tests
env:
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
GH_TOKEN: ${{ github.token }}
# Report-only: post the screenshot drift + perf table to Discord but NEVER fail the
# build on a screenshot difference — the Discord post is the signal. compare.ts exits 0
# without --fail-on-change; continue-on-error also shields transient Discord hiccups.
run: |
E2E=pass
{ [ "${{ steps.e2e.outcome }}" = "success" ] \
&& [ "${{ steps.web_e2e.outcome }}" = "success" ]; } || E2E=fail
npm run screenshots:check
npm run screenshots:report -- --e2e "$E2E"
# FALLBACK on failure: a minimal text-only "CI failed" notice, only when the
# rich screenshot report above did NOT post (build broke before the tests →
# report skipped, or the report itself errored). An e2e-only failure already
# posts the full report with the ❌ e2e badge + run URL — no duplicate ping.
# Uses curl, NOT the TS reporter, because on a build failure the test deps
# (npm ci) never installed. Main-push only.
- name: Discord CI-failure notice
if: failure() && github.ref == 'refs/heads/main' && github.event_name == 'push' && steps.report.outcome != 'success'
continue-on-error: true
env:
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
run: |
[ -z "$DISCORD_WEBHOOK_URL" ] && { echo "no webhook — skipping"; exit 0; }
SHORT="$(echo "${{ github.sha }}" | cut -c1-7)"
URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
SUBJ="$(git log -1 --pretty=%s 2>/dev/null || true)"
CONTENT="❌ **CI failed** \`$SHORT\` — $SUBJ"$'\n'"$URL"
jq -n --arg c "$CONTENT" '{content:$c, allowed_mentions:{parse:[]}}' \
| curl -sS -X POST "$DISCORD_WEBHOOK_URL" -H "Content-Type: application/json" -d @- >/dev/null \
&& echo "posted CI-failure notice"
- name: Upload test logs & screenshots
if: always() && inputs.run_tests
uses: actions/upload-artifact@v4
with:
name: ubicloud-e2e-${{ github.run_id }}
path: |
tests/logs/**
tests/test-results/**
tests/pw-artifacts/**
tests/playwright-report/**
tests/3d-regression/output/**
if-no-files-found: ignore