Skip to content

Stop killing long agent turns at the 30-minute worker cap #748

Stop killing long agent turns at the 30-minute worker cap

Stop killing long agent turns at the 30-minute worker cap #748

Workflow file for this run

name: d2e build plugin
on:
workflow_dispatch:
inputs:
GIT_REPO_FULL_NAME:
description: Select RepoName
required: false
type: choice
options:
- OHDSI/trex
GIT_BRANCH_NAME:
default: develop
description: Enter BranchName / ReleaseTagName
required: true
type: string
tag:
description: Enter tag for release
required: true
type: string
release:
description: Enter name for github release
required: true
type: string
prerelease:
type: boolean
default: true
required: true
overwrite:
type: boolean
default: true
required: true
artifacttype:
type: choice
required: true
default: "OSS-develop"
options:
- "OSS-develop"
- "OSS-release"
- "Project"
d2e_branch:
description: "D2E branch to test against (for the e2e-d2e job)"
default: "develop"
type: string
required: false
pull_request:
types: [opened, ready_for_review, reopened, synchronize]
merge_group:
push:
branches:
- develop
- main
tags:
- 'v*'
permissions: read-all
concurrency:
group: plugin-ci-${{ github.head_ref || github.ref_name }}
cancel-in-progress: true
# GitHub Actions cache budget (hard 10 GB per repository, LRU-evicted).
# Measured entry sizes: runtime amd64 5.62 GB, runtime arm64 5.59 GB, every
# other Rust plugin 0.85-1.13 GB. Caching all of them across plugin-ci,
# coverage and integration-tests demanded ~16 GB, so the quota stayed
# permanently overcommitted and the largest -- and most valuable -- entry never
# survived. Only two caches are funded now, both chosen for wall-clock:
# plugin-ci build runtime (amd64) ~5.6 GB gates e2e-d2e
# integration-tests trexas ~2.5 GB 39 -> 22 min on that workflow
# leaving ~1.9 GB of headroom. Adding another Rust target cache means taking
# the room from one of these two, not from thin air.
env:
GIT_BRANCH_NAME: ${{ github.event.inputs.GIT_BRANCH_NAME || github.head_ref || github.ref_name }} # workflow_dispatch || pull_request || push
GIT_REPO_FULL_NAME: ${{ github.event.inputs.GIT_REPO_FULL_NAME || github.event.pull_request.head.repo.full_name || github.event.repository.full_name }} # workflow_dispatch || pull_request || push
NPM_ARTIFACT_TYPE: ${{ github.event.inputs.artifacttype || 'OSS-develop'}}
jobs:
pick_runner:
runs-on: ubuntu-latest
if: (github.ref_name == 'develop' || github.ref_name == 'main' || contains('release/', github.ref_name) || github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v')) || ( github.event_name == 'pull_request' && !github.event.pull_request.draft ) # Should run if branch is develop/main/release/workflow_dispatch/tag and doesnt have a PR
outputs:
random_runner: ${{ steps.shuffle.outputs.random_runner }}
steps:
- id: shuffle
run: echo "random_runner=$(shuf -e ubuntu-22.04 ubuntu-24.04 | head -n 1)" >> "$GITHUB_OUTPUT"
prometheus:
needs: [pick_runner]
if: |
(github.ref_name == 'develop' || github.ref_name == 'main' || contains('release', github.ref_name) || github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v'))
|| (github.event_name == 'pull_request' && !github.event.pull_request.draft && github.event.pull_request.head.repo.full_name == github.repository)
runs-on: ${{ needs.pick_runner.outputs.random_runner }}
steps:
- uses: actions/checkout@v4
id: checkout_fhir_ui
continue-on-error: true
with:
ref: ${{ env.GIT_BRANCH_NAME }}
repository: ${{ env.GIT_REPO_FULL_NAME }}
submodules: false
fetch-depth: 0
- if: steps.checkout_fhir_ui.outcome == 'failure'
uses: actions/checkout@v4
with:
ref: ${{ env.GIT_BRANCH_NAME }}
repository: ${{ env.GIT_REPO_FULL_NAME }}
submodules: false
fetch-depth: 0
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: "20.x"
registry-url: "https://npm.pkg.github.com"
scope: "@trex"
- name: Install dependencies
run: |
cd ./plugins/prometheus
npm install --ignore-scripts
env:
CI: true
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Unit tests
run: |
cd ./plugins/prometheus
npm run test
- name: Build
run: |
cd ./plugins/prometheus
npm run build
# Deno unit tests for the core server's agent stack (agents/channels/
# connections/eve-shim), the memory plugin type (importer + gbrain worker,
# against a live pgvector Postgres), and the claw agent plugin. Scoped to
# those suites — realtime/* needs a wal_level=logical Postgres and is
# covered by the e2e jobs, and the devx agent eval suite is deliberately
# NOT run in CI (it needs live model credentials).
core-server-tests:
if: |
(github.ref_name == 'develop' || github.ref_name == 'main' || contains('release', github.ref_name) || github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v'))
|| (github.event_name == 'pull_request' && !github.event.pull_request.draft && github.event.pull_request.head.repo.full_name == github.repository)
runs-on: ubuntu-latest
timeout-minutes: 25
services:
postgres:
# Same image/db the vendored gbrain suite targets (see
# vendor/gbrain/docker-compose.ci.yml). Host port 5433 matches the
# tests' hardcoded fallback URL.
image: pgvector/pgvector:pg16
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: gbrain_test
ports:
- 5433:5432
options: >-
--health-cmd "pg_isready -U postgres -d gbrain_test"
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
# agents/plugin tests only need DATABASE_URL to be SET (core/server/db.ts
# throws at import time without it); the gbrain worker + self-import
# tests actually connect.
DATABASE_URL: postgres://postgres:postgres@127.0.0.1:5433/gbrain_test
GBRAIN_TEST_DATABASE_URL: postgres://postgres:postgres@127.0.0.1:5433/gbrain_test
steps:
- uses: actions/checkout@v4
id: checkout_core_tests
continue-on-error: true
with:
ref: ${{ env.GIT_BRANCH_NAME }}
repository: ${{ env.GIT_REPO_FULL_NAME }}
submodules: false
- if: steps.checkout_core_tests.outcome == 'failure'
uses: actions/checkout@v4
with:
ref: ${{ env.GIT_BRANCH_NAME }}
repository: ${{ env.GIT_REPO_FULL_NAME }}
submodules: false
- uses: denoland/setup-deno@v2
with:
deno-version: v2.x
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: "22.x"
- name: Install core/server node deps
# The deno.json npm: imports (ai, @ai-sdk/*, edn-data) are declared in
# package.json so plain npm ci materializes them — nodeModulesDir is
# "manual" (byonm) and deno test resolves npm: specifiers from
# node_modules. Do NOT use `deno install` here: it creates deno's own
# node_modules/.deno layout at the workspace root, under which express
# loses its @types and setTimeout types shift to Node's Timeout — 22
# typecheck errors that npm's layout doesn't produce.
run: cd core/server && npm ci
- name: Prepare test database (trexdb schema + memory import-state table)
run: |
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 \
-c 'CREATE SCHEMA IF NOT EXISTS trexdb;' \
-c 'DO $$ BEGIN CREATE ROLE authenticated; EXCEPTION WHEN duplicate_object THEN NULL; END $$;' \
-f core/schema/V6__memory_import_state.sql
# --no-check like the gbrain-worker/claw legs below: the deno workspace
# root is core/ (its deno.json declares the workspace, so its
# nodeModulesDir wins) and TYPE-REFERENCE directives from npm packages
# (/// <reference types="node">) resolve against core/node_modules,
# which only exists on dev machines (deno install residue) — npm ci in
# core/server can never satisfy them. Runtime npm: resolution works from
# core/server/node_modules either way. Typechecking runs on dev machines
# via the same command without --no-check.
- name: core/server tests (agents, plugin loader, memory importer, d2e-compat)
run: cd core/server && deno test --no-check --allow-all agents/ plugin/ memory/importer.test.ts d2e-compat/ webapi-native.test.ts
# The gbrain worker suite runs under its OWN import map (gbrain/ alias +
# npm: pins) — it is not a member of the core deno workspace, so it
# cannot run under core/server/deno.json (see gbrain-worker/deno.json).
- name: gbrain memory worker tests (live DB)
run: cd core/server && deno test --no-check --allow-all --import-map memory/gbrain-worker/deno.json --node-modules-dir=auto memory/gbrain-worker/
# Runtime JS split out of trex_lib.js so it is testable without
# ext:core/mod.js: db_resolve.js and hana_sql.js.
- name: runtime JS unit tests
run: deno test --no-check --allow-all plugins/runtime/ext/trex/js/
# claw's importmap.json is authoring/test-only: at runtime the agents
# loader stages the eve shim and generates the worker import map itself.
- name: claw plugin tests
run: deno test --no-check --allow-all --import-map plugins/claw/importmap.json --node-modules-dir=auto plugins/claw/
# Same authoring/test-only import map story as claw above. D2ESUPPORT_USER_ID
# is deliberately unset here — the allowlist test suite covers both the
# env-set and env-unset paths, and CI has no real devx user to loop back to.
- name: d2esupport plugin tests
run: deno test --no-check --allow-all --import-map plugins/d2esupport/importmap.json --node-modules-dir=auto plugins/d2esupport/
build:
needs: [pick_runner]
# PR builds run only for same-repo PRs. Fork PRs are excluded — they'd otherwise check out
# untrusted code (via the `repository:` input below), which runs prepack/prepublish lifecycle
# scripts during `npm pack` and compiles attacker-controlled Rust/TS during the build.
if: |
(github.ref_name == 'develop' || github.ref_name == 'main' || contains('release', github.ref_name) || github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v'))
|| (github.event_name == 'pull_request' && !github.event.pull_request.draft && github.event.pull_request.head.repo.full_name == github.repository)
strategy:
fail-fast: false
matrix:
include:
- PKGPATH: ./plugins/pool
- PKGPATH: ./plugins/chdb
RUNNER: ubuntu-24.04
- PKGPATH: ./plugins/atlas
RUNNER: ubuntu-24.04
- PKGPATH: ./plugins/hana
- PKGPATH: ./plugins/ai
RUNNER: ubuntu-24.04
- PKGPATH: ./plugins/pgwire
- PKGPATH: ./plugins/hades
- PKGPATH: ./plugins/cache
- PKGPATH: ./plugins/tpm
- PKGPATH: ./plugins/migration
- PKGPATH: ./plugins/db
- PKGPATH: ./plugins/etl
- PKGPATH: ./plugins/fhir
- PKGPATH: ./plugins/transform
- PKGPATH: ./plugins/cql2elm
RUNNER: ubuntu-24.04
- PKGPATH: ./plugins/bao
RUNNER: ubuntu-24.04
- PKGPATH: ./plugins/webapi
RUNNER: ubuntu-24.04
- PKGPATH: ./plugins/docs
- PKGPATH: ./plugins/web
- PKGPATH: ./plugins/notebook
- PKGPATH: ./plugins/pg-meta
- PKGPATH: ./plugins/studio
NODE_VERSION: "22.x"
- PKGPATH: ./plugins/runtime
RUNNER: ubuntu-24.04
# The only plugin with a Rust target cache: 26 min cold, and it
# gates e2e-d2e, so it is the one entry that buys pipeline time.
RUST_CACHE: true
- PKGPATH: ./plugins/devx
RUNNER: ubuntu-24.04
- PKGPATH: ./plugins/devx-ext
RUNNER: ubuntu-24.04
EXCLUDE_FROM_IMAGE: true
- PKGPATH: ./plugins/claw
- PKGPATH: ./plugins/d2esupport
runs-on: ${{ matrix.RUNNER || needs.pick_runner.outputs.random_runner }}
steps:
- uses: actions/checkout@v4
id: checkout_1
continue-on-error: true
with:
ref: ${{ env.GIT_BRANCH_NAME }}
repository: ${{ env.GIT_REPO_FULL_NAME }}
submodules: false
fetch-depth: 0
- if: steps.checkout_1.outcome == 'failure'
uses: actions/checkout@v4
with:
ref: ${{ env.GIT_BRANCH_NAME }}
repository: ${{ env.GIT_REPO_FULL_NAME }}
submodules: false
fetch-depth: 0
- name: Init submodules
shell: bash
run: |
# Drop the source-repo-scoped basic auth header so cross-repo public
# submodules can clone reliably (401 otherwise triggers prompt fallback).
git config --local --unset-all http.https://github.com/.extraheader 2>/dev/null || true
git config --global --unset-all http.https://github.com/.extraheader 2>/dev/null || true
for attempt in 1 2 3; do
if git submodule update --init --depth=1 --recursive --force; then
echo "Submodule init succeeded on attempt $attempt"
exit 0
fi
sleep $((attempt * 10))
done
echo "::error::Submodule init failed after 3 attempts"
exit 1
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.NODE_VERSION || '20.x' }}
registry-url: "https://npm.pkg.github.com"
scope: "@trex"
- name: Update version
run: |
cd ${{ matrix.PKGPATH }}
if [[ $GITHUB_EVENT_NAME == 'workflow_dispatch' ]]; then
RELEASE_VERSION=${{ github.event.inputs.tag }}
jq --arg v $RELEASE_VERSION '.version=$v' package.json > tmppkg; mv tmppkg package.json
elif [[ $GITHUB_REF == refs/tags/v* ]]; then
RELEASE_VERSION="${GITHUB_REF#refs/tags/v}"
jq --arg v "$RELEASE_VERSION" '.version=$v' package.json > tmppkg; mv tmppkg package.json
else
jq --arg v "-$(date +%s)-$GITHUB_SHA" '.version+=$v' package.json > tmppkg; mv tmppkg package.json
fi
- name: Set up Rust toolchain
if: matrix.PKGPATH == './plugins/bao'
uses: dtolnay/rust-toolchain@stable
- uses: ./.github/actions/setup-libtrexsql
if: matrix.PKGPATH == './plugins/bao'
with:
version: v1.4.4-trex
- name: Build bao native library
if: matrix.PKGPATH == './plugins/bao'
run: |
TREXSQL_LIB_DIR=/tmp/trexsql cargo build --release
mkdir -p plugins/bao/java/resources/linux-x86-64
cp target/release/libtrexsql_engine.so plugins/bao/java/resources/linux-x86-64/
cp /tmp/trexsql/libtrexsql.so plugins/bao/java/resources/linux-x86-64/
- name: Set up Java 21
if: matrix.PKGPATH == './plugins/bao'
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
- name: Install Leiningen
if: matrix.PKGPATH == './plugins/bao'
run: |
sudo curl -sL https://raw.githubusercontent.com/technomancy/leiningen/stable/bin/lein -o /usr/local/bin/lein
sudo chmod +x /usr/local/bin/lein
lein version
- name: Install dependencies
run: |
cd ${{ matrix.PKGPATH }}
npm install --ignore-scripts
env:
CI: true
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Rust plugins (runtime/trexas especially) produce a multi-GB target/ and,
# with both the build cache and rust-cache saving in the post phase, exhaust
# the runner's ~14 GB free disk ("No space left on device"). Reclaim ~15-20 GB
# first. tool-cache is kept (it holds the Node just installed by setup-node,
# which `npm pack` needs below).
- name: Free disk space
if: ${{ hashFiles(format('{0}/Cargo.lock', matrix.PKGPATH)) != '' }}
continue-on-error: true
uses: jlumbroso/free-disk-space@v1.3.1
with:
tool-cache: false
android: true
dotnet: true
haskell: true
# large-packages runs `apt-get remove` on the host and cascades into the
# C toolchain (removes gcc's stdarg.h) — this job compiles libsqlite3-sys
# via cc on the host, so keep it off. android+dotnet+haskell+docker already
# reclaim ~20 GB.
large-packages: false
docker-images: true
swap-storage: true
# Cache cargo / maven / leiningen state per-plugin. The actual build
# is triggered by `npm pack` -> the plugin's prepack hook, so caches
# need to be in place before that. Without this the runtime (trexas)
# build takes ~29 min on every PR which blocks e2e-d2e.
#
# RESTORE on every run, SAVE only on develop/main. GitHub scopes a cache
# written on a PR ref to that PR alone, and evicts LRU once the repo passes
# its 10 GB quota. The runtime rust-cache entry alone is ~5.7 GB, so every
# PR run used to write an entry nobody else could read that evicted all of
# develop's entries -- leaving the repo with *zero* reusable caches and
# every Rust plugin compiling cold on every run. Branches restore from
# develop via restore-keys, which is where the reuse actually comes from.
- name: Restore build cache
uses: actions/cache/restore@v4
id: build-cache
with:
path: |
~/.m2/repository
~/.cache/lein
${{ matrix.PKGPATH }}/build
key: plugin-ci-${{ matrix.PKGPATH }}-${{ runner.os }}-${{ hashFiles(format('{0}/pom.xml', matrix.PKGPATH), format('{0}/CMakeLists.txt', matrix.PKGPATH), format('{0}/package-lock.json', matrix.PKGPATH)) }}
restore-keys: |
plugin-ci-${{ matrix.PKGPATH }}-${{ runner.os }}-
# Rust target cache, for RUST_CACHE plugins only -- see the cache budget
# note at the top of this workflow. Caching every Cargo.lock plugin
# overcommitted the 10 GB quota by ~6 GB, so nothing survived and the one
# entry that pays for itself (runtime, ~5.6 GB) was evicted every run.
- name: Rust build cache
if: ${{ matrix.RUST_CACHE == true }}
uses: Swatinem/rust-cache@v2
with:
workspaces: ${{ matrix.PKGPATH }}
cache-on-failure: "true"
# Same single-writer rule as the build cache above.
save-if: ${{ github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/main' }}
- name: Configure @trex publish registry
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.NODE_VERSION || '20.x' }}
registry-url: "https://pkgs.dev.azure.com/data2evidence/d2e/_packaging/trex/npm/registry/"
scope: "@trex"
- name: Configure @data2evidence registry - OSS Develop
uses: actions/setup-node@v4
if: env.NPM_ARTIFACT_TYPE == 'OSS-develop'
with:
node-version: ${{ matrix.NODE_VERSION || '20.x' }}
registry-url: "https://pkgs.dev.azure.com/data2evidence/d2e/_packaging/d2e/npm/registry/"
scope: "@data2evidence"
- name: Configure @data2evidence registry - OSS Release
uses: actions/setup-node@v4
if: env.NPM_ARTIFACT_TYPE == 'OSS-release'
with:
node-version: ${{ matrix.NODE_VERSION || '20.x' }}
registry-url: "https://pkgs.dev.azure.com/data2evidence/d2e/_packaging/stable/npm/registry/"
scope: "@data2evidence"
- name: Configure @data2evidence registry - Project
uses: actions/setup-node@v4
if: env.NPM_ARTIFACT_TYPE == 'Project'
with:
node-version: ${{ matrix.NODE_VERSION || '20.x' }}
registry-url: "https://pkgs.dev.azure.com/data2evidence/d2e/_packaging/ms/npm/registry/"
scope: "@data2evidence"
- name: Pack
env:
CI: true
DUCKDB_EXTENSION_SIGNING_KEY: ${{ secrets.DUCKDB_EXTENSION_SIGNING_KEY }}
run: |
cd ${{ matrix.PKGPATH }}
npm pack
# Counterpart to the restore above: only develop/main publish cache entries,
# so PR runs can never evict the entries every branch restores from.
- name: Save build cache
if: ${{ (github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/main') && steps.build-cache.outputs.cache-hit != 'true' }}
uses: actions/cache/save@v4
with:
path: |
~/.m2/repository
~/.cache/lein
${{ matrix.PKGPATH }}/build
key: ${{ steps.build-cache.outputs.cache-primary-key }}
- name: Verify .trex artifact in tarball (extension plugins only)
run: |
cd ${{ matrix.PKGPATH }}
# Plugins whose published tarball MUST include a compiled *.trex
# artifact. Web/docs/notebook/cli/runtime/bao publish other shapes;
# everything else is a DuckDB extension and must ship a .trex.
case "${{ matrix.PKGPATH }}" in
./plugins/web|./plugins/docs|./plugins/notebook|./plugins/cli|./plugins/runtime|./plugins/bao|./plugins/pg-meta|./plugins/studio|./plugins/devx|./plugins/claw|./plugins/d2esupport)
echo "skip — non-extension plugin"
;;
*)
TARBALL=$(ls -t *.tgz | head -1)
if [ -z "$TARBALL" ]; then
echo "::error::npm pack produced no tarball"
exit 1
fi
if ! tar -tzf "$TARBALL" | grep -qE '\.trex$'; then
echo "::error::tarball $TARBALL is missing the .trex artifact — prepack/build did not produce one"
echo "Tarball contents:"
tar -tzf "$TARBALL" | head -40
exit 1
fi
echo "OK: tarball $TARBALL contains a .trex artifact"
tar -tzf "$TARBALL" | grep '\.trex$'
;;
esac
# Rust unit tests run in coverage.yml (rust-unit-coverage matrix); sqllogic
# tests run in integration-tests.yml. Running them here would force a debug
# rebuild inside this release-flavor job for no added signal.
- name: Publish
# Publish only from the canonical OHDSI/trex repository on develop or main.
# Forks and feature branches build + verify but do not push to the registry.
if: github.repository == 'OHDSI/trex' && (github.ref_name == 'develop' || github.ref_name == 'main')
env:
CI: true
NODE_AUTH_TOKEN: ${{ secrets.NPM_PROJECT_TOKEN }}
run: |
cd ${{ matrix.PKGPATH }}
TARBALL=$(ls -t *.tgz | head -1)
# Publish the already-built tarball — prepack already ran during npm pack.
# Use --ignore-scripts to avoid re-running prepack (and re-building) here.
npm publish "$TARBALL" --ignore-scripts
- name: Extract extension name
id: extname
run: echo "name=$(basename ${{ matrix.PKGPATH }})" >> "$GITHUB_OUTPUT"
- name: Upload extension artifact
if: hashFiles(format('{0}/*.trex', matrix.PKGPATH)) != ''
uses: actions/upload-artifact@v4
with:
# Plugins with EXCLUDE_FROM_IMAGE: true upload as dx-* so the
# build-docker / create-release jobs' `ext-*-amd64` download pattern
# skips them (keeps devx-ext out of the default trex image). The
# previous condition `matrix.BUNDLE_IN_IMAGE == false` matched
# null (unset) too, which had silently dropped EVERY x64 artifact
# to `dx-*` for months.
name: ${{ matrix.EXCLUDE_FROM_IMAGE && format('dx-{0}-amd64', steps.extname.outputs.name) || format('ext-{0}-amd64', steps.extname.outputs.name) }}
path: ${{ matrix.PKGPATH }}/*.trex
retention-days: 7
build-arm:
needs: [pick_runner]
if: github.ref_name == 'develop' || github.ref_name == 'main' || contains('release', github.ref_name) || github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v')
strategy:
fail-fast: false
matrix:
include:
- PKGPATH: ./plugins/hana
- PKGPATH: ./plugins/pgwire
- PKGPATH: ./plugins/hades
- PKGPATH: ./plugins/cache
- PKGPATH: ./plugins/tpm
- PKGPATH: ./plugins/migration
- PKGPATH: ./plugins/db
SWAP: true
CARGO_BUILD_JOBS: 4
- PKGPATH: ./plugins/etl
- PKGPATH: ./plugins/fhir
- PKGPATH: ./plugins/transform
- PKGPATH: ./plugins/pool
- PKGPATH: ./plugins/webapi
- PKGPATH: ./plugins/chdb
SWAP: true
CARGO_BUILD_JOBS: 4
# chdb links a prebuilt libchdb; install_chdb.sh (which already
# selects linux-aarch64) runs in prepack on amd64, but build-arm
# calls make directly, so install it here first.
PREBUILD: "sudo apt-get update && sudo apt-get install -y libc6-dev clang && sudo bash ./install_chdb.sh"
- PKGPATH: ./plugins/devx-ext
EXCLUDE_FROM_IMAGE: true
# CMake/native plugins — build.sh installs their toolchains
# (vcpkg/vulkan for ai, GraalVM + maven for atlas/cql2elm).
- PKGPATH: ./plugins/ai
BUILD_SCRIPT: "bash ./build.sh"
SWAP: true
- PKGPATH: ./plugins/atlas
BUILD_SCRIPT: "bash ./build.sh"
SWAP: true
- PKGPATH: ./plugins/cql2elm
BUILD_SCRIPT: "bash ./build.sh"
SWAP: true
# runtime/trexas links a prebuilt libtrexsql and embeds V8 (rusty_v8
# downloads the prebuilt arm64 lib via RUSTY_V8_MIRROR). Its prepack
# fetches the amd64 zip; build-arm calls make directly, so install the
# arm64 libtrexsql here. CARGO_NET_GIT_FETCH_WITH_CLI helps the large
# rusty_v8 chromium submodule fetch succeed.
- PKGPATH: ./plugins/runtime
SWAP: true
CARGO_BUILD_JOBS: 4
PREBUILD: "sudo apt-get update && sudo apt-get install -y curl unzip clang libclang-dev libopenblas-dev libglib2.0-dev && curl -L -O https://github.com/p-hoffmann/trexsql-rs/releases/download/v1.4.4-trex/libtrexsql-linux-arm64.zip && unzip -o libtrexsql-linux-arm64.zip && sudo cp duckdb.h /usr/local/include/ && sudo cp libtrexsql.so /usr/local/lib/ && sudo ldconfig"
runs-on: ubuntu-24.04-arm
steps:
- uses: actions/checkout@v4
id: checkout_2
continue-on-error: true
with:
ref: ${{ env.GIT_BRANCH_NAME }}
repository: ${{ env.GIT_REPO_FULL_NAME }}
submodules: false
fetch-depth: 0
- if: steps.checkout_2.outcome == 'failure'
uses: actions/checkout@v4
with:
ref: ${{ env.GIT_BRANCH_NAME }}
repository: ${{ env.GIT_REPO_FULL_NAME }}
submodules: false
fetch-depth: 0
- name: Init submodules
shell: bash
run: |
git config --local --unset-all http.https://github.com/.extraheader 2>/dev/null || true
git config --global --unset-all http.https://github.com/.extraheader 2>/dev/null || true
for attempt in 1 2 3; do
if git submodule update --init --depth=1 --recursive --force; then
echo "Submodule init succeeded on attempt $attempt"
exit 0
fi
sleep $((attempt * 10))
done
echo "::error::Submodule init failed after 3 attempts"
exit 1
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: "20.x"
registry-url: "https://npm.pkg.github.com"
scope: "@trex"
- name: Install dependencies
run: |
cd ${{ matrix.PKGPATH }}
npm install --ignore-scripts
env:
CI: true
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Add swap space
if: matrix.SWAP == true
run: |
sudo swapoff /swapfile 2>/dev/null || true
sudo rm -f /swapfile
sudo fallocate -l 8G /mnt/swapfile
sudo chmod 600 /mnt/swapfile
sudo mkswap /mnt/swapfile
sudo swapon /mnt/swapfile
# build-arm had no caching at all, so every arm64 plugin compiled cold on
# every develop push (runtime ~18 min, db ~10 min). This job only runs on
# develop/main/release/dispatch/tags -- never on PRs -- so it is always a
# legitimate cache writer and needs no save-if gate.
- name: Restore build cache (arm64)
uses: actions/cache/restore@v4
id: build-cache-arm
with:
path: |
~/.m2/repository
~/.cache/lein
${{ matrix.PKGPATH }}/build
key: plugin-ci-arm64-${{ matrix.PKGPATH }}-${{ hashFiles(format('{0}/pom.xml', matrix.PKGPATH), format('{0}/CMakeLists.txt', matrix.PKGPATH), format('{0}/package-lock.json', matrix.PKGPATH)) }}
restore-keys: |
plugin-ci-arm64-${{ matrix.PKGPATH }}-
# No Rust target cache on arm64 on purpose. The arm runtime entry was
# 5.59 GB -- on its own as large as the amd64 one -- and the two together
# (11.21 GB) exceeded the entire 10 GB quota, so each run evicted the
# other and every plugin still built cold. build-arm feeds build-docker
# (~42 min combined) and never gates e2e-d2e, so it is off the critical
# path and the quota is better spent on amd64 runtime.
- name: Build
env:
# Use the git CLI for fetches: libgit2 chokes on the large rusty_v8
# chromium submodules (runtime/trexas). Harmless for the other plugins.
CARGO_NET_GIT_FETCH_WITH_CLI: "true"
run: |
if [ -n "${{ matrix.CARGO_BUILD_JOBS }}" ]; then
export CARGO_BUILD_JOBS=${{ matrix.CARGO_BUILD_JOBS }}
fi
cd ${{ matrix.PKGPATH }}
# Some plugins need a native lib installed before the make build
# (e.g. chdb's libchdb). build-arm calls make directly rather than
# via npm prepack, so run that prep step here.
if [ -n "${{ matrix.PREBUILD }}" ]; then
echo "Running prebuild: ${{ matrix.PREBUILD }}"
eval "${{ matrix.PREBUILD }}"
fi
# CMake plugins (ai/atlas/cql2elm) drive their own toolchain setup
# (vcpkg/vulkan, GraalVM, maven) from build.sh; the Rust plugins use
# the plain make flow.
if [ -n "${{ matrix.BUILD_SCRIPT }}" ]; then
echo "Running build script: ${{ matrix.BUILD_SCRIPT }}"
eval "${{ matrix.BUILD_SCRIPT }}"
else
make configure
make release
fi
mv build/release/extension/*/*.trex . 2>/dev/null || true
- name: Save build cache (arm64)
if: ${{ steps.build-cache-arm.outputs.cache-hit != 'true' }}
uses: actions/cache/save@v4
with:
path: |
~/.m2/repository
~/.cache/lein
${{ matrix.PKGPATH }}/build
key: ${{ steps.build-cache-arm.outputs.cache-primary-key }}
- name: Extract extension name
id: extname
run: echo "name=$(basename ${{ matrix.PKGPATH }})" >> "$GITHUB_OUTPUT"
- name: Upload extension artifact
if: hashFiles(format('{0}/*.trex', matrix.PKGPATH)) != ''
uses: actions/upload-artifact@v4
with:
# Mirror the amd64 job: EXCLUDE_FROM_IMAGE plugins upload as dx-* so
# the create-release / build-docker `ext-*-arm64` pattern skips them.
name: ${{ matrix.EXCLUDE_FROM_IMAGE && format('dx-{0}-arm64', steps.extname.outputs.name) || format('ext-{0}-arm64', steps.extname.outputs.name) }}
path: ${{ matrix.PKGPATH }}/*.trex
retention-days: 7
# Build the heavy GraalVM native shared library libwebapi-native.so (embeds
# OHDSI WebAPI + the trexsql/bao integration). The webapi.trex extension is a
# thin shim that dlopens this lib at runtime, so without it the bundled
# extension can't boot. Built per-arch via Dockerfile.native-lib, boot-smoked
# (dlopen + webapi_start -> running), and uploaded as webapi-native-<arch> for
# create-release (release asset) and build-docker (baked into /usr/lib).
build-webapi-native:
# Same contexts as the plugin build matrix: develop/main/release/dispatch/tags
# and non-draft same-repo PRs.
if: |
(github.ref_name == 'develop' || github.ref_name == 'main' || contains('release', github.ref_name) || github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v'))
|| (github.event_name == 'pull_request' && !github.event.pull_request.draft && github.event.pull_request.head.repo.full_name == github.repository)
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
runner: ubuntu-24.04
- arch: arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
id: checkout_wn
continue-on-error: true
with:
ref: ${{ env.GIT_BRANCH_NAME }}
repository: ${{ env.GIT_REPO_FULL_NAME }}
submodules: false
- if: steps.checkout_wn.outcome == 'failure'
uses: actions/checkout@v4
with:
ref: ${{ env.GIT_BRANCH_NAME }}
repository: ${{ env.GIT_REPO_FULL_NAME }}
submodules: false
# The native lib build needs the webapi-be submodule (WebAPI source +
# native-image reachability config under WebAPI-extra) checked out.
- name: Init webapi-be submodule
shell: bash
run: |
git config --local --unset-all http.https://github.com/.extraheader 2>/dev/null || true
git config --global --unset-all http.https://github.com/.extraheader 2>/dev/null || true
for attempt in 1 2 3; do
if git submodule update --init --depth=1 --force plugins/webapi/webapi-be; then
echo "submodule init succeeded on attempt $attempt"; exit 0
fi
sleep $((attempt * 10))
done
echo "::error::webapi-be submodule init failed after 3 attempts"; exit 1
# native-image needs ~12-16 GB RAM; hosted runners have 16 GB. Free disk and
# add a large swap file so the linker doesn't get OOM-killed.
- name: Free disk space
continue-on-error: true
uses: jlumbroso/free-disk-space@v1.3.1
with:
tool-cache: true
android: true
dotnet: true
haskell: true
large-packages: true
docker-images: true
swap-storage: true
- name: Increase swap to 15G
continue-on-error: true
run: |
sudo swapoff -a || true
sudo fallocate -l 15G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# Registry-backed layer cache. Without it this job re-downloaded the pinned
# 500 MB GraalVM tarball, re-resolved all of Maven (the Dockerfile's
# --mount=type=cache,target=/root/.m2 dies with the builder) and re-ran
# native-image from scratch on every push -- ~27 min amd64 / ~21 min arm64,
# on build-docker's critical path. GHCR rather than BuildKit's type=gha
# because gha cache is charged against the same 10 GB Actions cache quota
# the Rust caches already contend for.
- name: Log in to GHCR (build cache)
if: github.repository == 'OHDSI/trex'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build libwebapi-native.so (${{ matrix.arch }})
env:
CACHE_REF: ghcr.io/ohdsi/trex-buildcache:webapi-native-${{ matrix.arch }}
run: |
# cache-from is best-effort: a missing/unauthorized ref just warns and
# builds cold. Only develop/main publish, so PR runs cannot poison it.
CACHE_ARGS="--cache-from type=registry,ref=${CACHE_REF}"
if [ "${{ github.repository }}" = "OHDSI/trex" ] \
&& { [ "${{ github.ref_name }}" = "develop" ] || [ "${{ github.ref_name }}" = "main" ]; }; then
CACHE_ARGS="$CACHE_ARGS --cache-to type=registry,ref=${CACHE_REF},mode=max,ignore-error=true"
fi
docker buildx build \
--target export -o type=local,dest=./out \
$CACHE_ARGS \
-f plugins/webapi/Dockerfile.native-lib .
ls -lh out/out/libwebapi-native.so
# Boot the native lib end-to-end (ephemeral Postgres + dlopen + webapi_start).
# smoke.sh prints results but always exits 0, so assert on its output here.
- name: Boot smoke test (${{ matrix.arch }})
env:
CACHE_REF: ghcr.io/ohdsi/trex-buildcache:webapi-smoke-${{ matrix.arch }}
run: |
# The smoke image compiles its own Rust engine stage; same cache rules.
CACHE_ARGS="--cache-from type=registry,ref=${CACHE_REF}"
if [ "${{ github.repository }}" = "OHDSI/trex" ] \
&& { [ "${{ github.ref_name }}" = "develop" ] || [ "${{ github.ref_name }}" = "main" ]; }; then
CACHE_ARGS="$CACHE_ARGS --cache-to type=registry,ref=${CACHE_REF},mode=max,ignore-error=true"
fi
docker buildx build $CACHE_ARGS --load \
-f plugins/webapi/smoke/Dockerfile.smoke -t webapi-smoke .
docker run --rm webapi-smoke | tee smoke.out
if ! grep -q "status=running" smoke.out; then
echo "::error::webapi_start did not reach running state"; exit 1
fi
# smoke.sh serves OIDC discovery over HTTPS from a throwaway CA and sets
# WEBAPI_TRUST_CERTS, so reaching "running" already proves the runtime
# truststore worked. This asserts the install itself ran, which catches the
# case where the discovery URL stops being HTTPS and the coverage silently
# lapses. smoke.sh always exits 0, so the assertion has to live here.
if ! grep -q "SMOKE_TRUST=installed" smoke.out; then
echo "::error::WEBAPI_TRUST_CERTS was not honoured in the native image"; exit 1
fi
# A shared-library native image installs no signal handlers, so SIGPIPE keeps the
# host default of terminate and the close_notify write during TLS teardown kills
# the process silently mid-boot. SigPipeGuard fixes that; assert it actually ran.
# The harness deliberately leaves SIGPIPE at its lethal default, so "running"
# above already proves the library protects itself - do NOT harden harness.c or
# this coverage disappears. See docs/design/webapi-native-sigpipe.md.
if ! grep -q "SIGPIPE: ignored" smoke.out; then
echo "::error::SigPipeGuard did not ignore SIGPIPE; a peer-closed TLS socket can kill the host process"
grep -E "SIGPIPE:" smoke.out || true
exit 1
fi
if grep -iqE "not registered for reflection|UnsupportedFeatureError|MissingReflectionRegistrationError|NoClassDefFoundError" smoke.out; then
echo "::error::native-image reachability gap hit during boot smoke"; exit 1
fi
- name: Stage native lib artifact
run: cp out/out/libwebapi-native.so "libwebapi-native-${{ matrix.arch }}.so"
- name: Upload native lib artifact
uses: actions/upload-artifact@v4
with:
name: webapi-native-${{ matrix.arch }}
path: libwebapi-native-${{ matrix.arch }}.so
if-no-files-found: error
retention-days: 7
create-release:
# build-webapi-native is a need for ordering (so its .so artifact exists), but
# the release is best-effort on it: a flaky heavy GraalVM build must not block
# publishing the other ~20 plugins. always() + explicit build/build-arm success
# gates preserve the original "release only if the plugin builds passed" rule.
needs: [build, build-arm, build-webapi-native]
# Releases only from canonical repo: workflow_dispatch on develop/main, or any pushed v* tag.
if: |
always()
&& needs.build.result == 'success'
&& needs.build-arm.result == 'success'
&& github.repository == 'OHDSI/trex'
&& (
(github.event_name == 'workflow_dispatch' && github.event.inputs.tag != '' && (github.ref_name == 'develop' || github.ref_name == 'main'))
|| (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v'))
)
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
id: checkout_3
continue-on-error: true
with:
ref: ${{ env.GIT_BRANCH_NAME }}
repository: ${{ env.GIT_REPO_FULL_NAME }}
- if: steps.checkout_3.outcome == 'failure'
uses: actions/checkout@v4
with:
ref: ${{ env.GIT_BRANCH_NAME }}
repository: ${{ env.GIT_REPO_FULL_NAME }}
- name: Download amd64 extension artifacts
uses: actions/download-artifact@v4
with:
pattern: ext-*-amd64
path: release-assets/amd64/
merge-multiple: true
- name: Download arm64 extension artifacts
uses: actions/download-artifact@v4
with:
pattern: ext-*-arm64
path: release-assets/arm64/
merge-multiple: true
continue-on-error: true
- name: Download webapi native lib artifacts
uses: actions/download-artifact@v4
with:
pattern: webapi-native-*
path: release-assets/native/
merge-multiple: true
continue-on-error: true
- name: Prepare release assets
run: |
mkdir -p release-assets/final
for f in release-assets/amd64/*.trex; do
[ -f "$f" ] && cp "$f" "release-assets/final/$(basename "${f%.trex}")-linux-amd64.trex"
done
for f in release-assets/arm64/*.trex; do
[ -f "$f" ] && cp "$f" "release-assets/final/$(basename "${f%.trex}")-linux-arm64.trex"
done
# libwebapi-native-<arch>.so — companion lib the webapi.trex shim dlopens.
for f in release-assets/native/libwebapi-native-*.so; do
[ -f "$f" ] && cp "$f" "release-assets/final/$(basename "$f")"
done
ls -la release-assets/final/
- name: Resolve release metadata
id: relmeta
run: |
if [[ "$GITHUB_EVENT_NAME" == 'workflow_dispatch' ]]; then
echo "tag=${{ github.event.inputs.tag }}" >> "$GITHUB_OUTPUT"
echo "name=${{ github.event.inputs.release }}" >> "$GITHUB_OUTPUT"
echo "prerelease=${{ github.event.inputs.prerelease }}" >> "$GITHUB_OUTPUT"
else
TAG="${GITHUB_REF#refs/tags/}"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "name=$TAG" >> "$GITHUB_OUTPUT"
# Tags containing a hyphen (e.g. v1.0.0-beta.1) are treated as prereleases.
if [[ "$TAG" == *-* ]]; then
echo "prerelease=true" >> "$GITHUB_OUTPUT"
else
echo "prerelease=false" >> "$GITHUB_OUTPUT"
fi
fi
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.relmeta.outputs.tag }}
name: ${{ steps.relmeta.outputs.name }}
prerelease: ${{ steps.relmeta.outputs.prerelease }}
files: |
release-assets/final/*.trex
release-assets/final/*.so
fail_on_unmatched_files: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Decide the docker build matrix. Multi-arch (amd64 + arm64) only when build-arm
# ran (develop/main/release/dispatch/tags) — an arm64 image needs the arm64
# extensions build-arm produces. Otherwise (e.g. PRs) build amd64 only.
docker-matrix:
needs: [build, build-arm]
if: always() && needs.build.result == 'success' && (needs.build-arm.result == 'success' || needs.build-arm.result == 'skipped')
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set.outputs.matrix }}
multiarch: ${{ steps.set.outputs.multiarch }}
steps:
- id: set
run: |
if [ "${{ needs.build-arm.result }}" = "success" ]; then
echo 'matrix={"include":[{"arch":"amd64","runner":"ubuntu-24.04"},{"arch":"arm64","runner":"ubuntu-24.04-arm"}]}' >> "$GITHUB_OUTPUT"
echo "multiarch=true" >> "$GITHUB_OUTPUT"
else
echo 'matrix={"include":[{"arch":"amd64","runner":"ubuntu-24.04"}]}' >> "$GITHUB_OUTPUT"
echo "multiarch=false" >> "$GITHUB_OUTPUT"
fi
build-docker:
needs: [build, build-arm, build-webapi-native, docker-matrix]
if: always() && needs.build.result == 'success' && (needs.build-arm.result == 'success' || needs.build-arm.result == 'skipped')
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.docker-matrix.outputs.matrix) }}
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
id: checkout_4
continue-on-error: true
with:
ref: ${{ env.GIT_BRANCH_NAME }}
repository: ${{ env.GIT_REPO_FULL_NAME }}
submodules: false
- if: steps.checkout_4.outcome == 'failure'
uses: actions/checkout@v4
with:
ref: ${{ env.GIT_BRANCH_NAME }}
repository: ${{ env.GIT_REPO_FULL_NAME }}
submodules: false
- name: Init submodules
shell: bash
run: |
git config --local --unset-all http.https://github.com/.extraheader 2>/dev/null || true
git config --global --unset-all http.https://github.com/.extraheader 2>/dev/null || true
for attempt in 1 2 3; do
if git submodule update --init --depth=1 --recursive --force; then
echo "Submodule init succeeded on attempt $attempt"
exit 0
fi
sleep $((attempt * 10))
done
echo "::error::Submodule init failed after 3 attempts"
exit 1
- name: Download ${{ matrix.arch }} extension artifacts
uses: actions/download-artifact@v4
with:
pattern: ext-*-${{ matrix.arch }}
path: extensions/${{ matrix.arch }}/
merge-multiple: true
# The webapi.trex shim dlopens libwebapi-native.so at runtime. Stage the
# per-arch native lib into the same extensions/<arch>/ dir the Dockerfile
# already COPYs; the Dockerfile copies *.so from there into /usr/lib.
# continue-on-error: if the native build failed/skipped, the image just
# ships without it (the Dockerfile copy tolerates its absence).
- name: Download ${{ matrix.arch }} webapi native lib
uses: actions/download-artifact@v4
with:
name: webapi-native-${{ matrix.arch }}
path: webapi-native/
continue-on-error: true
- name: Stage webapi native lib into extensions dir
run: |
mkdir -p extensions/${{ matrix.arch }}/
if [ -f "webapi-native/libwebapi-native-${{ matrix.arch }}.so" ]; then
cp "webapi-native/libwebapi-native-${{ matrix.arch }}.so" \
"extensions/${{ matrix.arch }}/libwebapi-native.so"
echo "staged libwebapi-native.so for ${{ matrix.arch }}"
else
echo "no webapi native lib for ${{ matrix.arch }} — image will ship without it"
fi
- name: List extensions
run: ls -la extensions/${{ matrix.arch }}/ 2>/dev/null || echo "none"
# The image builds below plus the buildx layer cache need headroom; the
# runner ships with ~14 GB free and the full image alone is multi-GB.
- name: Free disk space
continue-on-error: true
uses: jlumbroso/free-disk-space@v1.3.1
with:
tool-cache: false
android: true
dotnet: true
haskell: true
large-packages: false
docker-images: true
swap-storage: true
# Native build on a matching-arch runner — no QEMU needed.
#
# docker-container (the default driver) rather than `driver: docker`: the
# plain docker driver cannot export a build cache at all, so this job threw
# away every layer at the end of the run and rebuilt the whole image cold on
# the next one (~27 min amd64 / ~18 min arm64). Layers are cached to GHCR
# instead of type=gha to stay off the 10 GB Actions cache quota.
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR (build cache)
if: github.repository == 'OHDSI/trex'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# cache-to only from develop/main so PR runs read the shared cache but
# never write it. mode=max because the expensive work lives in the ten
# builder stages (the Rust `builder` stage above all) -- mode=min exports
# only the final image's own layers and would cache none of them.
- name: Resolve layer cache refs
id: dcache
run: |
REF=ghcr.io/ohdsi/trex-buildcache:image-${{ matrix.arch }}
echo "from=type=registry,ref=$REF" >> "$GITHUB_OUTPUT"
if [ "${{ github.repository }}" = "OHDSI/trex" ] \
&& { [ "${{ github.ref_name }}" = "develop" ] || [ "${{ github.ref_name }}" = "main" ]; }; then
echo "to=type=registry,ref=$REF,mode=max,ignore-error=true" >> "$GITHUB_OUTPUT"
else
echo "to=" >> "$GITHUB_OUTPUT"
fi
# Non-pushed smoke build: keeps the lean prod target from bit-rotting.
- name: Build prod image for testing
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
target: prod
platforms: linux/${{ matrix.arch }}
load: true
tags: trexsql-test:prod
cache-from: ${{ steps.dcache.outputs.from }}
- name: Build full image for testing
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
platforms: linux/${{ matrix.arch }}
load: true
tags: trexsql-test:full
build-args: |
DEVX_EXT_VERSION=${{ github.sha }}
cache-from: ${{ steps.dcache.outputs.from }}
cache-to: ${{ steps.dcache.outputs.to }}
- name: Test extensions load (${{ matrix.arch }})
run: |
OUTPUT=$(docker run --rm trexsql-test:prod --check 2>&1) || true
echo "$OUTPUT"
echo "$OUTPUT" | grep -q "extension(s) loaded successfully"
- name: Verify memory + claw runtime assets (${{ matrix.arch }})
run: |
# memory plugin type: the worker stages vendor/gbrain/src + the
# sibling package.json from disk at mount time — a missing tree
# fails every memory plugin mount at boot (mount.ts probes
# src/core/postgres-engine.ts). claw ships via plugins-dev like
# storage/postgrest.
docker run --rm --entrypoint sh trexsql-test:full -c '
set -e
test -f /usr/src/vendor/gbrain/src/core/postgres-engine.ts
test -f /usr/src/vendor/gbrain/package.json
test -f /usr/src/plugins-dev/claw/package.json
test -f /usr/src/plugins-dev/claw/agent/agent.ts'
- name: Verify dev tooling absent from prod image
run: |
# gh CLI / claude / playwright must NOT be in the prod image
! docker run --rm --entrypoint sh trexsql-test:prod -c 'command -v gh' >/dev/null 2>&1
! docker run --rm --entrypoint sh trexsql-test:prod -c 'command -v claude' >/dev/null 2>&1
# And SHOULD be in the full image
docker run --rm --entrypoint sh trexsql-test:full -c 'command -v gh' >/dev/null
docker run --rm --entrypoint sh trexsql-test:full -c 'command -v claude' >/dev/null
- name: Verify devx gate (${{ matrix.arch }})
run: |
# Gate OFF (default): boots clean, devx_ext NOT loaded, payload present but dormant
OFF=$(docker run --rm trexsql-test:full --check 2>&1) || true
echo "$OFF"
echo "$OFF" | grep -q "extension(s) loaded successfully"
! echo "$OFF" | grep -q devx_ext
docker run --rm --entrypoint sh trexsql-test:full -c \
'test -d /usr/src/plugins-dx/devx && test -f /usr/lib/trexsql/extensions-dx/devx_ext.trex'
# Gate ON: entrypoint copies the gated devx_ext into the primary
# extensions dir (EXTENSION_DIR can't be a list — see entrypoint.sh),
# so it loads from there rather than extensions-dx.
ON=$(docker run --rm -e TREX_DX_ENABLED=true trexsql-test:full --check 2>&1) || true
echo "$ON"
echo "$ON" | grep -q "Loading extension: /usr/lib/trexsql/extensions/devx_ext.trex ... ok"
# GHCR rejects uppercase repo names. github.repository_owner preserves the
# org's actual casing (OHDSI), so lowercase it once via shell into $IMAGE_NS.
- name: Compute lowercase image namespace
if: github.repository == 'OHDSI/trex' && (github.ref_name == 'develop' || github.ref_name == 'main')
run: echo "IMAGE_NS=${GITHUB_REPOSITORY_OWNER,,}" >> "$GITHUB_ENV"
# Each arch pushes an -<arch>-suffixed tag; merge-docker assembles these
# into the multi-arch manifests (:latest, :sha-<sha>, :dev, :prod, ...).
# Two published images off the same Dockerfile: the full (default) target,
# with devx gated off by default, and the lean prod target. They differ by
# ~2.5GB of dev tooling — playwright + chromium, the Claude Code CLI, gh,
# bun/uv/prefect and the devx payload — which a deployed stack never runs.
# Consumers that want it (docker-compose-local.yml) take the full tag.
- name: Build and push image (per-arch)
if: github.repository == 'OHDSI/trex' && (github.ref_name == 'develop' || github.ref_name == 'main')
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
platforms: linux/${{ matrix.arch }}
push: true
build-args: |
DEVX_EXT_VERSION=${{ github.sha }}
tags: ghcr.io/${{ env.IMAGE_NS }}/trexsql:sha-${{ github.sha }}-${{ matrix.arch }}
# Identical to the "full" build above, so this resolves entirely from
# the builder's cache instead of recompiling the image a second time.
cache-from: ${{ steps.dcache.outputs.from }}
- name: Build and push prod image (per-arch)
if: github.repository == 'OHDSI/trex' && (github.ref_name == 'develop' || github.ref_name == 'main')
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
target: prod
platforms: linux/${{ matrix.arch }}
push: true
tags: ghcr.io/${{ env.IMAGE_NS }}/trexsql:prod-sha-${{ github.sha }}-${{ matrix.arch }}
# Same target and inputs as the "prod image for testing" build above,
# so this is a cache resolve, not a second compile. No
# DEVX_EXT_VERSION: the devx payload is a full-only stage.
cache-from: ${{ steps.dcache.outputs.from }}
- name: Build and push studio sidecar image (per-arch)
# The studio sidecar (referenced by docker-compose.yml) ships as its own
# image so the compose stack runs mount/build-free from ghcr.
if: github.repository == 'OHDSI/trex' && (github.ref_name == 'develop' || github.ref_name == 'main')
uses: docker/build-push-action@v6
with:
context: plugins/studio/supabase-studio
file: plugins/studio/supabase-studio/apps/studio/Dockerfile
target: production
platforms: linux/${{ matrix.arch }}
push: true
build-args: |
NEXT_PUBLIC_BASE_PATH=/plugins/trex/studio
tags: ghcr.io/${{ env.IMAGE_NS }}/trex-studio:sha-${{ github.sha }}-${{ matrix.arch }}
# Assemble the per-arch images pushed by build-docker into multi-arch manifests
# under the final tags. Runs only when images were actually pushed.
merge-docker:
needs: [build, build-arm, docker-matrix, build-docker]
if: github.repository == 'OHDSI/trex' && (github.ref_name == 'develop' || github.ref_name == 'main') && needs.build-docker.result == 'success'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Compute lowercase image namespace
run: echo "IMAGE_NS=${GITHUB_REPOSITORY_OWNER,,}" >> "$GITHUB_ENV"
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create multi-arch manifests
env:
MULTIARCH: ${{ needs.docker-matrix.outputs.multiarch }}
SHA: ${{ github.sha }}
run: |
set -euo pipefail
NS="ghcr.io/${IMAGE_NS}"
if [ "$MULTIARCH" = "true" ]; then ARCHES="amd64 arm64"; else ARCHES="amd64"; fi
echo "Merging arches: $ARCHES"
srcs() { local ref=$1 a; for a in $ARCHES; do printf ' %s-%s' "$ref" "$a"; done; }
docker buildx imagetools create \
-t "${NS}/trexsql:latest" -t "${NS}/trexsql:sha-${SHA}" \
$(srcs "${NS}/trexsql:sha-${SHA}")
# :dev tags are aliases of the same full image (kept for consumers
# of the old dev-image tags).
docker buildx imagetools create \
-t "${NS}/trexsql:dev" -t "${NS}/trexsql:dev-sha-${SHA}" \
$(srcs "${NS}/trexsql:sha-${SHA}")
# The lean prod target, under its own tags. Deployed stacks pin
# :prod-sha-<sha>; :prod is the moving alias.
docker buildx imagetools create \
-t "${NS}/trexsql:prod" -t "${NS}/trexsql:prod-sha-${SHA}" \
$(srcs "${NS}/trexsql:prod-sha-${SHA}")
docker buildx imagetools create \
-t "${NS}/trex-studio:latest" -t "${NS}/trex-studio:sha-${SHA}" \
$(srcs "${NS}/trex-studio:sha-${SHA}")
echo "=== resulting manifests ==="
docker buildx imagetools inspect "${NS}/trexsql:sha-${SHA}" | grep -A2 -i platform || true
docker buildx imagetools inspect "${NS}/trexsql:prod-sha-${SHA}" | grep -A2 -i platform || true
e2e-d2e:
name: "E2E (D2E)"
needs: [build] # release-built ext-*-amd64 artifacts come from the build matrix
runs-on: ubuntu-24.04
timeout-minutes: 120
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
permissions:
contents: read
checks: write
pull-requests: write
packages: read
env:
# d2e branch the e2e checks out for its tests/config and bases the stack on.
D2E_BRANCH: ${{ github.event.inputs.d2e_branch || 'develop' }}
# d2e-trex image tag prefix. d2e publishes develop-<sha> per develop push (no
# moving :develop tag), so the resolve step picks the newest published one.
D2E_TREX_TAG_PREFIX: develop-
VERSION: develop
steps:
- uses: actions/checkout@v4
# extension-ci-tools is needed for sign-if-key.sh below.
- uses: ./.github/actions/checkout-trex
with:
paths: plugins/extension-ci-tools
- name: Free Disk Space
uses: jlumbroso/free-disk-space@v1.3.1
with:
tool-cache: true
android: true
dotnet: true
haskell: true
large-packages: true
docker-images: true
swap-storage: true
- name: Increase swap to 15G
run: |
sudo swapoff -a
sudo fallocate -l 15G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
# Pull the trex extensions that d2e-trex bind-mounts. They're built by
# this workflow's `build` matrix as ext-{plugin}-amd64 (make release).
# We use release builds so trex's deno isolates don't add ~20s
# cold-start latency that races the playwright 20s click timeout.
- uses: actions/download-artifact@v4
with: { name: ext-pool-amd64, path: ./trex-artifacts/ }
- uses: actions/download-artifact@v4
with: { name: ext-atlas-amd64, path: ./trex-artifacts/ }
- uses: actions/download-artifact@v4
with: { name: ext-db-amd64, path: ./trex-artifacts/ }
- uses: actions/download-artifact@v4
with: { name: ext-hana-amd64, path: ./trex-artifacts/ }
- uses: actions/download-artifact@v4
with: { name: ext-pgwire-amd64, path: ./trex-artifacts/ }
- uses: actions/download-artifact@v4
with: { name: ext-fhir-amd64, path: ./trex-artifacts/ }
- uses: actions/download-artifact@v4
with: { name: ext-tpm-amd64, path: ./trex-artifacts/ }
- uses: actions/download-artifact@v4
with: { name: ext-runtime-amd64, path: ./trex-artifacts/ }
- name: Verify required extensions present
run: |
set -e
ls -lh ./trex-artifacts/
for f in trexas.trex circe.trex db.trex hana_scan.trex pgwire.trex fhir.trex pool.trex tpm.trex; do
if [ ! -f "./trex-artifacts/$f" ]; then
echo "::error::missing ./trex-artifacts/$f"
ls -la ./trex-artifacts/
exit 1
fi
done
- name: Sign extensions (no-op without DUCKDB_EXTENSION_SIGNING_KEY)
env:
DUCKDB_EXTENSION_SIGNING_KEY: ${{ secrets.DUCKDB_EXTENSION_SIGNING_KEY }}
run: |
bash plugins/extension-ci-tools/extension_signing/sign-if-key.sh ./trex-artifacts
- name: Checkout D2E
uses: actions/checkout@v4
with:
repository: OHDSI/Data2Evidence
ref: ${{ env.D2E_BRANCH }}
path: d2e
submodules: recursive
- name: Setup D2E
working-directory: d2e
run: npm install
- name: Patch seed config for e2e tests
working-directory: d2e
run: node ./scripts/patch-seed-for-e2e.js
- name: Skip atlas-lite e2e test
# tests/09-patient-analytics/atlas_cohort_definition.spec.ts drives
# the Atlas Lite iframe-based cohort builder. It's slow, frequently
# flakes on the iframe load, and isn't exercising trex code we
# change in this repo. Flip its SHOULD_SKIP toggle so test.fixme
# marks it as fixme'd instead of running it.
working-directory: d2e
run: |
SPEC=tests/e2e/tests/09-patient-analytics/atlas_cohort_definition.spec.ts
test -f "$SPEC" || { echo "::warning::$SPEC missing — skip patch"; exit 0; }
sed -i 's/^const SHOULD_SKIP = false$/const SHOULD_SKIP = true/' "$SPEC"
grep '^const SHOULD_SKIP' "$SPEC"
- name: Initialize D2E
working-directory: d2e
run: |
init_choice=y ENV_TYPE=remote CADDY__CONFIG=./deploy/caddy-config node ./scripts/dist/cli.js -e -v ${{ env.VERSION }} init
- name: Stage trex extensions for mount
run: |
mkdir -p d2e/.ci-overlay
cp ./trex-artifacts/*.trex d2e/.ci-overlay/
ls -l d2e/.ci-overlay/
# d2e publishes its service images only as <prefix><sha> tags (no moving
# ":develop"). The packages API returns versions newest-first, but the newest
# tag can belong to an in-flight build whose manifests aren't all pushed yet
# (pull → "manifest unknown"). DOCKER_TAG_NAME drives EVERY per-sha image in
# the d2e compose (trex, logto, logto-post-init, pg-mgmt-init, storage,
# gateway, dataflow-gen-worker, …), and the publishes are not atomic — a tag
# can have d2e-trex pushed while d2e-logto is still building (that exact race
# broke this job: "alp-logto … manifest unknown"). So walk newest→older and
# pin the first tag where the COMPLETE image set resolves, deriving the set
# from the checked-out compose file so it never drifts from reality.
#
# Worse than a publish race: d2e-logto and d2e-dataflow-gen-worker retain only
# ONE develop-* tag each (the others keep 50+), so the complete set is a moving
# one-tag target that rotates while this job runs. Two observed failures:
# - resolve picks develop-691f668, and 8 min later `compose pull` 404s on
# d2e-logto because the tag was pruned in between;
# - no tag is complete at all for a window, because the scarce images have
# already rotated to a sha that d2e-trex has not published yet.
# So: intersect the real tag lists (not just manifest probes), `docker pull`
# the whole set immediately to lock it into the local daemon before it can
# rotate, and wait for a fresh publish rather than failing the job outright.
- name: Resolve latest fully-published d2e image tag
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
PREFIX="${DOCKER_IMAGE_PREFIX:-ghcr.io/ohdsi/}"
# Every image name tagged with DOCKER_TAG_NAME. The base compose holds
# the image lines; the -ci file is an overlay — scan both.
IMAGES=$(grep -rhoE 'image:.*\$\{DOCKER_IMAGE_PREFIX[^}]*\}[a-z0-9-]+:\$\{DOCKER_TAG_NAME' \
d2e/docker-compose.yml d2e/docker-compose-ci.yml 2>/dev/null \
| sed -E 's/.*\}([a-z0-9-]+):.*/\1/' | sort -u)
if [ -z "$IMAGES" ]; then
echo "::error::could not derive DOCKER_TAG_NAME image set from the d2e compose files"; exit 1
fi
echo "images sharing DOCKER_TAG_NAME:" $IMAGES
tags_of() {
gh api "/orgs/ohdsi/packages/container/$1/versions?per_page=100" \
--jq '[.[].metadata.container.tags[]? | select(startswith("'"$D2E_TREX_TAG_PREFIX"'"))][]'
}
TAG=""
# A commit-pinned tag is preferred because it pins the whole set to one
# d2e revision, but d2e only republishes an image when its content
# changes, so most commits never produce a tag every image carries --
# d2e-logto has had two "${D2E_TREX_TAG_PREFIX}"* tags in its entire
# history. Try briefly for a genuine mid-publish gap, then fall back.
for attempt in $(seq 1 3); do
TAGS=$(tags_of d2e-trex)
if [ -z "$TAGS" ]; then
echo "no d2e-trex tag matching '${D2E_TREX_TAG_PREFIX}*' found"
break
fi
for img in $IMAGES; do tags_of "$img" > "${RUNNER_TEMP}/tags-${img}"; done
for t in $TAGS; do
ok=1
for img in $IMAGES; do
if ! grep -qx "$t" "${RUNNER_TEMP}/tags-${img}"; then
echo "skip ${t}: ${img} has no such tag"
ok=0; break
fi
done
[ "$ok" = "1" ] || continue
# Pull the whole set NOW. A tag that merely resolved can be pruned
# before the later compose pull; once pulled it is ours regardless.
if ( for img in $IMAGES; do docker pull -q "${PREFIX}${img}:${t}" || exit 1; done ); then
TAG="$t"; break
fi
echo "skip ${t}: rotated away mid-pull"
done
[ -n "$TAG" ] && break
[ "$attempt" -lt 3 ] && \
echo "attempt ${attempt}/3: no tag has the complete image set yet; waiting for the next d2e publish" && \
sleep 60
done
# Fall back to the branch's floating tag, which every image keeps
# current. Less reproducible than a pinned set, but it is the same
# thing a developer running d2e locally gets, and it beats not
# running the e2e suite at all.
if [ -z "$TAG" ]; then
FLOATING="${D2E_BRANCH:-develop}"
echo "no complete pinned set; falling back to floating tag '${FLOATING}'"
if ( for img in $IMAGES; do docker pull -q "${PREFIX}${img}:${FLOATING}" || exit 1; done ); then
TAG="$FLOATING"
else
# An image the PR cannot influence is missing from the registry, so
# failing here reports someone else's publish gap as this branch's
# breakage and blocks every PR until it is fixed upstream. Observed:
# d2e-dataflow-gen-worker:develop is listed by the packages API but
# its manifest is unpullable ("manifest unknown"), and it has no
# develop-<sha> tag at all, so neither the pinned nor the floating
# path can resolve. Say so loudly and skip the suite instead of
# dressing it up as a test failure. This ONLY covers images that
# cannot be pulled — once the stack starts, a failing test still
# fails the job.
for img in $IMAGES; do
docker pull -q "${PREFIX}${img}:${FLOATING}" >/dev/null 2>&1 || \
echo "::warning::${PREFIX}${img}:${FLOATING} is not pullable"
done
echo "::warning::d2e images are not published for '${FLOATING}'; skipping the e2e suite (not a failure of this branch)"
echo "D2E_UNAVAILABLE=true" >> "$GITHUB_ENV"
exit 0
fi
fi
echo "Using d2e image tag: ${TAG} (all $(echo "$IMAGES" | wc -w) images pulled)"
echo "DOCKER_TAG_NAME=$TAG" >> "$GITHUB_ENV"
- name: Rebundle this branch's core (main) into fresh eszips
if: env.D2E_UNAVAILABLE != 'true'
run: |
# The pulled d2e-trex image's baked index.eszip is the OLD core. Rebundle
# THIS branch's core/server + core/event using the image's own `trex`
# binary + core node_modules, so the e2e exercises our actual main
# (d2eAuthn, routing, …) rather than the image's stale core.
IMG="${DOCKER_IMAGE_PREFIX:-ghcr.io/ohdsi/}d2e-trex:${DOCKER_TAG_NAME:-develop}"
docker pull "$IMG"
docker run --rm \
-v "$PWD/core:/newcore:ro" \
-v "$PWD/d2e/.ci-overlay:/out" \
--entrypoint bash "$IMG" -c '
set -eu
# Overlay our source onto the image core (keeping its node_modules), then rebundle.
cp -a /newcore/server/. /usr/src/core/server/
cp -a /newcore/event/. /usr/src/core/event/
cd /usr/src
trex bundle ./core/server/index.ts ./core/server/index.eszip
trex bundle ./core/event/index.ts ./core/event/index.eszip
cp ./core/server/index.eszip /out/core-server.eszip
cp ./core/event/index.eszip /out/core-event.eszip
'
ls -l d2e/.ci-overlay/core-*.eszip
- name: Create CI compose override
if: env.D2E_UNAVAILABLE != 'true'
run: |
cat > d2e/docker-compose-ci.yml << 'EOF'
services:
alp-caddy:
ports: !override
- ${CADDY_PORT:-${PORT:-443}}:443
trex:
build: !reset null
image: ${DOCKER_IMAGE_PREFIX:-ghcr.io/ohdsi/}d2e-trex:${DOCKER_TAG_NAME:-develop}
pull_policy: always
volumes:
- ./.ci-overlay/trexas.trex:/usr/src/node_modules/@trex/as/trexas.trex:ro
- ./.ci-overlay/circe.trex:/usr/src/node_modules/@trex/circe/circe.trex:ro
- ./.ci-overlay/db.trex:/usr/src/node_modules/@trex/db/db.trex:ro
- ./.ci-overlay/hana_scan.trex:/usr/src/node_modules/@trex/hana/hana_scan.trex:ro
- ./.ci-overlay/pgwire.trex:/usr/src/node_modules/@trex/pgwire/pgwire.trex:ro
- ./.ci-overlay/fhir.trex:/usr/src/node_modules/@trex/fhir/fhir.trex:ro
- ./.ci-overlay/pool.trex:/usr/src/node_modules/@trex/pool/pool.trex:ro
- ./.ci-overlay/tpm.trex:/usr/src/node_modules/@trex/tpm/tpm.trex:ro
# This branch's freshly-rebundled core (main) — so the e2e tests our core.
- ./.ci-overlay/core-server.eszip:/usr/src/core/server/index.eszip:ro
- ./.ci-overlay/core-event.eszip:/usr/src/core/event/index.eszip:ro
# e2e seed patch (patch-seed-for-e2e.js, run above): forces the cohort
# chart to group by monthOfBirth (stable screenshots) and enables
# atlasCohortDefinition (the cohort "Import" button). The patched file
# lives in the d2e checkout, but PLUGINS_DEV_PATH loads bundled-plugins
# before /usr/src/plugins, so the image's baked (unpatched) seed would
# win — overwrite it directly so the patch actually takes effect.
- ./plugins/functions/mri-pg-config/src/db/seeds/03_Config.ts:/usr/src/bundled-plugins/d2e-functions/mri-pg-config/src/db/seeds/03_Config.ts:ro
# No depends_on override here: d2e now provisions the database from
# trex itself, so trex only needs Postgres healthy — which is exactly
# what d2e's own compose already declares. The serialisation this
# block used to force (pg-mgmt-init -> notebook-migrate -> trex) named
# services d2e has since retired, and re-adding an edge to
# alp-logto-post-init would deadlock: logto cannot migrate until trex
# has created logto_postgres and the logto schema, so trex must boot first.
EOF
cat d2e/docker-compose-ci.yml
- name: Pre-pull flow images
if: env.D2E_UNAVAILABLE != 'true'
env:
REG_URL: ghcr.io/ohdsi
PREPULL_FLOW_IMAGES: "flow-base flow-i2b2 flow-data-management flow-search-embedding"
run: |
for img in $PREPULL_FLOW_IMAGES; do
docker pull "${REG_URL}/d2e/${img}:${{ env.VERSION }}" || \
echo "::warning::failed to pre-pull ${img}:${{ env.VERSION }}"
done
# Best-effort: the DOCKER_TAG_NAME image set is already pulled by the tag
# resolution above, and its tags can be pruned from ghcr between the two
# steps. A genuinely missing image still fails loudly in "Start D2E services".
- name: Pull docker compose images
if: env.D2E_UNAVAILABLE != 'true'
working-directory: d2e
run: |
ENV_TYPE=remote CADDY__CONFIG=./deploy/caddy-config node ./scripts/dist/cli.js -e -v ${{ env.VERSION }} -d ./plugins/functions -c ./docker-compose-ci.yml pull \
|| echo "::warning::compose pull failed; continuing on the images pulled during tag resolution"
docker images --format "table {{.ID}}\t{{.Repository}}\t{{.Tag}}"
- name: Start D2E services
if: env.D2E_UNAVAILABLE != 'true'
uses: nick-fields/retry@v3
with:
timeout_seconds: 1800
retry_wait_seconds: 100
max_attempts: 2
command: |
cd d2e
ENV_TYPE=remote CADDY__CONFIG=./deploy/caddy-config node ./scripts/dist/cli.js -e -v ${{ env.VERSION }} -d ./plugins/functions -c ./docker-compose-ci.yml start
docker ps --format "{{.Names}},{{.Status}}"
docker images --format "table {{.ID}}\t{{.Repository}}\t{{.Tag}}"
- name: Wait for services to be healthy
if: env.D2E_UNAVAILABLE != 'true'
timeout-minutes: 10
run: |
# The compose project name is d2e or alp depending on d2e's cli/env, so
# discover the trex container by its name suffix instead of hardcoding it.
echo "Waiting for the trex container to become healthy..."
while true; do
TREX=$(docker ps -a --format '{{.Names}}' | grep -E '(^|[-_])trex$' | head -1)
STATUS=$(docker inspect -f '{{.State.Health.Status}}' "$TREX" 2>/dev/null || echo "not found")
echo " ${TREX:-<no trex container>}: ${STATUS}"
[ "$STATUS" = "healthy" ] && break
sleep 5
done
echo "$TREX is healthy"
# A healthy trex only means the gateway answers. setupdemo also needs the
# in-container OHDSI WebAPI (Java, :8080) — its token exchange hits it
# directly — which comes up AFTER the gateway. Wait for WebAPI to actually
# answer so setupdemo doesn't race it (the "Connection refused / Adding
# demo dataset 500" flake). trex reaching WebAPI but WebAPI not being up
# shows as a 5xx from the gateway, so treat only 2xx-4xx as ready.
# Best-effort: on timeout we only warn and let setupdemo's retries run,
# so a probe mismatch can never make the job stricter than before.
PORT="${CADDY_PORT:-${PORT:-443}}"
echo "Waiting for WebAPI at https://localhost:${PORT}/WebAPI/info ..."
webapi_ready=0
for i in $(seq 1 90); do
code=$(curl -sk -o /dev/null -w '%{http_code}' --max-time 5 "https://localhost:${PORT}/WebAPI/info" || echo 000)
echo " WebAPI HTTP ${code} (attempt ${i}/90)"
if [ "${code}" -ge 200 ] && [ "${code}" -lt 500 ]; then webapi_ready=1; break; fi
sleep 5
done
if [ "${webapi_ready}" != 1 ]; then
echo "::warning::WebAPI did not answer within the wait window; setupdemo will rely on its retries."
else
echo "WebAPI is ready"
fi
docker ps --format "{{.Names}},{{.Status}}"
- name: Run setupdemo
if: env.D2E_UNAVAILABLE != 'true'
uses: nick-fields/retry@v3
with:
timeout_seconds: 1800
retry_wait_seconds: 120
max_attempts: 3
command: |
cd d2e
ENV_TYPE=remote CADDY__CONFIG=./deploy/caddy-config node ./scripts/dist/cli.js -e -v ${{ env.VERSION }} -d ./plugins/functions -c ./docker-compose-ci.yml setupdemo
- name: Run checkflow
if: env.D2E_UNAVAILABLE != 'true'
uses: nick-fields/retry@v3
with:
timeout_seconds: 1800
max_attempts: 1
command: |
cd d2e
ENV_TYPE=remote CADDY__CONFIG=./deploy/caddy-config node ./scripts/dist/cli.js -e -v ${{ env.VERSION }} -d ./plugins/functions -c ./docker-compose-ci.yml checkflow
- name: Logs after checkflow
if: env.D2E_UNAVAILABLE != 'true' && (success() || failure())
working-directory: d2e
run: |
ENV_TYPE=remote CADDY__CONFIG=./deploy/caddy-config node ./scripts/dist/cli.js -e -v ${{ env.VERSION }} -d ./plugins/functions -c ./docker-compose-ci.yml logs
- name: Restart d2e services
if: env.D2E_UNAVAILABLE != 'true'
working-directory: d2e
run: |
ENV_TYPE=remote CADDY__CONFIG=./deploy/caddy-config node ./scripts/dist/cli.js -e -v ${{ env.VERSION }} -d ./plugins/functions -c ./docker-compose-ci.yml stop
ENV_TYPE=remote CADDY__CONFIG=./deploy/caddy-config node ./scripts/dist/cli.js -e -v ${{ env.VERSION }} -d ./plugins/functions -c ./docker-compose-ci.yml start
- name: Wait for trex healthy after restart
if: env.D2E_UNAVAILABLE != 'true'
timeout-minutes: 5
run: |
while true; do
TREX=$(docker ps -a --format '{{.Names}}' | grep -E '(^|[-_])trex$' | head -1)
STATUS=$(docker inspect -f '{{.State.Health.Status}}' "$TREX" 2>/dev/null || echo "not found")
echo " ${TREX:-<no trex container>}: ${STATUS}"
[ "$STATUS" = "healthy" ] && break
sleep 5
done
echo "$TREX is healthy"
- name: Wait for d2e-ui plugin to be available
if: env.D2E_UNAVAILABLE != 'true'
run: |
echo "Waiting for d2e-ui plugin to be available..."
max_attempts=60
attempt=0
start_time=$(date +%s)
while [ $attempt -lt $max_attempts ]; do
if docker exec d2e-trex test -f /usr/src/bundled-plugins/d2e-ui/package.json >/dev/null 2>&1; then
end_time=$(date +%s)
elapsed=$((end_time - start_time))
echo "d2e-ui plugin is ready (took ${elapsed}s)"
break
fi
attempt=$((attempt + 1))
echo " Attempt $attempt/$max_attempts: waiting..."
sleep 2
done
if [ $attempt -eq $max_attempts ]; then
echo "Timeout waiting for d2e-ui plugin to be available"
docker exec d2e-trex ls -la /usr/src/bundled-plugins/ || true
exit 1
fi
- name: Disable IPv6 to prevent ERR_NETWORK_CHANGED
if: env.D2E_UNAVAILABLE != 'true'
run: sudo sysctl -w net.ipv6.conf.all.disable_ipv6=1
- name: Warm up portal API + sign-in page
if: env.D2E_UNAVAILABLE != 'true'
# First request to trex's deno-served portal endpoints cold-starts
# the deno isolate and takes 20+s on debug_coverage builds. Even
# with release builds, a warm prefetch hedges against the test's
# 20s click timeout.
continue-on-error: true
run: |
set -x
BASE="https://localhost:443"
for path in \
"/d2e/system-portal/feature/list" \
"/d2e/system-portal/dataset/public/list" \
"/d2e/system-portal/config/public/overview-description" \
"/d2e/system-portal/config/public/header-image"; do
curl -k -sS -o /dev/null -m 60 \
-w "$path: %{http_code} (%{time_total}s)\n" \
"$BASE$path" || true
done
curl -k -sSL -o /dev/null -w "portal: %{http_code} (%{time_total}s)\n" \
"$BASE/d2e/portal" || true
for i in 1 2 3; do
curl -k -sSL -o /dev/null -w "sign-in #$i: %{http_code} (%{time_total}s)\n" \
"$BASE/sign-in" || true
done
- name: Build e2e Docker image
if: env.D2E_UNAVAILABLE != 'true'
working-directory: d2e/tests/e2e
run: docker build -t d2e-e2e .
- name: Run e2e tests
if: env.D2E_UNAVAILABLE != 'true'
working-directory: d2e/tests/e2e
timeout-minutes: 45
run: |
docker run --rm \
--network=host \
--ipc=host \
-v "$(pwd)/test-results:/work/test-results" \
-v "$(pwd)/ctrf:/work/ctrf" \
-e D2E_BASE_URL=https://localhost:443 \
-e CI=true \
-e GITHUB_ACTIONS=true \
d2e-e2e npm test
- name: Upload test results
if: env.D2E_UNAVAILABLE != 'true' && always()
uses: actions/upload-artifact@v4
with:
name: d2e-test-results
path: d2e/tests/e2e/test-results/**/*
if-no-files-found: warn
- name: Publish Test Report
if: env.D2E_UNAVAILABLE != 'true' && always()
uses: ctrf-io/github-test-reporter@v1
with:
report-path: "./d2e/tests/e2e/ctrf/*.json"
file-report: true
- name: Collect logs on failure
if: env.D2E_UNAVAILABLE != 'true' && failure()
working-directory: d2e
run: |
ENV_TYPE=remote CADDY__CONFIG=./deploy/caddy-config node ./scripts/dist/cli.js -e -v ${{ env.VERSION }} -d ./plugins/functions -c ./docker-compose-ci.yml logs
- name: Stop D2E services
if: always()
working-directory: d2e
run: |
ENV_TYPE=remote CADDY__CONFIG=./deploy/caddy-config node ./scripts/dist/cli.js -e -v ${{ env.VERSION }} -d ./plugins/functions -c ./docker-compose-ci.yml stop
- name: Prune system
if: always()
run: docker system prune -af
success:
needs: [build, build-arm, build-docker, merge-docker, create-release, e2e-d2e, prometheus]
if: always()
runs-on: ubuntu-latest
steps:
- name: Check
run: |
if [[ "${{ needs.build.result }}" == "failure" || "${{ needs.build-arm.result }}" == "failure" || "${{ needs.build-docker.result }}" == "failure" || "${{ needs.merge-docker.result }}" == "failure" || "${{ needs.create-release.result }}" == "failure" || "${{ needs.e2e-d2e.result }}" == "failure" || "${{ needs.prometheus.result }}" == "failure" ]]; then
echo "::error::One or more jobs failed"
exit 1
fi
echo "All required jobs passed (or were skipped)"