Repository navigation
fix(backends): scope each Redis operation to the calling tenant, not the first caller (LAB-4773) #1110
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CI | |
| on: | |
| push: | |
| branches: [ main, develop ] | |
| pull_request: | |
| branches: [ main, develop ] | |
| permissions: | |
| contents: read | |
| pull-requests: read | |
| checks: write | |
| env: | |
| CARGO_TERM_COLOR: always | |
| RUST_BACKTRACE: 1 | |
| REDIS_DISABLE_HIREDIS: true | |
| DEFAULT_PYTHON_VERSION: "3.12" | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: true | |
| jobs: | |
| # Lint, format, type check (runs on all events) | |
| quick-check: | |
| name: Format & Lint | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| steps: | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 | |
| - uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 | |
| with: | |
| version: "0.12.12" # pinned: uv runs as step 1 of every job; a floating release is code exec | |
| # `uv sync` builds the Rust extension via maturin into ./target, and clippy | |
| # below compiles the same workspace — cache the dependency artifacts and the | |
| # cargo registry across runs. | |
| - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 | |
| - name: Install dependencies | |
| run: uv sync --python ${{ env.DEFAULT_PYTHON_VERSION }} --group dev | |
| - name: Check Python formatting | |
| run: uv run ruff format --check . | |
| - name: Lint Python | |
| if: success() || failure() | |
| run: uv run ruff check . | |
| - name: Type check Python | |
| if: success() || failure() | |
| run: uv run basedpyright --level error | |
| - name: Check Rust formatting | |
| if: success() || failure() | |
| run: cd rust && cargo fmt --check | |
| # A leftover path dep or [patch.crates-io] redirect would make maturin build | |
| # release wheels from unpublished local source instead of the published crate, | |
| # producing a non-reproducible public wheel. Assert the POSITIVE invariant on | |
| # the lockfile: cachekit-core must resolve to the crates.io registry. A path or | |
| # patch dep has no `source` line, so this one check covers every Cargo.toml form | |
| # (inline table, [dependencies.cachekit-core], [patch.crates-io]) and the | |
| # workspace root — unlike a grep for `path =`, which only caught the inline form. | |
| - name: Guard cachekit-core resolves to crates.io | |
| if: success() || failure() | |
| run: | | |
| src=$(awk ' | |
| /^\[\[package\]\]/ { name=""; source="" } | |
| /^name = / { name=$3 } | |
| /^source = / { source=$0 } | |
| name == "\"cachekit-core\"" && source != "" { print source; exit } | |
| ' Cargo.lock) | |
| echo "cachekit-core resolved: ${src:-<no source line>}" | |
| if [ "$src" != 'source = "registry+https://github.com/rust-lang/crates.io-index"' ]; then | |
| echo "::error::cachekit-core must resolve to the crates.io registry, not a local path/patch dep" | |
| exit 1 | |
| fi | |
| # --locked: fail on a stale Cargo.lock so the build provably resolves | |
| # cachekit-core from crates.io as committed | |
| - name: Lint Rust | |
| if: success() || failure() | |
| run: cd rust && cargo clippy --locked -- -D warnings | |
| # PR: single Python version, critical tests only | |
| # Push: full matrix, full test suite | |
| test: | |
| name: Tests (Python ${{ matrix.python-version }}) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 | |
| permissions: | |
| contents: read | |
| id-token: write # Required for Codecov OIDC | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| python-version: ${{ github.event_name == 'push' && fromJSON('["3.10", "3.11", "3.12", "3.13", "3.14"]') || fromJSON('["3.12"]') }} | |
| steps: | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 | |
| - name: Start Redis (no persistence) | |
| run: | | |
| docker run -d --name redis -p 6379:6379 \ | |
| redis:7-alpine redis-server --save "" --appendonly no | |
| until docker exec redis redis-cli ping | grep -q PONG; do sleep 1; done | |
| - uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 | |
| with: | |
| version: "0.12.12" # pinned: uv runs as step 1 of every job; a floating release is code exec | |
| cache-suffix: py${{ matrix.python-version }} # setup-uv keys on lockfile hash only, not job/matrix | |
| # pyo3 is built per interpreter (no abi3), so key the Rust cache per matrix | |
| # leg — with one shared key the first leg to save wins and every other leg | |
| # restores foreign pyo3 artifacts and rebuilds anyway (cargo fingerprints | |
| # catch the mismatch), so the cache would thrash instead of hit. | |
| - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 | |
| with: | |
| key: py${{ matrix.python-version }} | |
| - name: Install dependencies | |
| run: uv sync --python ${{ matrix.python-version }} --group dev | |
| - name: Run tests (PRs) | |
| if: github.event_name == 'pull_request' | |
| env: | |
| REDIS_URL: redis://localhost:6379 | |
| run: | | |
| uv run pytest tests/unit/ -m "not slow" \ | |
| -n auto \ | |
| --cov=src/cachekit \ | |
| --cov-report=xml \ | |
| --cov-report=term \ | |
| --junitxml=junit-unit.xml \ | |
| -o junit_family=legacy | |
| # --cov-fail-under evaluates the *combined* unit+critical total (the | |
| # --cov-append accumulation), so it gates this final invocation only, and | |
| # reddens the job in pytest independent of the Codecov upload (rationale: | |
| # residual-risk note below). 82 = lowest same-repo PR cumulative TOTAL | |
| # across the PR matrix minus codecov.yml's declared 2% tolerance, floored; | |
| # keep it in step with the Makefile test-cov floor. | |
| uv run pytest tests/critical/ -m "not slow" \ | |
| --cov=src/cachekit \ | |
| --cov-append \ | |
| --cov-report=xml \ | |
| --cov-report=term \ | |
| --cov-fail-under=82 \ | |
| --junitxml=junit.xml \ | |
| -o junit_family=legacy | |
| - name: Run full test suite (main) | |
| if: github.event_name == 'push' | |
| env: | |
| REDIS_URL: redis://localhost:6379 | |
| run: | | |
| uv run pytest tests/unit/ -m "not slow" \ | |
| -n auto \ | |
| --cov=src/cachekit \ | |
| --cov-report=xml \ | |
| --cov-report=term \ | |
| --junitxml=junit-unit.xml \ | |
| -o junit_family=legacy | |
| uv run pytest tests/critical/ tests/integration/ -m "not slow" \ | |
| --ignore=tests/integration/saas \ | |
| --cov=src/cachekit \ | |
| --cov-append \ | |
| --cov-report=xml \ | |
| --cov-report=term \ | |
| --junitxml=junit.xml \ | |
| -o junit_family=legacy | |
| # Deterministic memory invariants (RSS/allocation bounds), not wall-clock benchmarks. These | |
| # are `performance and slow` — the mmap zero-copy read (#171) and the large-object allocation | |
| # caps (#152). Flaky timing/throughput benchmarks are `performance` WITHOUT `slow` and stay out | |
| # of CI. Version-independent, so run once on 3.12 (~5s) rather than across the push matrix. | |
| - name: Run memory-invariant tests | |
| if: matrix.python-version == '3.12' | |
| env: | |
| REDIS_URL: redis://localhost:6379 # autouse redis-isolation fixture uses external Redis when set (else spawns a binary the runner lacks) | |
| run: uv run pytest tests/performance/ -m "performance and slow" -q | |
| # The two uploads below are deliberately treated differently, not defaulted | |
| # (LAB-2528 finding 3). coverage.xml is the ONLY input to the | |
| # project/patch statuses codecov.yml declares, and every flag there sets | |
| # `carryforward: true` — so a silently-dropped upload does not remove the | |
| # status, it answers "is this PR's new code 80% covered?" with a previous | |
| # run's numbers. | |
| # | |
| # Scoped to same-repo events rather than a bare `true`, because of what the | |
| # action actually does on a fork (read at the pinned SHA, not assumed): its | |
| # `Get OIDC token` step is guarded `CC_USE_OIDC == 'true' && CC_FORK != 'true'`, | |
| # so on a fork it never attempts OIDC, `CC_TOKEN` stays empty, and the upload | |
| # proceeds TOKENLESS — Codecov's rate-limited path. A bare `true` would let a | |
| # 429 nobody controls redden an outside contribution, which on a repo with no | |
| # branch protection trains maintainers to merge over red CI: the opposite of | |
| # what this ticket is hardening. The flag therefore applies exactly where OIDC | |
| # really authenticates. | |
| # | |
| # RESIDUAL, now narrowed to the PATCH status: the TOTAL-coverage floor no | |
| # longer depends on the upload — the final PR pytest run enforces it with | |
| # `--cov-fail-under` (above), so a dropped or degraded upload can no longer | |
| # answer the total-coverage question with an earlier commit's carryforward; | |
| # the job reddens inside pytest first, on same-repo and fork PRs alike. What | |
| # Codecov still solely owns is the new-code PATCH status: it is diff-level, | |
| # stays `carryforward: true`, and is not enforced locally, so on a fork PR | |
| # whose tokenless upload is silently dropped it can still answer "is this | |
| # PR's new code 80% covered?" with a previous run's numbers. Accepted: that | |
| # status is a reporting signal, not a merge floor, and the local total floor | |
| # already catches a gross coverage regression on the same run. | |
| # The wrapper's CLI signature check is likewise | |
| # unenforced there (fail_ci_if_error is its switch; see the pin note below). | |
| # Accepted because a fork PR cannot reach the self-hosted `cachekit` runner | |
| # without a maintainer approving the run, no fork PR has run here to date, and | |
| # a tampered binary reaches same-repo runs first, where it fails closed. | |
| # | |
| # Do not re-pin below v7.0.0 (fb8b3582): releases published before Codecov's | |
| # June 2026 keybase migration fetch the CLI signing key from a deleted account, | |
| # so `gpg --verify` can never pass. The wrapper stops there only when | |
| # fail_ci_if_error is true; otherwise it prints "CLI integrity verified" and runs | |
| # the unverified binary. Accepted flip side: a keybase.io outage now fails | |
| # same-repo CI closed, not open. | |
| - name: Upload coverage to Codecov | |
| if: ${{ !cancelled() }} | |
| uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7 | |
| with: | |
| files: ./coverage.xml | |
| use_oidc: true | |
| fail_ci_if_error: ${{ github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository }} | |
| flags: ${{ github.event_name == 'push' && 'full' || 'critical' }}-python-${{ matrix.python-version }} | |
| # fail_ci_if_error is true here too, for a different reason: it is also the | |
| # wrapper's signature-enforcement switch (see the pin note above), so false | |
| # would let an unverified binary run. The "don't redden CI" intent lives in | |
| # continue-on-error instead: junit.xml feeds Codecov Test Analytics (flaky-test | |
| # history) only and nothing gates on it, so a Codecov-side outage marks this | |
| # step failed-and-continued and the job stays green. | |
| # That downgrade is visibility only: the wrapper exits before it chmods or runs | |
| # the binary, so a bad signature here still runs no downloaded code. Nor does it hide | |
| # an integrity failure on same-repo events: the coverage step above verifies the | |
| # same CLI, key and checksum hard-fail first, so a signature that cannot pass | |
| # reddens the job there before this step runs. | |
| # | |
| # `handle_no_reports_found` is left at its default (false) on both uploads on | |
| # purpose: it would also swallow "the report was never written", which is the | |
| # silent-degradation this ticket exists to remove. On coverage.xml a green job | |
| # that uploaded nothing is a trust bug; on junit.xml continue-on-error already | |
| # accepts a green job, and false there keeps the failed step visible as an | |
| # annotation instead of hiding it. | |
| - name: Upload test results to Codecov | |
| if: ${{ !cancelled() }} | |
| continue-on-error: true | |
| uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7 | |
| with: | |
| files: ./junit.xml | |
| report_type: test_results | |
| use_oidc: true | |
| flags: ${{ github.event_name == 'push' && 'full' || 'critical' }}-python-${{ matrix.python-version }} | |
| fail_ci_if_error: true | |
| # LAB-511: free-threaded CPython safety net. Runs the core suites on a | |
| # 3.14 free-threaded build and fails if the GIL gets re-enabled, so code | |
| # whose correctness silently depended on the GIL's memory ordering cannot | |
| # regress unnoticed. Optional native deps without free-threaded wheels | |
| # (orjson, numpy, pandas, pyarrow) are dev-group-only and stay out | |
| # (--group test); hiredis is skipped because it does not declare | |
| # free-threaded support — importing it re-enables the GIL — and redis-py | |
| # transparently falls back to its pure-Python parser. | |
| test-freethreaded: | |
| name: Tests (Python 3.14t, GIL disabled) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 | |
| steps: | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 | |
| - name: Start Redis (no persistence) | |
| run: | | |
| docker run -d --name redis -p 6379:6379 \ | |
| redis:7-alpine redis-server --save "" --appendonly no | |
| until docker exec redis redis-cli ping | grep -q PONG; do sleep 1; done | |
| - uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 | |
| with: | |
| version: "0.12.12" # pinned: uv runs as step 1 of every job; a floating release is code exec | |
| cache-suffix: py3.14t | |
| - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 | |
| - name: Install dependencies (test group only) | |
| run: uv sync --python 3.14t --no-default-groups --group test --no-install-package hiredis | |
| - name: Verify the GIL is actually disabled | |
| run: | | |
| uv run --no-sync python -c " | |
| import sys, sysconfig | |
| assert sysconfig.get_config_var('Py_GIL_DISABLED') == 1, 'not a free-threaded build' | |
| import cachekit | |
| import cachekit._rust_serializer | |
| import redis | |
| assert not sys._is_gil_enabled(), 'an import re-enabled the GIL' | |
| print('free-threaded build, GIL still disabled after imports') | |
| " | |
| - name: Run unit + critical suites (GIL-free) | |
| env: | |
| REDIS_URL: redis://localhost:6379 | |
| run: | | |
| uv run --no-sync pytest tests/unit/ -m "not slow" -n auto | |
| uv run --no-sync pytest tests/critical/ -m "not slow" | |
| # Version sync + doc tests (push to main only) | |
| post-merge: | |
| name: Post-Merge Checks | |
| if: github.event_name == 'push' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| steps: | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 | |
| - uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 | |
| with: | |
| version: "0.12.12" # pinned: uv runs as step 1 of every job; a floating release is code exec | |
| # `uv sync` below builds the Rust extension via maturin — cache its dependencies. | |
| - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 | |
| - name: Check Python ↔ Rust version consistency | |
| run: | | |
| PYTHON_VERSION=$(grep -E "^version = " pyproject.toml | head -1 | cut -d'"' -f2) | |
| RUST_VERSION=$(grep -E "^version = " rust/Cargo.toml | head -1 | cut -d'"' -f2) | |
| if [ "$PYTHON_VERSION" != "$RUST_VERSION" ]; then | |
| echo "Version mismatch: Python=$PYTHON_VERSION Rust=$RUST_VERSION" | |
| exit 1 | |
| fi | |
| - name: Install dependencies | |
| run: uv sync --python ${{ env.DEFAULT_PYTHON_VERSION }} --group dev | |
| - name: Scan Python dependencies for CVEs | |
| run: | | |
| # No suppressions: every prior CVE is resolved at source on the py3.10+ | |
| # resolution. urllib3>=2.7.0 and pip>=26.2 are pinned via | |
| # [tool.uv] constraint-dependencies; pygments/pyarrow advisories cleared | |
| # by their py3.10+ fix versions. Keep this list IDENTICAL to | |
| # security-fast.yml's pip-audit so the two cannot drift. | |
| uv run pip-audit --desc | |
| - name: Run markdown documentation tests | |
| run: make test-docs-examples | |
| # LAB-3500: this repo is public and forkable, so every job runs on | |
| # GitHub-hosted runners, never on self-hosted ones. Allow-list, not | |
| # deny-list: a deny-list fails open on a new label, `CacheKit` casing, | |
| # `${{ vars.X }}` indirection or the mapping form `runs-on: {group:, labels:}`. | |
| # The program is the heredoc below; its regression fixtures are | |
| # .github/scripts/runner-drift-guard-selftest.sh, run first so a broken | |
| # guard cannot pass anything. This is drift protection for maintainers, NOT | |
| # a fork-PR control: a fork PR runs the fork's copy of this file and can | |
| # simply delete the guard; the control for that lives in repository and org | |
| # runner settings, outside this file. | |
| runner-drift-guard: | |
| name: Runner Drift Guard | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 2 | |
| steps: | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 | |
| # The program stays in this workflow file on purpose: GITHUB_TOKEN cannot | |
| # write .github/workflows/, so a `contents: write` job (release-please) or | |
| # a compromised action could rewrite a script under .github/scripts/ but | |
| # not this heredoc. It is written to a temp file so the self-test and the | |
| # scan run the same bytes. | |
| - name: Write the guard program | |
| run: | | |
| cat > "$RUNNER_TEMP/runner-drift-guard.awk" <<'AWK' | |
| # Allow-list, fail closed: every runs-on value, and every os value | |
| # inside a matrix block, must be one of the exact hosted labels in | |
| # HOSTED or the matrix.os expression. This is a line scanner, not a | |
| # YAML parser: a line that could hide a runner label from it is an | |
| # error, never a skip. Exact labels, not a hosted-prefix pattern (a | |
| # prefix admits any self-hosted label named ubuntu-<anything>) and not | |
| # a copy of GitHub's image table (it rots): adopting another hosted | |
| # image is a deliberate edit to HOSTED. Out of scope, deliberately: | |
| # YAML obfuscation (explicit `?` keys, escape sequences, tags on keys) | |
| # is a deliberate act, not drift; the control for that lives in | |
| # repository and org runner settings, outside this file. | |
| BEGIN { HOSTED = "^(ubuntu|macos|windows)-latest$" } | |
| FNR == 1 { matrix_indent = -1 } | |
| { sub(/#.*/, ""); $0 = tolower($0) } | |
| /^[[:space:]]*$/ { next } | |
| # Track the matrix block by indentation: a bare os: is validated only | |
| # there, because the same key names a workflow_dispatch input or an | |
| # action with: parameter, which select no runner. The tracker arms | |
| # only on a bare `matrix:` line — anything else on that line (an | |
| # expression, anchor, alias, tag or flow map) leaves the block unread, | |
| # so the rule below rejects the line instead of guessing what is on it. | |
| { | |
| match($0, /^[[:space:]]*(-[[:space:]]*)?/) | |
| indent = RLENGTH | |
| if (matrix_indent >= 0 && indent <= matrix_indent) matrix_indent = -1 | |
| } | |
| /^[[:space:]]*(-[[:space:]]*)?["']?matrix["']?[[:space:]]*:[[:space:]]*$/ { matrix_indent = indent } | |
| # Lines that may hide a runner label: a flow mapping carrying a | |
| # runner/strategy/matrix key; strategy: or matrix: with any inline | |
| # value, include: with one inside a matrix; a YAML merge key or a bare | |
| # alias item; a job calling a reusable workflow in another repo (its | |
| # runs-on is outside this scan; ./.github/workflows/ callees are | |
| # scanned). Accepted false positives, all fail-closed: an env/with key | |
| # named matrix or strategy with an inline value; a flow map with an | |
| # os key. | |
| /[{,][[:space:]]*["']?(runs-on|os|strategy|matrix|include)["']?[[:space:]]*:/ || | |
| /^[[:space:]]*(-[[:space:]]*)?["']?(strategy|matrix)["']?[[:space:]]*:[[:space:]]*[^[:space:]]/ || | |
| (/^[[:space:]]*(-[[:space:]]*)?["']?include["']?[[:space:]]*:[[:space:]]*[^[:space:]]/ && matrix_indent >= 0) || | |
| /^[[:space:]]*(-[[:space:]]*)?(<<[[:space:]]*:|\*[^*[:space:]])/ || | |
| (/^[[:space:]]*uses[[:space:]]*:/ && /\/\.github\/workflows\// && !/uses[[:space:]]*:[[:space:]]*["']?\.\//) { | |
| printf "::error file=%s,line=%d::line may hide a runner label from the drift guard — write `runs-on: <label>` / `os: [<labels>]` inline; keep `strategy:`, `matrix:`, `include:` as bare keys over a static block (no expressions, anchors, aliases or flow maps); call reusable workflows only from ./.github/workflows/ (LAB-3500)\n", FILENAME, FNR | |
| bad = 1 | |
| } | |
| /^[[:space:]]*(-[[:space:]]*)?["']?runs-on["']?[[:space:]]*:/ || | |
| (/^[[:space:]]*(-[[:space:]]*)?["']?os["']?[[:space:]]*:/ && matrix_indent >= 0) { | |
| v = $0; sub(/^[^:]*:[[:space:]]*/, "", v); sub(/[[:space:]]+$/, "", v) | |
| if (v ~ /^["']?\$\{\{[[:space:]]*matrix\.os[[:space:]]*\}\}["']?$/) next | |
| if (v ~ /^\[/ && v !~ /\]$/) v = "" | |
| gsub(/[][ "']/, "", v) | |
| n = split(v, items, ",") | |
| ok = (v != "") | |
| for (i = 1; i <= n; i++) if (items[i] !~ HOSTED) ok = 0 | |
| if (!ok) { | |
| printf "::error file=%s,line=%d::runner value \"%s\" is not an allow-listed GitHub-hosted label — this public repo runs only on GitHub-hosted runners (LAB-3500); adopting a new hosted image means extending HOSTED in the runner-drift-guard job of ci.yml\n", FILENAME, FNR, (v == "" ? "<value not inline on this line>" : v) | |
| bad = 1 | |
| } | |
| } | |
| END { exit bad } | |
| AWK | |
| - name: Guard self-test (regression fixtures) | |
| run: bash .github/scripts/runner-drift-guard-selftest.sh "$RUNNER_TEMP/runner-drift-guard.awk" | |
| # One awk pass, no shell pipeline: a program error exits non-zero and fails | |
| # the step (fail closed) instead of reading as "no match". | |
| - name: Every runs-on / matrix os value is an allow-listed GitHub-hosted label | |
| run: awk -f "$RUNNER_TEMP/runner-drift-guard.awk" .github/workflows/*.y*ml | |
| # Summary job (required for branch protection) | |
| ci-success: | |
| name: CI Success | |
| runs-on: ubuntu-latest | |
| needs: [quick-check, test, test-freethreaded, runner-drift-guard] | |
| if: always() | |
| steps: | |
| - name: Check all jobs succeeded | |
| run: | | |
| if [[ "${{ needs.quick-check.result }}" != "success" ]] || \ | |
| [[ "${{ needs.test.result }}" != "success" ]] || \ | |
| [[ "${{ needs.test-freethreaded.result }}" != "success" ]] || \ | |
| [[ "${{ needs.runner-drift-guard.result }}" != "success" ]]; then | |
| echo "One or more jobs failed" | |
| exit 1 | |
| fi | |
| echo "All CI checks passed" |