Stop killing long agent turns at the 30-minute worker cap #748
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: 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)" |