Repository navigation
engine: darwin-arm64 solves in parallel — the residual crash was unfenced node publication #635
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
| # ───────────────────────────────────────────────────────────────────────────── | |
| # CI — the gate every change to main passes through. | |
| # | |
| # Four tiers, cheapest first, all required: | |
| # build the parser and the engine driver compile and typecheck | |
| # hygiene repo invariants that need no solver and catch silent breakage | |
| # engine the java / typescript / python / javascript / csharp regression suites, in | |
| # parallel, each compiling its own engine from the rules | |
| # engines every language's engine builds on every platform (the reusable | |
| # build-engines workflow — the same artifacts publish-npm ships) | |
| # | |
| # NO WORKFLOW-LEVEL PATH FILTERS, deliberately. A required check that is skipped | |
| # by a path filter never reports, and a pull request waiting on a check that will | |
| # never report can never merge. Instead the workflow always starts, the `changes` | |
| # job classifies the diff, and the expensive jobs skip THEMSELVES: a docs-only pull | |
| # request runs build and hygiene (which carries the version gate) and nothing else, | |
| # and the platform engine build runs only for main, and only when what it compiles | |
| # changed. The `CI` job reports either way. | |
| # | |
| # dev is the default branch: every pull request lands there and gets build, hygiene | |
| # and the five suites. The every-platform engine build is the slow part and no test | |
| # uses its output, so it runs on the way INTO main (a promotion pull request, a push | |
| # to main) and in the nightly, not on every change to dev. | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| name: CI | |
| on: | |
| push: | |
| # dev takes direct pushes, so they get a result too. A release branch (0.1.6, ...) | |
| # takes merges from pull requests: the run on the merge is what saves the compiled | |
| # engines where the NEXT pull request into it can restore them — a cache a pull | |
| # request saves is visible to that pull request alone. | |
| branches: [main, dev, '0.*'] | |
| pull_request: | |
| merge_group: | |
| workflow_dispatch: | |
| # nightly.yml calls this with fresh=true: no restored engine cache, so every engine | |
| # is compiled from the rules as they are, and the platform build always runs. | |
| workflow_call: | |
| inputs: | |
| fresh: | |
| type: boolean | |
| default: false | |
| # One run per ref. A new push to a pull request cancels the previous run, but a | |
| # run on main is always allowed to finish — main's history is the record. | |
| concurrency: | |
| group: ci-${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: ${{ github.event_name == 'pull_request' }} | |
| permissions: | |
| contents: read | |
| env: | |
| # bin/axiomcode and the parser need Node ≥ 22.5. | |
| NODE_VERSION: '22' | |
| SOUFFLE_VERSION: '2.5' | |
| SOUFFLE_SHA512: '6b86e554f6aa5abf8a8b55d8312ae37c0957c5bd6c9edeea89246db9406f645ec5e600b84fe6636b1c163da556f0da6c3d2dad46c1083413f2fcf4f95b9ac62c' | |
| jobs: | |
| changes: | |
| name: what changed | |
| runs-on: ubuntu-24.04 | |
| timeout-minutes: 5 | |
| outputs: | |
| code: ${{ steps.c.outputs.code }} | |
| engines: ${{ steps.c.outputs.engines }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - id: c | |
| env: | |
| BASE: ${{ github.event.pull_request.base.sha }} | |
| run: | | |
| set -euo pipefail | |
| if [ "${{ inputs.fresh }}" = true ]; then | |
| echo "code=true" >> "$GITHUB_OUTPUT"; echo "engines=true" >> "$GITHUB_OUTPUT" | |
| echo "nightly: everything runs"; exit 0 | |
| fi | |
| if [ "${{ github.event_name }}" = push ] && [ "${{ github.ref }}" = refs/heads/dev ]; then | |
| echo "code=true" >> "$GITHUB_OUTPUT"; echo "engines=false" >> "$GITHUB_OUTPUT" | |
| echo "push to dev: suites run, platform engines wait for main"; exit 0 | |
| fi | |
| if [ "${{ github.event_name }}" = push ] && [[ "${{ github.ref }}" == refs/heads/0.* ]]; then | |
| echo "code=true" >> "$GITHUB_OUTPUT"; echo "engines=false" >> "$GITHUB_OUTPUT" | |
| echo "push to a release branch: suites run (and save its engines for pull requests into it), platform engines wait for main"; exit 0 | |
| fi | |
| # A push to main is a release when its version has no tag yet: that commit, and only that one, | |
| # builds every platform and runs the five-platform e2e, and release.yml drafts from it once | |
| # it is green. A push whose version is already tagged released nothing new and builds nothing. | |
| if [ "${{ github.event_name }}" = push ] && [ "${{ github.ref }}" = refs/heads/main ]; then | |
| v="$(node .github/scripts/version.mjs get)" | |
| if git ls-remote --exit-code --tags origin "refs/tags/v$v" >/dev/null; then | |
| echo "code=true" >> "$GITHUB_OUTPUT"; echo "engines=false" >> "$GITHUB_OUTPUT" | |
| echo "push to main at v$v, already tagged: suites run, nothing to release" | |
| else | |
| echo "code=true" >> "$GITHUB_OUTPUT"; echo "engines=true" >> "$GITHUB_OUTPUT" | |
| echo "push to main at v$v, not yet tagged: the release build runs on every platform" | |
| fi | |
| exit 0 | |
| fi | |
| if [ "${{ github.event_name }}" != pull_request ]; then | |
| echo "code=true" >> "$GITHUB_OUTPUT"; echo "engines=true" >> "$GITHUB_OUTPUT" | |
| echo "${{ github.event_name }} on ${{ github.ref }}: everything runs"; exit 0 | |
| fi | |
| files="$(git diff --name-only "$BASE"...HEAD)" | |
| printf '%s\n' "$files" | sed 's/^/ /' | |
| # DOCS: prose nothing executes. Markdown under graph/ or parser/ is NOT | |
| # docs: graph/bundle/SCHEMA.md is generated, and a suite checks it is current. | |
| code="$(printf '%s\n' "$files" | grep -vE \ | |
| -e '^$' \ | |
| -e '^(graph|parser)/' -e '^[^/]+\.md$' -e '^docs/' -e '^paper/' \ | |
| -e '^\.github/(ISSUE_TEMPLATE/|pull_request_template\.md$|CODEOWNERS$|RELEASING\.md$)' \ | |
| -e '^LICENSE' -e '\.(png|jpe?g|gif|svg)$' || true)" | |
| # graph/ and parser/ are code even when the file is markdown | |
| code="$code$(printf '%s\n' "$files" | grep -E '^(graph|parser)/' || true)" | |
| # ENGINES: what the platform build compiles or packages. | |
| engines="$(printf '%s\n' "$files" | grep -E \ | |
| -e '\.dl$' -e '^graph/pipeline/' -e '^packaging/' -e 'scripts/dl_program\.py$' -e '^\.github/scripts/query-smoke\.sh$' \ | |
| -e '^\.github/workflows/build-engines\.yml$' -e '^package\.json$' -e '^\.github/scripts/e2e-' -e '^\.github/e2e/' || true)" | |
| [ -n "$code" ] && echo "code=true" >> "$GITHUB_OUTPUT" || echo "code=false" >> "$GITHUB_OUTPUT" | |
| # No pull request builds the platform engines: a release builds them once, on the push that | |
| # lands it on main, and publishes exactly that build (#1350). | |
| engines="" | |
| [ -n "$engines" ] && echo "engines=true" >> "$GITHUB_OUTPUT" || echo "engines=false" >> "$GITHUB_OUTPUT" | |
| echo "suites: $([ -n "$code" ] && echo run || echo skip) platform engines: $([ -n "$engines" ] && echo run || echo skip)" | |
| build: | |
| name: build & typecheck | |
| runs-on: ubuntu-24.04 | |
| timeout-minutes: 15 | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: actions/setup-node@v4 | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| # No lock file is committed (#476), so this is `npm install`, not `npm ci`, | |
| # and setup-node's npm cache (keyed on a lock file) is not used. The install | |
| # runs the `prepare` script, which builds the parser workspace and the | |
| # driver. Keeping the explicit build step anyway means a prepare-script | |
| # change cannot silently stop compiling this repo. | |
| - run: npm install | |
| - run: npm run typecheck | |
| - run: npm run build | |
| - name: the parser actually built | |
| run: | | |
| test -f parser/dist/index.js \ | |
| || { echo "::error::parser/dist/index.js is missing after build"; exit 1; } | |
| hygiene: | |
| name: repo invariants | |
| runs-on: ubuntu-24.04 | |
| timeout-minutes: 5 | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 # the version gate needs the merge base with the target branch | |
| # A fixture input that .gitignore matches passes on the machine that wrote | |
| # it and fails on every clone. The suites run this too; running it here as | |
| # well means the answer arrives in seconds rather than after the engine. | |
| - name: every fixture input is tracked by git | |
| run: bash graph/test/tools/no-ignored-fixtures.sh | |
| # A relation staged for the client but not for libraries is EMPTY on every | |
| # run and nothing errors — no golden can see it. This is the only check | |
| # that can. | |
| - name: IR staging maps are consistent | |
| run: | | |
| fail=0 | |
| for lang in java typescript python javascript csharp; do | |
| echo "── $lang" | |
| python3 graph/test/tools/check_staging.py --lang "$lang" || fail=1 | |
| done | |
| exit $fail | |
| # The engine's base declarations are a COPY of the parser's generated | |
| # schema (graph/<lang>/souffle/decls_base.dl ← parser/src/schema/<lang>/). | |
| # A column appended on the parser side and not here is an arity error at | |
| # solve time in every suite at once, with the cause two directories away. | |
| # Compared on the `.decl` lines only: the copies carry their own preambles. | |
| - name: engine declarations match the parser schema | |
| run: | | |
| fail=0 | |
| for pair in typescript:decls_base_ts.dl python:decls_base_py.dl javascript:decls_base_js.dl csharp:decls_base_cs.dl; do | |
| lang="${pair%%:*}"; file="${pair#*:}" | |
| if ! diff <(grep '^\.decl' "parser/src/schema/$lang/$file") \ | |
| <(grep '^\.decl' "graph/$lang/souffle/decls_base.dl"); then | |
| echo "::error::graph/$lang/souffle/decls_base.dl has drifted from parser/src/schema/$lang/$file" | |
| fail=1 | |
| fi | |
| done | |
| exit $fail | |
| # The client->library half is carried by a smaller set of fixtures than the | |
| # client->client half, and it is the half that disappears silently: delete a | |
| # golden and the case still runs, still passes, and simply stops claiming | |
| # anything. A suite can only check the assertions it still has. | |
| - name: client->library coverage has not shrunk | |
| run: bash graph/test/tools/lib-coverage.sh | |
| - name: shell scripts parse | |
| run: | | |
| fail=0 | |
| while IFS= read -r f; do | |
| bash -n "$f" || { echo "::error file=$f::does not parse"; fail=1; } | |
| done < <(git ls-files '*.sh') | |
| exit $fail | |
| # npm will not republish a version, so anything inside the tarball reaches | |
| # nobody unless the version moves — README.md included, since `files` names | |
| # it. The inverse is the error worth avoiding too: a bump demanded for a CI | |
| # tweak or a test fixture teaches people to bump without asking why, and a | |
| # version that moves for reasons users cannot observe stops meaning | |
| # anything. The gate reads package.json's own `files` declaration to tell | |
| # the two apart, and prints the files that decided it. | |
| - uses: actions/setup-node@v4 | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| # package.json's version is repeated in the engine pins, the parser and every | |
| # plugin manifest. Checked on every event, not only pull requests, so main | |
| # can never hold a tree whose manifests disagree about what it is. | |
| - name: every manifest carries the same version | |
| run: node .github/scripts/version.mjs check | |
| - name: a change that reaches a user has a version | |
| if: github.event_name == 'pull_request' | |
| run: bash .github/scripts/version-gate.sh "origin/${{ github.base_ref }}" | |
| # A push to main builds and releases only when its version is new (#1350). The gate above already | |
| # refuses a pull request that changes what users get without a new version; a CI or docs change | |
| # may keep main's version and then builds nothing when it lands. What is left to refuse here is a | |
| # new version that was already released: its tag exists, so the push would build nothing and the | |
| # change would never ship. | |
| - name: a pull request into main does not reuse a released version | |
| if: github.event_name == 'pull_request' && github.base_ref == 'main' | |
| run: | | |
| set -euo pipefail | |
| head="$(node .github/scripts/version.mjs get)" | |
| base="$(git show origin/main:package.json | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>console.log(JSON.parse(s).version))')" | |
| if [ "$head" = "$base" ]; then | |
| echo "main stays at $base: nothing in this change is released, and landing it builds nothing"; exit 0 | |
| fi | |
| if git ls-remote --exit-code --tags origin "refs/tags/v$head" >/dev/null; then | |
| echo "::error::v$head is already released — pick the next version"; exit 1 | |
| fi | |
| echo "main $base -> $head: landing this builds every platform and drafts v$head" | |
| engine: | |
| name: engine (${{ matrix.lang }}) | |
| needs: [changes] | |
| if: needs.changes.outputs.code == 'true' | |
| runs-on: ubuntu-24.04 | |
| timeout-minutes: 90 | |
| env: | |
| # the engine is compiled for any x86-64 runner, so a cached one can be restored | |
| # on whichever runner this job lands (see the engine cache step below) | |
| AXIOM_ENGINE_MARCH: portable | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| # --oracle scores the engine against GROUND TRUTH, not only against the | |
| # goldens. A golden says "the same as last time", which a wrong answer | |
| # satisfies perfectly well as long as it was wrong last time too. | |
| # | |
| # The ground truth is built with the toolchain that defines the language: | |
| # javac and javap for Java, the TypeScript compiler for TypeScript and — | |
| # over allowJs/checkJs — for JavaScript. No third-party analyzer, and no | |
| # third-party library is downloaded to do it. A case whose ground truth would need an external | |
| # classpath reports itself unscored rather than pulling one in. | |
| # | |
| # --no-torture for java ONLY. Those families call java.util.List, Map and | |
| # the functional interfaces, so they need the JVM platform IR staged as a | |
| # library. That IR is 1.8 GB and is built from a JDK source checkout, so it | |
| # cannot live in a repository or a cache. Without it those receivers are | |
| # unresolvable BY CONSTRUCTION — the census goes from 10 missing edges to | |
| # 23 and recall to 0.847 — which measures the staging, not the rules, and | |
| # the resulting red would read as a regression in whatever PR met it. | |
| # | |
| # Java client->library resolution is still covered here: six cases ship | |
| # their own stub library in lib-src/ and are solved with it as --library. | |
| # The torture families remain a local gate until the platform IR can be | |
| # produced reproducibly; the suite prints EXCLUDED so it is never mistaken | |
| # for a family that passed. | |
| - lang: java | |
| oracle: '--oracle --no-torture' | |
| - lang: typescript | |
| oracle: '--oracle' | |
| # Python's ground truth is frozen CPython output, authored by a separate | |
| # harness checkout ($AXIOM_PY_ORACLE) that CI cannot reach yet, so this leg | |
| # is goldens-only for now. That separation is deliberate — | |
| # graph/test/python/run-tests.sh explains why the ability to re-bless | |
| # ground truth must not sit beside the code under test — but it does mean | |
| # the python leg is a weaker check than the other three until the harness | |
| # is reachable from here. | |
| - lang: python | |
| oracle: '' | |
| # JavaScript: 19 cases; the library case ships its dependency under | |
| # src/node_modules and is solved twice. The execution oracles (torture/, | |
| # realapp/) are separate harnesses and stay a local gate — realapp needs | |
| # network for its own npm install. | |
| - lang: javascript | |
| oracle: '--oracle' | |
| # C# has no goldens: every case is scored against the Roslyn oracle | |
| # (graph/test/csharp/ground-truth), which the step below builds with the | |
| # .NET 8 SDK. No flag — scoring against the compiler is all it does. | |
| - lang: csharp | |
| oracle: '' | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: actions/setup-node@v4 | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| # Builds the in-repo parser (parser/dist) through the prepare script, and | |
| # supplies the TypeScript compiler the typescript and javascript oracles run. | |
| # `npm install`, not `npm ci`: no lock file is committed (#476). | |
| - run: npm install | |
| - name: the parser actually built | |
| run: | | |
| test -f parser/dist/index.js \ | |
| || { echo "::error::parser/dist/index.js is missing after npm install"; exit 1; } | |
| # TWO interpreters, because the python suite needs two different things and | |
| # they cannot be the same version. | |
| # | |
| # 3.10 — the tier-1 attribution preflight reads CPython OPCODES, whose | |
| # shapes are not stable across minor versions. It resolves | |
| # `python3.10` by name, so this only has to exist on PATH. | |
| # 3.12 — the torture fixtures are SOURCE that has to import: one of them | |
| # uses `typing.Self`, which is 3.11+ (PEP 673), so on 3.10 the | |
| # tracer dies at import and the family scores nothing. | |
| # | |
| # The later setup-python wins for plain `python3`, so 3.12 must come second. | |
| - uses: actions/setup-python@v5 | |
| with: | |
| python-version: '3.10' | |
| - uses: actions/setup-python@v5 | |
| with: | |
| python-version: '3.12' | |
| - name: both interpreters are on PATH | |
| run: | | |
| set -euo pipefail | |
| echo "python3 -> $(python3 --version)" | |
| echo "python3.10 -> $(python3.10 --version)" | |
| # JDK 24, not the runner's default. The java torture harness reads class files | |
| # with java.lang.classfile, which is not final before 24 — on an older JDK it | |
| # exits 77 and the whole ten-family oracle silently does not run. | |
| - uses: actions/setup-java@v4 | |
| if: matrix.lang == 'java' | |
| with: | |
| distribution: temurin | |
| java-version: '24' | |
| - uses: actions/setup-dotnet@v4 | |
| if: matrix.lang == 'csharp' | |
| with: | |
| dotnet-version: '8.0.x' | |
| # Without the oracle binary the suite exits 77, which run-suite.sh turns into | |
| # a failure — so a missing build is loud, never a silent skip. | |
| - name: build the Roslyn oracle | |
| if: matrix.lang == 'csharp' | |
| run: dotnet build -c Release graph/test/csharp/ground-truth/AxiomCsOracle | |
| - name: cache the Soufflé package | |
| uses: actions/cache@v4 | |
| with: | |
| path: ~/souffle-pkg | |
| key: souffle-deb-${{ env.SOUFFLE_VERSION }}-ubuntu-2404 | |
| # Soufflé is the solver, not a library under test: the engine is compiled | |
| # from .dl to C++ and linked against Soufflé's headers, so a build of it has | |
| # to be present the way a compiler has to be present. | |
| # | |
| # It is pinned to an exact version AND verified against the checksum upstream | |
| # published for that release, so what CI links against is decided in this | |
| # file rather than by whatever the archive happens to serve today. The pin | |
| # the driver reads is graph/pipeline/engine.conf; this must agree with it. | |
| - name: install Soufflé ${{ env.SOUFFLE_VERSION }} | |
| run: | | |
| set -euo pipefail | |
| deb="x86_64-ubuntu-2404-souffle-${SOUFFLE_VERSION}-Linux.deb" | |
| dir="$HOME/souffle-pkg"; mkdir -p "$dir" | |
| if [ ! -f "$dir/$deb" ]; then | |
| curl -fsSL --retry 3 -o "$dir/$deb" \ | |
| "https://github.com/souffle-lang/souffle/releases/download/${SOUFFLE_VERSION}/${deb}" | |
| fi | |
| echo "${SOUFFLE_SHA512} ${dir}/${deb}" | sha512sum -c - | |
| sudo apt-get update -qq | |
| sudo apt-get install -y --no-install-recommends "$dir/$deb" | |
| souffle --version | head -2 | |
| # ── the compiled engine ──────────────────────────────────────────────── | |
| # run-souffle.sh caches the compiled solver under a content hash of the | |
| # .dl program text. Mirroring that key here skips a multi-minute C++ build | |
| # on every run whose rules did not change. | |
| # Keyed by this language's ENGINE_ID — the hash the driver itself names its | |
| # compiled binary by (its rules and the Soufflé version) — so a rule change in | |
| # one language recompiles that language only, and an unchanged one never does. | |
| # Two things decide the binary that ENGINE_ID does not cover, so they are in the | |
| # key too: the driver that compiles it (run-souffle.sh holds the compiler flags), | |
| # and the target it is compiled for. | |
| # | |
| # THE TARGET IS PORTABLE, NOT THE RUNNER'S CPU. The driver's default is | |
| # -march=native, and a binary built on one hosted runner died with SIGILL on | |
| # another (runners that report the same model name do not all expose the same | |
| # instruction set). The key used to carry a hash of the runner's CPU flags to | |
| # keep such a binary off other CPUs — and so it missed on almost every run, | |
| # since which CPU a job lands on is luck: a three-minute compile per language, | |
| # per run, for rules nothing had changed. AXIOM_ENGINE_MARCH=portable compiles | |
| # for the compiler's baseline x86-64 target instead, as the published engines | |
| # are, so any runner can run any runner's binary and the CPU leaves the key. | |
| # | |
| # A cache saved by a pull request is visible to that pull request only. The | |
| # ones every pull request can restore are the base branch's and the default | |
| # branch's, which is why a push to a release branch runs the suites too (see | |
| # `on.push` and the `changes` job): it leaves the engine for its rules where | |
| # every pull request into that branch finds it. | |
| - name: this language's engine id | |
| id: eid | |
| run: | | |
| echo "id=$(bash graph/pipeline/run-souffle.sh --language ${{ matrix.lang }} --print-engine-id)" >> "$GITHUB_OUTPUT" | |
| - name: restore the compiled Soufflé engine | |
| id: engine-cache | |
| if: ${{ !inputs.fresh }} | |
| uses: actions/cache/restore@v4 | |
| with: | |
| path: .souffle-cache | |
| key: souffle-engine-${{ matrix.lang }}-${{ steps.eid.outputs.id }}-${{ hashFiles('graph/pipeline/run-souffle.sh') }}-march-${{ env.AXIOM_ENGINE_MARCH }} | |
| - name: ${{ matrix.lang }} regression suite | |
| env: | |
| AXIOM_PARSER: ${{ github.workspace }}/parser/dist/index.js | |
| # The driver's default cache is ~/.cache/axiomcode/souffle; point it at the | |
| # directory the cache step above saves and restores, or it never hits. | |
| AXIOM_SOUFFLE_CACHE: ${{ github.workspace }}/.souffle-cache | |
| # The cases run concurrently, up to one per CPU (graph/test/tools/case-pool.sh); | |
| # AXIOM_SUITE_JOBS=1 here would run them one at a time, as they used to. | |
| run: bash .github/scripts/run-suite.sh ${{ matrix.lang }} ${{ matrix.oracle }} | |
| # THE QUERY LAYER'S CASES (tests/run.py): what impact, path, context, changed and test-impact answer on small | |
| # projects written for one behaviour each. Every case must pass, and a case marked pending that now passes fails | |
| # the run until the mark is removed, so a fix for one shape cannot quietly break another's answer. The suite | |
| # calls the verbs directly, so it checks their own answers, not the front-door rendering (tests/front_door.py). | |
| - name: ${{ matrix.lang }} query cases | |
| env: | |
| AXIOM_PARSER: ${{ github.workspace }}/parser/dist/index.js | |
| AXIOM_SOUFFLE_CACHE: ${{ github.workspace }}/.souffle-cache | |
| run: python3 tests/run.py --lang ${{ matrix.lang }} --jobs 4 | |
| # the small surface as users and agents get it: find / impact / path / tests through the installed command and | |
| # the MCP server, answered as places with their code, and the direct calls and flags that keep the old answers | |
| - name: the front door answers as places with their code | |
| if: matrix.lang == 'python' | |
| env: | |
| AXIOM_PARSER: ${{ github.workspace }}/parser/dist/index.js | |
| AXIOM_SOUFFLE_CACHE: ${{ github.workspace }}/.souffle-cache | |
| run: python3 tests/front_door.py | |
| # The hooks answer impact from SQL (graph_sql.impact_shaped) and fall back to the rules (dl/impact.dl) only when | |
| # it declines, so the two must list the same rows for the same edit: tests/fastpath.py indexes a small case, asks | |
| # both on each target shape, and runs hooks/changes.py on one edit through each path. Same parser and engine | |
| # cache as the suite above. Not typescript: its case needs the TypeScript engine; javascript has no case. | |
| - name: the hooks' fast path agrees with the rules (${{ matrix.lang }}) | |
| if: matrix.lang == 'python' || matrix.lang == 'java' || matrix.lang == 'csharp' | |
| env: | |
| AXIOM_PARSER: ${{ github.workspace }}/parser/dist/index.js | |
| AXIOM_SOUFFLE_CACHE: ${{ github.workspace }}/.souffle-cache | |
| run: python3 tests/fastpath.py --lang ${{ matrix.lang }} | |
| # Saved whether or not the suite passed. The binary does not depend on the verdict: | |
| # run-souffle.sh publishes it only whole and verified (a temp name, then a rename), | |
| # so a red run's engine is as good as a green one's, and the run that most needs the | |
| # next push to be fast is the red one. Not when cancelled, and not unless this | |
| # language's engine is actually there: a key saved without it would stay taken, and | |
| # every later run would restore the gap and could never save over it. | |
| - name: save the compiled Soufflé engine | |
| if: >- | |
| ${{ !cancelled() && !inputs.fresh && steps.engine-cache.outputs.cache-hit != 'true' | |
| && hashFiles(format('.souffle-cache/souffle-engine-{0}-{1}', matrix.lang, steps.eid.outputs.id)) != '' }} | |
| uses: actions/cache/save@v4 | |
| with: | |
| path: .souffle-cache | |
| key: souffle-engine-${{ matrix.lang }}-${{ steps.eid.outputs.id }}-${{ hashFiles('graph/pipeline/run-souffle.sh') }}-march-${{ env.AXIOM_ENGINE_MARCH }} | |
| # Every language's engine, every platform, built once per release: on the push that | |
| # lands a new version on main (and in the nightly). publish-npm ships these very | |
| # artifacts, so what the e2e below tested is what users install (#1350). | |
| engines: | |
| name: engines build on every platform | |
| needs: [build, changes] | |
| if: needs.changes.outputs.engines == 'true' | |
| uses: ./.github/workflows/build-engines.yml | |
| with: | |
| fresh: ${{ inputs.fresh == true }} | |
| # THE RELEASE GATE: what a user installs, on every platform, answers every verb. The engines | |
| # job only proves each binary compiles and starts; the suites run from the checkout. Neither | |
| # installs the packages, so a missing `files` entry, an engine package the CLI does not find, | |
| # a query program that needs Soufflé, or a verb that only breaks on Windows reached users. | |
| # Runs wherever the platform engines are built: on the push that lands a release on main, and in the nightly. | |
| pack: | |
| name: pack @axiomcode/code-graph | |
| needs: [build, changes] | |
| if: needs.changes.outputs.engines == 'true' | |
| runs-on: ubuntu-24.04 | |
| timeout-minutes: 15 | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: actions/setup-node@v4 | |
| with: | |
| node-version: '22' | |
| - run: npm install --no-audit --no-fund | |
| # --ignore-scripts: `prepare` already built it; the tarball carries what the build produced | |
| - run: mkdir -p tgz && npm pack --ignore-scripts --pack-destination tgz && ls -la tgz | |
| # kept as long as the engines: publish-npm ships this exact tarball, whenever the draft is published | |
| - uses: actions/upload-artifact@v4 | |
| with: { name: code-graph-tgz, path: tgz, retention-days: 90, if-no-files-found: error } | |
| e2e: | |
| name: e2e on ${{ matrix.target.platform }} | |
| needs: [changes, engines, pack] | |
| if: needs.changes.outputs.engines == 'true' | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| target: | |
| - { os: ubuntu-24.04, platform: linux-x64 } | |
| - { os: ubuntu-24.04-arm, platform: linux-arm64 } | |
| - { os: windows-2025, platform: win32-x64 } | |
| - { os: macos-15, platform: darwin-arm64 } | |
| - { os: macos-15-intel, platform: darwin-x64 } | |
| runs-on: ${{ matrix.target.os }} | |
| timeout-minutes: 45 | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: actions/setup-node@v4 | |
| with: | |
| node-version: '22' | |
| - uses: actions/setup-python@v5 | |
| with: | |
| python-version: '3.12' | |
| - uses: actions/download-artifact@v4 | |
| with: { name: 'engines-${{ matrix.target.platform }}', path: artifacts/engines } | |
| - uses: actions/download-artifact@v4 | |
| with: { name: code-graph-tgz, path: artifacts/tgz } | |
| - name: installed from the tarballs, no Soufflé, every language, every verb | |
| shell: bash | |
| env: | |
| PLATFORM: ${{ matrix.target.platform }} | |
| run: | | |
| set -euo pipefail | |
| command -v souffle && { echo "::error::this runner has souffle; the gate would not prove anything"; exit 1; } | |
| export CODEGRAPH_TGZ="$(ls "$PWD"/artifacts/tgz/*.tgz)" | |
| for lang in java typescript python javascript csharp; do | |
| # A glob, not ls | head: under pipefail, head exiting early SIGPIPEs ls and fails the step. | |
| cases=(graph/test/"$lang"/cases/*/src) | |
| case_dir="${cases[0]}" | |
| [ -d "$case_dir" ] || { echo "::error::no $lang case under graph/test/$lang/cases"; exit 1; } | |
| echo "::group::$lang ($case_dir)" | |
| bash .github/scripts/e2e-install.sh artifacts/engines "$PLATFORM" "$lang" "$case_dir" | |
| echo "::endgroup::" | |
| done | |
| # A single job the branch ruleset can require. Without it, every new matrix | |
| # entry has to be added to the protection rules by hand, and a matrix job that | |
| # fails to start reports nothing at all — which a ruleset reads as "not | |
| # failing" rather than as "did not run". | |
| ci: | |
| name: CI | |
| runs-on: ubuntu-24.04 | |
| needs: [changes, build, hygiene, engine, engines, pack, e2e] | |
| if: always() | |
| steps: | |
| - name: every required job succeeded | |
| run: | | |
| # The expression quotes its separator with SINGLE quotes because that is | |
| # the only string delimiter a GitHub expression has. A double quote there | |
| # is a lex error that invalidates the entire workflow file, and the run | |
| # then fails in zero seconds with no job having started. | |
| # changes, build and hygiene always run and must succeed. engine and | |
| # engines may be SKIPPED, but only when `changes` said so; any other | |
| # skip (a job that never started) is a failure. | |
| always="${{ needs.changes.result }} ${{ needs.build.result }} ${{ needs.hygiene.result }}" | |
| echo "always-run jobs: $always" | |
| for r in $always; do | |
| [ "$r" = "success" ] || { echo "::error::a required job reported '$r'"; exit 1; } | |
| done | |
| check() { # name result expected-to-run | |
| if [ "$3" = true ]; then | |
| [ "$2" = success ] || { echo "::error::$1 reported '$2'"; exit 1; } | |
| else | |
| [ "$2" = skipped ] || { echo "::error::$1 reported '$2' though nothing it tests changed"; exit 1; } | |
| fi | |
| echo "$1: $2" | |
| } | |
| check "engine suites" "${{ needs.engine.result }}" "${{ needs.changes.outputs.code }}" | |
| check "platform engines" "${{ needs.engines.result }}" "${{ needs.changes.outputs.engines }}" | |
| check "pack" "${{ needs.pack.result }}" "${{ needs.changes.outputs.engines }}" | |
| check "e2e on every platform" "${{ needs.e2e.result }}" "${{ needs.changes.outputs.engines }}" | |
| echo "all required jobs passed" |