Skip to content

ci: set ZKCOINS_PROVER_LEASE_PATH for the heavy coverage job #1

ci: set ZKCOINS_PROVER_LEASE_PATH for the heavy coverage job

ci: set ZKCOINS_PROVER_LEASE_PATH for the heavy coverage job #1

Workflow file for this run

name: CI

Check failure on line 1 in .github/workflows/ci.yaml

View workflow run for this annotation

GitHub Actions / .github/workflows/ci.yaml

Invalid workflow file

(Line: 281, Col: 34): Unrecognized named-value: 'runner'. Located at position 1 within expression: runner.temp
on:
# `workflow_dispatch` stays available: a run can still be started by
# hand from the Actions tab when a specific answer is wanted.
workflow_dispatch:
# CI runs on every pull request regardless of target branch. This
# makes the default safe for stacked PRs (PR-A → PR-B → PR-C where
# each PR's base is the previous PR's branch) and any other workflow
# that opens a PR against a non-`develop` branch — previously such
# PRs were silently skipped because `branches: [develop]` filtered
# them out, and the only fix was to hand-edit ci.yaml on each new
# feature stack. Letting every PR trigger CI matches what most
# repos default to. The heavy M3 Ultra gate (`test-and-coverage`)
# now runs on every non-draft PR (CI endgame / G16 — see
# `.github/coverage-baseline.md`); drafts still skip via each
# job's `if:` guard.
#
# `push: develop` is intentionally absent. Every commit reaching
# `develop` is already covered by the open Release PR (`Release:
# develop -> main`, created by auto-release-pr.yaml) — that PR's
# `synchronize` event runs CI on the new HEAD, and the heavy gate
# runs because the Release PR is non-draft. Adding
# `on: push: branches: [develop]` would queue a second workflow
# instance on the same SHA, doubling self-hosted-runner load on a
# check the Release PR's `synchronize` already provides.
# (Under the PR-number grouping in the concurrency block below the
# two runs would land in DIFFERENT groups — push keyed by
# `refs/heads/develop`, PR keyed by the Release PR's number — so
# the block would not deduplicate them.)
#
# Drafts skip the jobs unless they carry `ci` or `ci:full`. That
# is how CI starts on a draft — never by flipping ready-for-review
# just to wake the suite. `labeled` / `unlabeled` re-fire so adding
# `ci` / `ci:full` on a draft actually starts the jobs.
# `ready_for_review` still fires when a finished PR leaves draft.
pull_request:
types: [opened, synchronize, reopened, ready_for_review, labeled, unlabeled]
concurrency:
# Group by PR number so a new push to the same PR cancels the
# in-flight Heavy run on the outdated commit. The self-hosted
# M3 Ultra runner pool is shared with every other open PR —
# letting an obsolete 60-90-min run finish wastes a slot another
# PR could use. Grouping by SHA (the previous approach) put every
# commit in its own group, so `cancel-in-progress: true` never
# fired and back-to-back pushes queued sequentially.
# Falls back to `github.ref` for push/dispatch events (where there
# is no `pull_request.number`), so e.g. a `workflow_dispatch` on
# the same ref serializes too.
#
# Label events (`labeled` / `unlabeled`) get their own isolated
# group keyed by `run_id`, so toggling a label on a PR does NOT
# cancel an in-flight Heavy run on the same PR — most label
# toggles are unrelated (`bug`, `priority/*`, …) and killing the
# Heavy run for them would be a footgun. Trade-off: a label toggle
# mid-run does NOT auto-stop a Heavy run that is already
# executing; cancel it manually with `gh run cancel` if you really
# need to free an agent.
group: >-
${{
(github.event.action == 'labeled' || github.event.action == 'unlabeled')
&& format('ci-{0}-label-{1}', github.workflow, github.run_id)
|| format('ci-{0}-{1}', github.workflow, github.event.pull_request.number || github.ref)
}}
cancel-in-progress: true
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
# Job topology — a two-tier test-gating model:
#
# * Tier 1 — `lint-and-build` — GitHub-hosted Linux, the DEFAULT.
# Runs on every non-draft PR and every push. Catches cross-platform
# compile bitrot and lint regressions cheaply. Runs in PARALLEL with
# the heavy gate below — it does not gate it via `needs:`. Each job
# carries its own draft `if:` guard, so a lint failure does not
# block the heavy gate from starting (deliberate: parallel
# feedback. Trade-off: on a lint failure the m3-ultra runner time
# is spent regardless).
#
# * Tier 2 — `test-and-coverage` — the authoritative test + coverage
# gate. Runs on every non-draft PR (CI endgame / G16 expansion —
# previously label-gated behind `ci:full`; see
# `.github/coverage-baseline.md`). Single heavy job on the shared
# self-hosted M3 Ultra runner pool. It runs the FULL node + shared
# nextest suite under llvm-cov instrumentation: the Postgres
# `db_tests`, the Plonky2-heavy mint/send/receive prover flows, the
# measured coverage floor, plus the release-mode prover package and
# the four `#[ignore]` prove flows (Task #9), all in one job.
# Drafts skip unless they carry `ci` or `ci:full`.
#
# There is no third "subset" tier: a test either runs in the default
# Lint & Build (compile/lint) or in the heavy gate (the full suite).
# The previous narrow per-area subset jobs (and their per-area opt-in
# labels) were removed — the heavy gate is a strict superset of
# everything they selected, so they added a maintenance burden
# (filter drift) without extending coverage.
#
# Why test + coverage are merged into one job: the previous topology
# had a `node-tests` job and a separate `coverage` job, both
# running the SAME nextest suite (`coverage` simply wrapped nextest
# in `cargo llvm-cov nextest`). That doubled wall-clock and m3-ultra
# agent usage on every Release PR for no signal benefit — llvm-cov
# under nextest produces both test execution AND coverage data in a
# single binary run. Merging them keeps the measured coverage floor
# intact (same ignore regex as baseline, same `not binary(api_remote)`
# exclusion) while running the heavy suite once per PR.
#
# The documented hardware target is the M3 Ultra (CONTRIBUTING.md
# § "Working on the Plonky2 Migration"). On `ubuntu-latest` the
# full suite repeatedly hit the 75-min timeout (issue #30); on the
# M3 Ultra it is ~60-90 min for a Rust change. Moving the gate
# into CI rather than the developer's laptop unblocks the developer
# on push (issue #40).
#
# Runner ops: see scripts/ci-runner/README.md.
jobs:
lint-and-build:
name: Lint & Build
# Skip drafts that have no CI-start label. `ci` and `ci:full`
# start this job on a draft (same suite as a ready PR). Ready
# is not a CI switch.
if: >
github.event_name != 'pull_request' ||
github.event.pull_request.draft == false ||
contains(toJson(github.event.pull_request.labels.*.name), '"ci"') ||
contains(toJson(github.event.pull_request.labels.*.name), '"ci:full"')
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v4
# The repo pins its toolchain in `rust-toolchain` (a dated nightly, with
# `rustfmt` and `clippy` in `components`) and every other job uses it.
# This step used to install 1.81.0 explicitly and export
# `RUSTUP_TOOLCHAIN`, which overrides the file — so formatting and lints
# were checked on a toolchain seventeen releases older than the one that
# builds and tests the same tree. That is not a conservative choice, it
# is a different compiler: a clippy suggestion can name an API the build
# toolchain has and the lint toolchain does not, and a lint that only
# exists in one of them is either invisible or unfixable. Installing the
# pinned toolchain instead keeps a single pin with nothing to drift.
- name: Install the pinned toolchain (rust-toolchain)
run: |
rustup show active-toolchain || rustup toolchain install
cargo --version
cargo fmt --version
cargo clippy --version
- name: Cache cargo registry and build
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-
# kernel-proto/build.rs invokes `protoc` (via tonic-build / prost-build)
# to compile the kernel.v1 contract. ubuntu-latest ships no
# protobuf-compiler by default — without this step, fmt is fine but
# clippy/build fail with "Could not find `protoc`".
- name: Install protoc (kernel-proto build.rs)
run: sudo apt-get update && sudo apt-get install -y protobuf-compiler
- name: Check formatting
run: cargo fmt --all --check
# `--all-targets` on every clippy step: without it clippy lints the
# library targets only, so tests, benches and feature-gated fixture
# modules — several thousand lines that decide whether a green run
# means anything — were never linted at all. Two real lints were
# hiding in `shared/src/spec_v1/nflog_boundary.rs`, which is behind
# the `test-fixtures` feature and therefore invisible to a plain
# `-p shared` run.
- name: Run clippy (node + shared, MVP feature set)
run: cargo clippy -p node -p shared --all-targets -- -D warnings
- name: Run clippy (node, all features)
run: cargo clippy -p node --all-features --all-targets -- -D warnings
- name: Run clippy (program + prover)
run: cargo clippy -p zkcoins-program-plonky2 -p zkcoins-prover-plonky2 --all-targets -- -D warnings
# Issue #84: chain-tip advance and publisher commit→reveal waits must
# be event-driven (bitcoind ZMQ / block signals), not silent polls.
# The deleted Esplora WS scanner modules are gone; the live hotpaths
# are the v1 bitcoind scan loop in `main.rs` and `publisher.rs`.
# The grep fails the build if a `tokio::time::{sleep,sleep_until,
# interval}` or `std::thread::sleep` call appears there without the
# same-line opt-out marker. See CONTRIBUTING.md § "No polling —
# events only" for `scanner-polling-ok:` and the rationale for each
# grandfathered occurrence. The marker is a plain comment token
# (not an `#[allow(...)]` attribute) so contributors cannot mistake
# it for a real lint suppression (issue #84 round-4 MINOR 4).
# Follow-up: replace main.rs scan_to_tip idle sleep with bitcoind
# block-signal subscription (event-driven tip advance).
- name: Forbid polling patterns in scanner/publisher
run: |
set -e
FOUND=$(grep -rEn 'tokio::time::(sleep|sleep_until|interval)|std::thread::sleep' node/src/main.rs node/src/publisher.rs 2>/dev/null | grep -v 'scanner-polling-ok:' || true)
if [ -n "$FOUND" ]; then
echo "::error::Polling pattern (tokio::time::sleep|sleep_until|interval or std::thread::sleep) detected in event-driven hot paths. See issue #84."
echo "$FOUND"
exit 1
fi
echo "Scanner/publisher polling check: OK"
- name: Build node (MVP feature set — the DEV + PRD image)
run: cargo build -p node
- name: Build node (all features — self-host opt-in build)
run: cargo build -p node --all-features
test-and-coverage:
name: Tests + Coverage Gate (M3 Ultra, measured coverage floor)
# Authoritative heavy gate: runs the full nextest suite under
# llvm-cov instrumentation, producing both test execution AND
# coverage data in a single binary run. Replaces the previous
# `node-tests` + `coverage` pair (the two jobs ran the same
# nextest suite — see the file header for the merge rationale).
# Floor values and ignore rules: `.github/coverage-baseline.md`.
#
# Same start rule as `lint-and-build`: every non-draft PR, plus
# drafts that carry `ci` or `ci:full`. `ci:full` is no longer a
# scope gate — it is only a start-on-draft alias.
if: >
github.event_name != 'pull_request' ||
github.event.pull_request.draft == false ||
contains(toJson(github.event.pull_request.labels.*.name), '"ci"') ||
contains(toJson(github.event.pull_request.labels.*.name), '"ci:full"')
# The registered self-hosted M3 Ultra machines (dfx01, dfx01-2) advertise
# the `zkcoins-node` pool label, not `m3-ultra` — the old `m3-ultra`
# requirement matched no online runner, so this gate queued forever. Target
# the label the runners actually carry.
runs-on: [self-hosted, zkcoins-node]
# 180 min: llvm-cov nextest (~60-90 min) + release prover package
# + four `#[ignore]` prove flows (a representative multi-input
# send prove alone is ~290 s locally) + circuit-digest verify.
# Was 120 min when the job stopped at coverage + digests only.
timeout-minutes: 180
env:
# All three chain-shaping env vars are required by the node
# bootstrap (see `lib::build_network_config_from_env`). CI uses
# `127.0.0.1:1` endpoints so any test that exercises the commit
# pipeline / scanner WS fails fast instead of reaching a public
# third-party host (a previous Mutinynet-flavoured silent
# fallback used to add >60 s per test).
IS_MAINNET: "false"
ESPLORA_URL: http://127.0.0.1:1/api
ESPLORA_WS_URL: ws://127.0.0.1:1/api/v1/ws
# `USERNAME_DOMAIN` is required by the node bootstrap (no
# default — see node/src/main.rs and issue #95). The test value
# is irrelevant for the `info_returns_*` assertions (they only
# check non-empty + shape).
USERNAME_DOMAIN: test.zkcoins.local
# `PUBLISHER_KEY` is required on every network (no default —
# see `node/src/lib.rs`); the value is a syntactically valid
# 32-byte hex placeholder, NOT a secret. MUST match
# `node/src/router_tests.rs` — the test mocks derive the
# wiremock'd publisher address from this key. The previous
# `1234567890abcdef…` fallback was a publicly-known test key that
# drainer bots swept within minutes of any on-chain top-up; the
# fallback was removed network-wide. The `0000…0001` value here
# is chosen so a future grep for the burned `1234…` key returns
# empty across the repo + CI config; it MUST NEVER be reused on
# any chain that holds value.
PUBLISHER_KEY: "0000000000000000000000000000000000000000000000000000000000000001"
# Host-wide proving lease: `compliance_circuit` refuses to
# build without ZKCOINS_PROVER_LEASE_PATH (it serialises the
# RAM-heavy circuit across processes). The file is created on
# first open. `runner.temp` is writable per job.
ZKCOINS_PROVER_LEASE_PATH: ${{ runner.temp }}/zkcoins-prover.lease
# The full suite includes the `db_tests`, which use the
# `testcontainers` crate to spin up a real Postgres 17 per test
# against the local Docker daemon. The self-hosted runner runs
# Colima (not Docker Desktop), whose socket lives under the
# runner user's home directory; `DOCKER_HOST` is set in a step
# below so the Colima socket path resolves from `$HOME` at
# runtime.
# `sccache` wraps `rustc` and caches compiled crates across CI
# runs. The M3 Ultra runner agents are self-hosted, so the cache
# lives on local disk and survives between jobs.
RUSTC_WRAPPER: sccache
# Bump the cache cap above sccache's 10-GiB default. The cache is
# user-level (~/Library/Caches/Mozilla.sccache) and shared by
# every m3-ultra agent on the host; with 3+ parallel agents the
# 10-GiB default thrashed (writes from one agent evicted hits
# another had not consumed yet). 50 GiB fits the current working
# set with room to grow; the host has >600 GiB free disk. The
# server only reads SCCACHE_CACHE_SIZE at start, so the install
# step below restarts it when the running cap differs.
SCCACHE_CACHE_SIZE: "50G"
# Activate the workspace's `coverage_nightly` cfg gate so the
# `#[cfg_attr(coverage_nightly, coverage(off))]` annotations
# (14× repo-wide, plus the platform-detection helpers in
# `node/src/r2_probe.rs`) actually take effect under
# `cargo llvm-cov`. cargo-llvm-cov does NOT auto-set this cfg —
# without it every `coverage(off)` in the workspace is inert
# and llvm-cov counts the excluded fns / lines as uncovered,
# which would drag the measured floor below its true value the
# moment the first annotation landed in the `node` crate. Set
# only on this job: the `lint-and-build` job runs stable
# 1.81.0 and would reject `feature(coverage_attribute)`, so the
# cfg has no effect there.
RUSTFLAGS: "--cfg coverage_nightly"
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Prepend ~/.cargo/bin to PATH (use rustup proxy, not Homebrew Rust)
run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
# The `testcontainers` workload runs in a dedicated, resource-
# capped Colima profile (`ci`) instead of the host's default
# Docker profile. Runner hosts may share their default profile
# with unrelated workloads; a separate profile guarantees the
# CI Postgres containers can never contend with (or be starved
# by) anything else on the host. The profile is created once per
# host (see the runner provisioning docs); this step only boots
# it if it is not already running. `--activate=false` keeps the
# host's global Docker context untouched — the job talks to the
# profile exclusively via the explicit DOCKER_HOST below.
- name: Ensure dedicated CI Docker profile is running
run: colima status ci >/dev/null 2>&1 || colima start ci --activate=false
# Point `testcontainers` at the `ci` profile's socket (see the
# `DOCKER_HOST` comment in the job env block above). Set in a
# step so the path resolves from `$HOME` at runtime instead of
# being hard-coded.
- name: Set DOCKER_HOST for CI Colima profile socket
run: echo "DOCKER_HOST=unix://$HOME/.colima/ci/docker.sock" >> "$GITHUB_ENV"
# `sccache` (compile cache) and `cargo-nextest` (test runner)
# are installed once per runner via Homebrew. Idempotent: no-op
# on a warm runner where both tools already exist. If a server is
# already running with a different cap than the requested
# SCCACHE_CACHE_SIZE, stop it so the next --start-server picks up
# the new env value; the on-disk cache files survive the restart.
- name: Ensure sccache + cargo-nextest + protoc are installed
run: |
command -v sccache >/dev/null || brew install sccache
command -v cargo-nextest >/dev/null || brew install cargo-nextest
# kernel-proto/build.rs invokes protoc; the self-hosted runner has
# no protobuf-compiler unless we install it (idempotent, like the
# tools above). The ubuntu lint job installs it via apt separately.
command -v protoc >/dev/null || brew install protobuf
protoc --version
if ! sccache --show-stats 2>/dev/null | grep -qE "Max cache size +50 GiB"; then
sccache --stop-server >/dev/null 2>&1 || true
fi
sccache --start-server >/dev/null 2>&1 || true
sccache --show-stats
# The full suite's `db_tests` use testcontainers to spin up a
# real Postgres 17 per test, so Docker (via Colima) must be
# reachable on PATH. Fail fast with a readable error if it ever
# goes away, instead of letting the suite die minutes into the
# run with a hard-to-read bollard error.
- name: Verify Docker is reachable (testcontainers dependency)
run: docker info > /dev/null
# `cargo llvm-cov nextest` is the nextest-aware coverage
# subcommand: collects llvm-cov data while driving the suite
# through nextest, so the measured coverage floor and the test
# execution share a single binary run. This is the merge of
# the previous `node-tests` + `coverage` pair — the previous
# `node-tests` job ran the same nextest invocation without the
# `cargo llvm-cov` wrapper, which produced no extra signal.
#
# Scope: `-p node -p shared --all-features`. Production modules
# (publisher, runtime, flow, job_dispatcher, scanners, shared)
# are measured. Legitimate ignores only: test infra + crate
# entrypoints + Plonky2 circuit packages. Floor integers and
# full justification: `.github/coverage-baseline.md`.
#
# The `api_remote` integration test (node/tests/api_remote.rs)
# is excluded: it targets the live DEV node and belongs in
# the post-deploy `api-e2e` job in deploy-dev.yaml, not the
# hermetic gate. The coverage scope is measured by the rest of
# the suite, which covers the in-process axum handlers via
# oneshot().
- name: Run llvm-cov nextest (measured coverage floor)
# `--test-threads=8` (issue #181 Opt A): the heavy gate is
# the largest wall consumer on M3 Ultra (~60-90 min at
# --test-threads=1). 8 outer threads × Rayon-pinned cores
# exploits the runner's 24 cores without over-subscribing —
# Plonky2 prove tests already saturate Rayon internally.
# Per-test schema isolation (#182) + cross-process file lock
# around the shared container (`test_db::init_shared_pg`)
# make the suite parallel-safe under llvm-cov.
#
# Floor: integer under measured totals (75.26% lines /
# 76.02% functions). See `.github/coverage-baseline.md`.
run: |
cargo llvm-cov nextest --release -p node -p shared --all-features --show-missing-lines \
--ignore-filename-regex '_tests\.rs$|test_db\.rs$|bin/.*\.rs$|main\.rs$|lib\.rs$|program-plonky2/|script-plonky2/' \
--fail-under-lines 75 \
--fail-under-functions 76 \
--test-threads 8 \
-E 'not binary(api_remote)'
# Task #9: release-mode prover package suite. These prove tests
# are multi-minute and never ran in any gate before; the M3 Ultra
# job is the right home (GitHub-hosted `lint-and-build` would
# time out). Local verification of a representative multi-input
# send prove in this package: ~290 s, green.
- name: Run zkcoins-prover-plonky2 release tests
run: cargo nextest run -p zkcoins-prover-plonky2 --release
# Task #9: four `#[ignore]` prove flows in node+shared that the
# default nextest (and the llvm-cov step above) never select.
# They exercise real sequential-history prove paths and a forged
# wrapper rejection — multi-minute, self-hosted only:
# - begin_receive_initial_proof_uses_sequential_history_roots
# - begin_send_multi_input_uses_sequential_history_roots
# - verify_incoming_rejects_forged_wrapper_proof_data_before_verify
# - prover_bridge_real_end_to_end
- name: Run ignored prove flows (node + shared)
run: >
cargo nextest run -p node -p shared --all-features --release
--run-ignored ignored-only
# §1.7.9 circuit-digest generator: builds real C / C_balance for every
# network and verifies the committed generated_circuit_digests.txt
# matches. Multi-minute; kept out of default cargo test via #[ignore],
# but a test that cannot run cannot fail — this heavy job is the one
# that actually runs it. Does NOT set REGEN_CIRCUIT_DIGESTS
# (verify-only; no file rewrite).
- name: Verify live circuit digests vs committed generated_circuit_digests.txt
run: |
cargo test -p zkcoins-prover-plonky2 --release --test generated_circuit_digests_test \
generate_circuit_digests -- --ignored --nocapture
# On gate failure, re-format the existing llvm-cov data (no
# re-run, no new test execution — `report` reads the on-disk
# profraw / profdata produced by the previous step) and emit
# the per-file "Uncovered Lines" block plus a json digest of
# files still below 100% line / function (gap list; the gate
# itself fails under the measured floor — see baseline).
# `--show-missing-lines` on the gate step sometimes elides this
# section depending on the llvm-cov build, so this step makes
# the detail deterministic: whenever the gate fails, the
# operator sees which file/line/function is uncovered without
# having to reproduce locally.
- name: Show missing coverage on gate failure
if: failure()
run: |
IGNORE='_tests\.rs$|test_db\.rs$|bin/.*\.rs$|main\.rs$|lib\.rs$|program-plonky2/|script-plonky2/'
echo "--- llvm-cov report: --show-missing-lines (text) ---"
cargo llvm-cov report --release --show-missing-lines \
--ignore-filename-regex "$IGNORE" || true
echo "--- llvm-cov report: per-file json (filter < 100%) ---"
cargo llvm-cov report --release --json \
--ignore-filename-regex "$IGNORE" \
| jq -r '.data[0].files[]
| select(.summary.lines.percent < 100 or .summary.functions.percent < 100)
| {filename, lines: .summary.lines, functions: .summary.functions}' \
|| echo "(jq not available or json parse failed)"
# Per-function coverage list: emits one line per uncovered
# function with file + name + line so the operator sees the
# exact `pub fn foo at router.rs:1234` without having to
# cross-reference the line ranges manually.
echo "--- llvm-cov report: uncovered functions (per-symbol) ---"
cargo llvm-cov report --release --json \
--ignore-filename-regex "$IGNORE" \
| jq -r '.data[0].functions[]
| select(.count == 0)
| "\(.filenames[0]):\(.regions[0][0])\t\(.name)"' \
| sort -u || echo "(per-function extraction failed)"
# Full HTML report — uploaded as an artifact below so the
# operator can browse the per-line coverage in a browser
# without re-running llvm-cov locally (heavy gate is
# ~50 min on M3 Ultra).
echo "--- llvm-cov report: generating HTML for artifact ---"
cargo llvm-cov report --release --html \
--output-dir target/llvm-cov-html \
--ignore-filename-regex "$IGNORE" \
|| echo "(HTML generation failed)"
- name: Upload coverage HTML report on gate failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: llvm-cov-html-${{ github.run_id }}-${{ github.run_attempt }}
path: target/llvm-cov-html
if-no-files-found: warn
retention-days: 14
# Tear down the shared test container created by
# `test_db::setup_pool` via testcontainers' `ReuseDirective::
# Always` (see `node/src/test_db.rs`). The reuse flag tells
# testcontainers NOT to drop the container at process exit so
# every `cargo nextest` test process can attach to the same
# daemon-side container — but that means nobody removes it
# either. Always-on cleanup so a stale container from one PR run
# cannot bleed into the next on the same self-hosted runner.
- name: Tear down shared test Postgres container
if: always()
run: docker rm -f zkcoins-test-shared-pg 2>/dev/null || true
- name: sccache stats (post-build)
if: always()
run: sccache --show-stats
# Telegram alert on workflow failure for the heavy gate. Modelled
# as a separate job (not an inline step) so job-level failures —
# timeout, OOM, runner crash — still fire the alert. `if: failure()`
# evaluates against the whole `needs:` group: any listed job
# transitioning to `failure` triggers it, while skipped jobs
# (all jobs on a draft PR) and manual cancellation stay silent.
notify-failure:
name: Telegram alert on failure
needs: [lint-and-build, test-and-coverage]
if: failure()
runs-on: ubuntu-latest
steps:
- name: Send Telegram alert
env:
TG_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TG_CHAT: ${{ secrets.TELEGRAM_CHAT_ID }}
run: |
TEXT=$'❌ <b>'"${{ github.workflow }}"$'</b> failed\n<b>Repo:</b> '"${{ github.repository }}"$'\n<b>Branch:</b> '"${{ github.ref_name }}"$'\n<b>Run:</b> '"${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
curl -sS -X POST "https://api.telegram.org/bot${TG_TOKEN}/sendMessage" \
--data-urlencode "chat_id=${TG_CHAT}" \
--data-urlencode "text=${TEXT}" \
-d "parse_mode=HTML" \
-d "disable_web_page_preview=true"