Skip to content

ci + release: gate main on all five suites; one version, tag and draft release per bump; npm on publish #27

ci + release: gate main on all five suites; one version, tag and draft release per bump; npm on publish

ci + release: gate main on all five suites; one version, tag and draft release per bump; npm on publish #27

Workflow file for this run

# ─────────────────────────────────────────────────────────────────────────────
# 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 when what it compiles changed. The `CI`
# job reports either way. Pushes to main, merge queues and manual runs run it all.
# ─────────────────────────────────────────────────────────────────────────────
name: CI
on:
push:
branches: [main, dev] # dev takes direct pushes, so they get a result too
pull_request:
merge_group:
workflow_dispatch:
# 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 [ "${{ github.event_name }}" != pull_request ]; then
echo "code=true" >> "$GITHUB_OUTPUT"; echo "engines=true" >> "$GITHUB_OUTPUT"
echo "not a pull request: 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 '^\.github/workflows/build-engines\.yml$' -e '^package\.json$' || true)"
[ -n "$code" ] && echo "code=true" >> "$GITHUB_OUTPUT" || echo "code=false" >> "$GITHUB_OUTPUT"
[ -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 }}"
engine:
name: engine (${{ matrix.lang }})
needs: [changes]
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 45
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.
- name: cache the compiled Soufflé engine
uses: actions/cache@v4
with:
path: .souffle-cache
key: souffle-${{ env.SOUFFLE_VERSION }}-${{ matrix.lang }}-${{ hashFiles('graph/**/*.dl', 'graph/**/*.map', 'graph/**/*.tsv') }}
restore-keys: |
souffle-${{ env.SOUFFLE_VERSION }}-${{ matrix.lang }}-
- name: ${{ matrix.lang }} regression suite
env:
AXIOM_PARSER: ${{ github.workspace }}/parser/dist/index.js
run: bash .github/scripts/run-suite.sh ${{ matrix.lang }} ${{ matrix.oracle }}
# Every language's engine, every platform: the reusable build that publish-npm
# ships from, run here WITHOUT publishing. A rule that solves on Ubuntu but does
# not compile with MSVC, or that no longer generates for a language, fails the
# gate here rather than at release time.
engines:
name: engines build on every platform
needs: [build, changes]
if: needs.changes.outputs.engines == 'true'
uses: ./.github/workflows/build-engines.yml
# 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]
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 }}"
echo "all required jobs passed"