From 2c25c8ca335035d471fcd6bef0864d7d8929a8cf Mon Sep 17 00:00:00 2001 From: Clay Townsend Date: Fri, 10 Apr 2026 14:42:27 +1000 Subject: [PATCH 01/24] improvement(ci): parallelize audit+prepare, add caching, share build artifacts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract `prepare` job: install → build:packages → generate → rebuild. Uploads compiled dist/ + .mercato/generated/ as a reusable artifact so downstream jobs skip the repeated build+generate work. - Extract `audit` job: runs in parallel with `prepare` rather than sequentially inside `test`, removing it from the critical path. - `test` job now downloads the artifact instead of rebuilding packages. - `ephemeral-integration` downloads the same artifact and skips install+build:packages×2+generate (was ~1m31s of duplicate work). - Add `cache: 'yarn'` to all setup-node@v4 steps (~45s saved per job on warm cache hits). - Add `actions/cache` for pip to avoid re-downloading markitdown on every run. Measured baseline (run 24178370484): test 7m12s, ephemeral-integration 48m47s, total wall ~55 min. Integration tests themselves account for 44m59s; sharding across 3 Playwright workers (--shard=N/3) is the next lever — see comment in the ephemeral-integration job for what that would require. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 109 +++++++++++++++++++++++++++++++++------ 1 file changed, 93 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ecb771604dc..988300a0648 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,11 @@ on: - develop jobs: - test: + # ── Shared build ──────────────────────────────────────────────────────────── + # Installs deps, compiles all packages, and runs the code generator. + # Uploads the compiled dist/ + generated .mercato/ as an artifact so every + # downstream job can skip this work entirely. + prepare: runs-on: ubuntu-latest steps: - name: Checkout repository @@ -23,19 +27,14 @@ jobs: uses: actions/setup-node@v4 with: node-version: 24 + cache: 'yarn' - name: Enable Corepack run: corepack enable - - name: Install markitdown CLI - run: python3 -m pip install --upgrade pip markitdown - - name: Install dependencies run: yarn install --immutable - - name: Audit dependencies for known CVEs - run: yarn npm audit --all --recursive --severity high - - name: Build packages run: yarn build:packages @@ -45,6 +44,76 @@ jobs: - name: Rebuild packages with generated files run: yarn build:packages + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: build-artifacts + path: | + packages/*/dist/ + apps/mercato/.mercato/generated/ + retention-days: 1 + if-no-files-found: error + + # ── Security audit ────────────────────────────────────────────────────────── + # Runs in parallel with 'prepare' — only needs yarn install, not the full build. + audit: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: 'yarn' + + - name: Enable Corepack + run: corepack enable + + - name: Install dependencies + run: yarn install --immutable + + - name: Audit dependencies for known CVEs + run: yarn npm audit --all --recursive --severity high + + # ── Quality checks ────────────────────────────────────────────────────────── + # Typechecking, unit tests, i18n sync, and the Next.js app build. + # Blocked on both 'prepare' (needs compiled packages) and 'audit' (security gate). + test: + runs-on: ubuntu-latest + needs: [prepare, audit] + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: 'yarn' + + - name: Enable Corepack + run: corepack enable + + - name: Cache pip packages + uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: pip-markitdown-v1 + restore-keys: pip-markitdown- + + - name: Install markitdown CLI + run: python3 -m pip install --upgrade pip markitdown + + - name: Install dependencies + run: yarn install --immutable + + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + name: build-artifacts + - name: Check dependency version conflicts run: yarn check:dep-versions @@ -64,6 +133,14 @@ jobs: - name: Build run: yarn build:app + # ── Integration tests ─────────────────────────────────────────────────────── + # Boots an ephemeral app server and runs the full Playwright suite. + # Blocked on 'test' so integration only runs when unit tests are green. + # + # Performance note: at 311 spec files with workers: 1, this job accounts for + # ~45 of the ~55 min total wall time. Playwright sharding across 3 parallel + # runners (--shard=N/3) would cut this to ~16 min but requires each shard + # to start its own ephemeral server and a final step to merge coverage JSON. ephemeral-integration: runs-on: ubuntu-latest needs: test @@ -81,6 +158,7 @@ jobs: uses: actions/setup-node@v4 with: node-version: 24 + cache: 'yarn' - name: Enable Corepack run: corepack enable @@ -88,14 +166,10 @@ jobs: - name: Install dependencies run: yarn install --immutable - - name: Build packages - run: yarn build:packages - - - name: Prepare generated modules - run: yarn generate - - - name: Rebuild packages with generated files - run: yarn build:packages + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + name: build-artifacts - name: Build app run: yarn workspace @open-mercato/app build @@ -149,7 +223,7 @@ jobs: \`| Functions | \${t.functions?.covered ?? 0}/\${t.functions?.total ?? 0} | \${t.functions?.pct ?? 0}% |\`, \`| Branches | \${t.branches?.covered ?? 0}/\${t.branches?.total ?? 0} | \${t.branches?.pct ?? 0}% |\`, '', - 'Source: `.ai/qa/test-results/coverage/code/coverage-summary.json`', + 'Source: \`.ai/qa/test-results/coverage/code/coverage-summary.json\`', '', ].join('\n')); " "$SUMMARY_FILE" @@ -165,6 +239,9 @@ jobs: .ai/qa/test-results/results.json if-no-files-found: ignore + # ── Docker image builds ────────────────────────────────────────────────────── + # Validates all Dockerfiles build cleanly. Runs in parallel with + # 'ephemeral-integration' (both need test), so it does not add to wall time. docker-build: runs-on: ubuntu-latest needs: test From b4340e280f6b5b589169ef42a32ee17d1cd5c3e8 Mon Sep 17 00:00:00 2001 From: Clay Townsend Date: Fri, 10 Apr 2026 15:22:09 +1000 Subject: [PATCH 02/24] improvement(ci): add concurrency groups, enable Turbo cache, wire remote cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Concurrency groups: - ci.yml: cancel-in-progress on same branch — stops stale 55-min runs from completing when a new push arrives - snapshot.yml: cancel-in-progress — prevents double npm publishes from two rapid pushes to develop Turbo cache: - Remove globalPassThroughEnv: ["*"] which made every env var part of the cache key, giving a near-zero hit rate in CI - Replace with globalEnv: ["NODE_ENV"] — the only env var that legitimately affects compiled output - Enable cache: true for build and typecheck tasks - Verified locally: second build run hits 18/18 packages from cache in 478ms vs 2.7s cold (83% faster, scales to minutes saved in CI) Remote cache wiring: - Add TURBO_TOKEN + TURBO_TEAM env vars to build/typecheck steps (optional — falls back to local GHA cache when secrets are absent) - Add actions/cache on .turbo/ with branch-scoped restore-keys so cross-commit cache hits work within the same branch, and fallback to any previous run serves as a warm start for new branches Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 33 +++++++++++++++++++++++++++++++++ .github/workflows/snapshot.yml | 6 ++++++ turbo.json | 6 +++--- 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 988300a0648..e4f9b45b194 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,12 @@ on: - main - develop +# Cancel any in-progress run on the same branch when a new push arrives. +# Prevents multiple 55-min runs queuing up from rapid pushes. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + jobs: # ── Shared build ──────────────────────────────────────────────────────────── # Installs deps, compiles all packages, and runs the code generator. @@ -32,17 +38,32 @@ jobs: - name: Enable Corepack run: corepack enable + - name: Cache Turbo build outputs + uses: actions/cache@v4 + with: + path: .turbo + key: turbo-${{ runner.os }}-${{ github.sha }} + restore-keys: | + turbo-${{ runner.os }}-${{ github.ref_name }}- + turbo-${{ runner.os }}- + - name: Install dependencies run: yarn install --immutable - name: Build packages run: yarn build:packages + env: + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} + TURBO_TEAM: ${{ secrets.TURBO_TEAM }} - name: Prepare generated modules run: yarn generate - name: Rebuild packages with generated files run: yarn build:packages + env: + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} + TURBO_TEAM: ${{ secrets.TURBO_TEAM }} - name: Upload build artifacts uses: actions/upload-artifact@v4 @@ -109,6 +130,15 @@ jobs: - name: Install dependencies run: yarn install --immutable + - name: Cache Turbo build outputs + uses: actions/cache@v4 + with: + path: .turbo + key: turbo-${{ runner.os }}-${{ github.sha }} + restore-keys: | + turbo-${{ runner.os }}-${{ github.ref_name }}- + turbo-${{ runner.os }}- + - name: Download build artifacts uses: actions/download-artifact@v4 with: @@ -126,6 +156,9 @@ jobs: - name: Checking types run: yarn typecheck + env: + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} + TURBO_TEAM: ${{ secrets.TURBO_TEAM }} - name: Test run: yarn test diff --git a/.github/workflows/snapshot.yml b/.github/workflows/snapshot.yml index f7747f4c436..d3536a243c2 100644 --- a/.github/workflows/snapshot.yml +++ b/.github/workflows/snapshot.yml @@ -10,6 +10,12 @@ permissions: contents: read pull-requests: write +# Cancel any in-progress snapshot run for the same branch on new pushes. +# Without this, two rapid develop pushes publish the same tag twice. +concurrency: + group: snapshot-${{ github.ref }} + cancel-in-progress: true + jobs: snapshot: name: Publish Snapshot diff --git a/turbo.json b/turbo.json index fbb97783c17..d723ced1d46 100644 --- a/turbo.json +++ b/turbo.json @@ -1,9 +1,9 @@ { "$schema": "https://turbo.build/schema.json", - "globalPassThroughEnv": ["*"], + "globalEnv": ["NODE_ENV"], "tasks": { "build": { - "cache": false, + "cache": true, "outputs": ["dist/**", ".next/**", "!.next/cache/**"] }, "start": { @@ -27,7 +27,7 @@ "persistent": true }, "typecheck": { - "cache": false, + "cache": true, "outputs": [] }, "test": { From f5d5d7dbb947f4f9a132f2f18c71075eeb6561ea Mon Sep 17 00:00:00 2001 From: Clay Townsend Date: Fri, 10 Apr 2026 15:28:10 +1000 Subject: [PATCH 03/24] fix(ci): use cancel-in-progress: false for snapshot releases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cancel-in-progress: true would cancel a snapshot run mid-publish if a new push arrived, leaving some packages at the new version and others at the previous one — an inconsistent npm registry state. Queue (cancel-in-progress: false) is the correct behaviour: each publish completes atomically before the next one starts. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/snapshot.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/snapshot.yml b/.github/workflows/snapshot.yml index d3536a243c2..e115a089b5e 100644 --- a/.github/workflows/snapshot.yml +++ b/.github/workflows/snapshot.yml @@ -10,11 +10,13 @@ permissions: contents: read pull-requests: write -# Cancel any in-progress snapshot run for the same branch on new pushes. -# Without this, two rapid develop pushes publish the same tag twice. +# Serialize snapshot runs per branch — queue rather than cancel. +# cancel-in-progress: false is intentional: cancelling mid-publish would +# leave some packages at the new version and others at the previous one, +# making the npm registry inconsistent. Queuing is the safe behaviour. concurrency: group: snapshot-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: false jobs: snapshot: From 2c7c33625389687fed4de4e2ffd94a1a90e6bbc4 Mon Sep 17 00:00:00 2001 From: Clay Townsend Date: Fri, 10 Apr 2026 16:09:20 +1000 Subject: [PATCH 04/24] improvement(ci): affected-only build, typecheck, and unit tests on PRs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On pull requests, Turbo now scopes build, typecheck, and test tasks to only packages that changed relative to the base branch — plus their transitive dependents. On pushes to main/develop, the full suite runs. Implementation details: - Fetch the base branch at depth=1 before running Turbo so git can compute the diff (shallow clone cannot reach origin/main otherwise) - Use --filter=[origin/]... to scope to affected packages - Add --filter='!./apps/*' to exclude apps from the package build steps: Turbo's multiple --filter flags use union semantics, so without the negation, apps are included as dependents of changed packages and fail because generated files don't exist at that point in the job - generate and build:app always run fully — generate is global module discovery; build:app is required for integration tests downstream Expected impact on a PR touching 1 module: build:packages 2m30s → ~5s (17 cache hits + 1 rebuild) typecheck 2m04s → ~5s (only affected packages) unit tests 1m17s → ~5s (only affected packages) Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 54 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 50 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e4f9b45b194..74c991fabc4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,12 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 + - name: Fetch base branch for change detection + # Only needed on PRs — gives Turbo enough history to compute + # which packages changed relative to the target branch. + if: github.event_name == 'pull_request' + run: git fetch origin ${{ github.base_ref }} --depth=1 + - name: Setup Node.js uses: actions/setup-node@v4 with: @@ -51,16 +57,37 @@ jobs: run: yarn install --immutable - name: Build packages - run: yarn build:packages + # On PRs: turbo run build scoped to affected packages only, apps excluded. + # Multiple --filter flags use union semantics, so we must exclude apps + # explicitly — they would otherwise be included as dependents of changed + # packages, and they fail here because generated files don't exist yet. + # On pushes to protected branches: full build, no filter. + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + yarn turbo run build \ + --filter=[origin/${{ github.base_ref }}]... \ + --filter='!./apps/*' + else + yarn build:packages + fi env: TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_TEAM: ${{ secrets.TURBO_TEAM }} - name: Prepare generated modules + # Always run fully — generator discovers all modules regardless of + # what changed, and its output is consumed by the app, not packages. run: yarn generate - name: Rebuild packages with generated files - run: yarn build:packages + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + yarn turbo run build \ + --filter=[origin/${{ github.base_ref }}]... \ + --filter='!./apps/*' + else + yarn build:packages + fi env: TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_TEAM: ${{ secrets.TURBO_TEAM }} @@ -108,6 +135,10 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 + - name: Fetch base branch for change detection + if: github.event_name == 'pull_request' + run: git fetch origin ${{ github.base_ref }} --depth=1 + - name: Setup Node.js uses: actions/setup-node@v4 with: @@ -155,15 +186,30 @@ jobs: continue-on-error: true - name: Checking types - run: yarn typecheck + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + yarn turbo run typecheck \ + --filter=[origin/${{ github.base_ref }}]... \ + --filter='!./apps/*' + else + yarn typecheck + fi env: TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_TEAM: ${{ secrets.TURBO_TEAM }} - name: Test - run: yarn test + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + yarn turbo run test \ + --filter=[origin/${{ github.base_ref }}]... \ + --filter='!./apps/*' + else + yarn test + fi - name: Build + # Always build the full app — integration tests need a complete build. run: yarn build:app # ── Integration tests ─────────────────────────────────────────────────────── From 3220a846fc2b3886147a41c2b8276401be2a1b30 Mon Sep 17 00:00:00 2001 From: Clay Townsend Date: Fri, 10 Apr 2026 19:20:13 +1000 Subject: [PATCH 05/24] fix(ci): remove affected filter from prepare, fix app exclusion in test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two correctness issues in the previous commit: 1. Filtering build:packages in prepare produces an incomplete artifact. Downstream jobs (test, ephemeral-integration) download the artifact expecting every package's dist/ to be present. A filtered build skips unaffected packages entirely — Turbo only restores cache outputs for packages it actually runs, so excluded packages never land on disk. Fix: prepare always builds all packages. Turbo cache handles the speedup — unchanged packages are cache hits restored in milliseconds, so the artifact is always complete and the build is still fast. 2. The --filter='!./apps/*' negation in typecheck/test silently skipped the app even when app-level source files changed. Generated files are available in the test job (artifact download includes .mercato/generated/), so the app typecheck is safe to include when the app is in scope. Fix: drop the negation. The git-based filter correctly includes the app only when it or something it depends on has changed. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 52 +++++++++++++--------------------------- 1 file changed, 16 insertions(+), 36 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 74c991fabc4..62ec4b56148 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,12 +29,6 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - - name: Fetch base branch for change detection - # Only needed on PRs — gives Turbo enough history to compute - # which packages changed relative to the target branch. - if: github.event_name == 'pull_request' - run: git fetch origin ${{ github.base_ref }} --depth=1 - - name: Setup Node.js uses: actions/setup-node@v4 with: @@ -57,37 +51,22 @@ jobs: run: yarn install --immutable - name: Build packages - # On PRs: turbo run build scoped to affected packages only, apps excluded. - # Multiple --filter flags use union semantics, so we must exclude apps - # explicitly — they would otherwise be included as dependents of changed - # packages, and they fail here because generated files don't exist yet. - # On pushes to protected branches: full build, no filter. - run: | - if [ "${{ github.event_name }}" = "pull_request" ]; then - yarn turbo run build \ - --filter=[origin/${{ github.base_ref }}]... \ - --filter='!./apps/*' - else - yarn build:packages - fi + # Always builds all packages — no filter. Turbo cache handles the speedup: + # unchanged packages are cache hits and restored to disk in milliseconds. + # Filtering here would produce an incomplete artifact, breaking downstream + # jobs that need every package's dist/ output (test, ephemeral-integration). + run: yarn build:packages env: TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_TEAM: ${{ secrets.TURBO_TEAM }} - name: Prepare generated modules - # Always run fully — generator discovers all modules regardless of - # what changed, and its output is consumed by the app, not packages. + # Always run fully — generator discovers all modules regardless of what + # changed, and its output is consumed by the app, not packages. run: yarn generate - name: Rebuild packages with generated files - run: | - if [ "${{ github.event_name }}" = "pull_request" ]; then - yarn turbo run build \ - --filter=[origin/${{ github.base_ref }}]... \ - --filter='!./apps/*' - else - yarn build:packages - fi + run: yarn build:packages env: TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_TEAM: ${{ secrets.TURBO_TEAM }} @@ -136,6 +115,7 @@ jobs: uses: actions/checkout@v4 - name: Fetch base branch for change detection + # Needed so Turbo can compute which packages changed vs. the PR base. if: github.event_name == 'pull_request' run: git fetch origin ${{ github.base_ref }} --depth=1 @@ -186,11 +166,13 @@ jobs: continue-on-error: true - name: Checking types + # On PRs: scope to packages/app that changed since the base branch. + # Generated files are available from the artifact download, so the app + # can be typechecked safely if app-level code changed. + # On pushes to protected branches: full typecheck, no filter. run: | if [ "${{ github.event_name }}" = "pull_request" ]; then - yarn turbo run typecheck \ - --filter=[origin/${{ github.base_ref }}]... \ - --filter='!./apps/*' + yarn turbo run typecheck --filter=[origin/${{ github.base_ref }}]... else yarn typecheck fi @@ -199,17 +181,15 @@ jobs: TURBO_TEAM: ${{ secrets.TURBO_TEAM }} - name: Test + # Same scoping as typecheck — affected packages only on PRs. run: | if [ "${{ github.event_name }}" = "pull_request" ]; then - yarn turbo run test \ - --filter=[origin/${{ github.base_ref }}]... \ - --filter='!./apps/*' + yarn turbo run test --filter=[origin/${{ github.base_ref }}]... else yarn test fi - name: Build - # Always build the full app — integration tests need a complete build. run: yarn build:app # ── Integration tests ─────────────────────────────────────────────────────── From 8aad5d362f9a5ead734397a52ad9700911330826 Mon Sep 17 00:00:00 2001 From: Clay Townsend Date: Fri, 10 Apr 2026 19:28:17 +1000 Subject: [PATCH 06/24] fix(ci): replace cache: 'yarn' with explicit .yarn/cache step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setup-node@v4's cache: 'yarn' calls `yarn config get cacheFolder` before corepack enable runs, which invokes Yarn 1 (1.22.22) — not Yarn 4. With packageManager: "yarn@4.12.0" in package.json, Yarn 1 aborts immediately. Fix: remove cache: 'yarn' from all four jobs (prepare, audit, test, ephemeral-integration) and add an explicit actions/cache step on .yarn/cache after corepack enable. Cache key is yarn.lock hash with runner OS prefix. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 62ec4b56148..d812e4d005e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,11 +33,17 @@ jobs: uses: actions/setup-node@v4 with: node-version: 24 - cache: 'yarn' - name: Enable Corepack run: corepack enable + - name: Cache Yarn packages + uses: actions/cache@v4 + with: + path: .yarn/cache + key: yarn-${{ runner.os }}-${{ hashFiles('yarn.lock') }} + restore-keys: yarn-${{ runner.os }}- + - name: Cache Turbo build outputs uses: actions/cache@v4 with: @@ -93,11 +99,17 @@ jobs: uses: actions/setup-node@v4 with: node-version: 24 - cache: 'yarn' - name: Enable Corepack run: corepack enable + - name: Cache Yarn packages + uses: actions/cache@v4 + with: + path: .yarn/cache + key: yarn-${{ runner.os }}-${{ hashFiles('yarn.lock') }} + restore-keys: yarn-${{ runner.os }}- + - name: Install dependencies run: yarn install --immutable @@ -123,11 +135,17 @@ jobs: uses: actions/setup-node@v4 with: node-version: 24 - cache: 'yarn' - name: Enable Corepack run: corepack enable + - name: Cache Yarn packages + uses: actions/cache@v4 + with: + path: .yarn/cache + key: yarn-${{ runner.os }}-${{ hashFiles('yarn.lock') }} + restore-keys: yarn-${{ runner.os }}- + - name: Cache pip packages uses: actions/cache@v4 with: @@ -217,11 +235,17 @@ jobs: uses: actions/setup-node@v4 with: node-version: 24 - cache: 'yarn' - name: Enable Corepack run: corepack enable + - name: Cache Yarn packages + uses: actions/cache@v4 + with: + path: .yarn/cache + key: yarn-${{ runner.os }}-${{ hashFiles('yarn.lock') }} + restore-keys: yarn-${{ runner.os }}- + - name: Install dependencies run: yarn install --immutable From a623e23a6b239ba18154be06ab2f72f85b329438 Mon Sep 17 00:00:00 2001 From: Clay Townsend Date: Fri, 10 Apr 2026 19:37:58 +1000 Subject: [PATCH 07/24] fix(ci): include packages/*/generated/ in build artifact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TypeScript resolves #generated/* imports via the packages/core/package.json imports field, where the 'types' condition points to source .ts files in packages/core/generated/ (not dist/). The artifact previously only uploaded packages/*/dist/ and apps/mercato/.mercato/generated/, leaving these source TypeScript generated files off disk in the test job — causing: Cannot find module '#generated/entities.ids.generated' Affected packages with a generated/ dir: core, onboarding, scheduler, integration-cozystack. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d812e4d005e..3761fe2e357 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,6 +83,7 @@ jobs: name: build-artifacts path: | packages/*/dist/ + packages/*/generated/ apps/mercato/.mercato/generated/ retention-days: 1 if-no-files-found: error From f5b3e273eff05eef7143318b58c62dd0e6c4ab1e Mon Sep 17 00:00:00 2001 From: Clay Townsend Date: Fri, 10 Apr 2026 19:56:28 +1000 Subject: [PATCH 08/24] docs(ci-spec): sync Phase 0 and snapshot concurrency with actual implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Phase 0: replace incorrect 'cache: yarn' checkbox with the actual fix (explicit actions/cache on .yarn/cache after corepack enable) and document why setup-node cache: yarn cannot be used with Yarn Berry + Corepack. - Phase 0: add packages/*/generated/ artifact note — required for #generated/* Node.js subpath imports whose types condition points to source .ts files. - Section 5.5: correct snapshot.yml concurrency to cancel-in-progress: false with explanation — cancelling mid-publish leaves partial npm packages. Co-Authored-By: Claude Sonnet 4.6 --- .ai/specs/2026-04-10-ci-cd-performance.md | 555 ++++++++++++++++++++++ 1 file changed, 555 insertions(+) create mode 100644 .ai/specs/2026-04-10-ci-cd-performance.md diff --git a/.ai/specs/2026-04-10-ci-cd-performance.md b/.ai/specs/2026-04-10-ci-cd-performance.md new file mode 100644 index 00000000000..54c514038eb --- /dev/null +++ b/.ai/specs/2026-04-10-ci-cd-performance.md @@ -0,0 +1,555 @@ +# CI/CD Performance — Current State, Analysis & Proposed Changes + +**Date:** 2026-04-10 +**Status:** Draft +**Scope:** All GitHub Actions workflows, Turborepo configuration, Docker build pipeline, integration test architecture + +--- + +## 1. What the CI/CD System Currently Does + +There are five GitHub Actions workflows. Each has a distinct purpose. + +### 1.1 `ci.yml` — Main Quality Gate + +**Triggers:** Push or PR to `main` or `develop`. + +**Purpose:** Ensure the codebase compiles, passes static analysis, unit tests, and integration tests before any code lands on a protected branch. + +**Job graph (post-refactor, current branch state):** + +``` +prepare ──┐ + ├──► test ──► ephemeral-integration +audit ──┘ └──► docker-build +``` + +| Job | What it does | Why | +|-----|-------------|-----| +| `prepare` | Install deps, build all packages twice (before and after `generate`), upload `dist/` + `.mercato/generated/` as artifact | Packages must be compiled before any other job can typecheck or test them. The double build is required because `generate` produces TypeScript files that packages then import. | +| `audit` | Install deps, `yarn npm audit --severity high` | Security gate — run in parallel with `prepare` since it only needs `yarn.lock`, not built packages. | +| `test` | Download artifact, install markitdown, run dep-version check, i18n sync/usage check, `tsc --noEmit`, Jest unit tests, Next.js app build | Validates code correctness without a live server. Must run after `prepare` (needs compiled packages) and `audit` (security gate). | +| `ephemeral-integration` | Download artifact, build Next.js app, install Playwright, boot the full app in-process, run 311 Playwright spec files with `workers: 1` | End-to-end validation that modules interact correctly. Only runs when unit tests are green. | +| `docker-build` | Build three Dockerfiles using GitHub Actions layer cache | Validates production images build cleanly. Runs in parallel with ephemeral-integration. Not on critical path. | + +**Measured wall times (run 24178370484):** + +``` +prepare: ~2m30s (estimated, not yet broken out) +audit: ~1m20s (parallel) +test: 7m12s + ├─ yarn install 1m03s + ├─ typecheck 2m04s + ├─ unit tests 1m17s + └─ build:app 1m38s +ephemeral-integration: 48m47s + ├─ yarn install 1m05s + ├─ download artifact ~5s + ├─ build app 1m37s + ├─ playwright install 25s + └─ integration tests 44m59s ← 82% of total wall time +docker-build: 16m17s (parallel, not on critical path) + +TOTAL WALL TIME: ~55 minutes +``` + +### 1.2 `snapshot.yml` — Canary npm Releases + +**Triggers:** Push to `develop`, or PR targeting `develop` or `main` (non-fork only). + +**Purpose:** Publish a timestamped snapshot version of all public packages to npm with a canary dist-tag after every develop commit. Also validates that a consumer scaffolding a fresh app from the published snapshot can build and run integration tests successfully. + +**Job graph:** + +``` +snapshot ──► standalone-integration +``` + +| Job | What it does | +|-----|-------------| +| `snapshot` | Install deps, compute channel/tag from branch/event, run `release-snapshot.sh` to bump versions + publish to npm, comment on PR with published versions | +| `standalone-integration` | Scaffold a brand new app via `create-mercato-app@`, wait for npm propagation (up to 5 minutes per package), configure env, build and start the app, run Playwright integration tests against it | + +**Key design choice:** The standalone integration test validates that the *published npm packages* work correctly in a real consumer project — not just the monorepo source. This catches issues like missing exports, bad `package.json` `exports` fields, or mismatched peer dependencies that would not show up in monorepo integration tests. + +**No concurrency group.** Two rapid pushes to `develop` both publish to npm. The second publish overwrites the first with the same tag. + +### 1.3 `release.yml` — Production npm Releases + +**Triggers:** Manual `workflow_dispatch` with `patch` / `minor` / `major` input. + +**Purpose:** Publish a versioned production release to npm. Requires the `production` GitHub Environment to be configured with mandatory reviewers — prevents a single compromised account from publishing unilaterally. + +**Key protections:** +- Only runs from `main` branch +- Requires human approval (GitHub Environment gate) +- Has a concurrency group (`${{ github.workflow }}-${{ github.ref }}`) — only one release can run at a time +- Creates a git tag and GitHub Release automatically + +### 1.4 `qa-deploy.yml` — QA Environment Deployment + +**Triggers:** Manual `workflow_dispatch`. Inputs: slot (`qa1` or `qa2`), branch, optional PR number. + +**Purpose:** Build a Docker image from any branch and deploy it to a Dokploy-managed QA environment. Labels the PR and posts a comment with the deployment URL and image tag. + +**Key design choice:** Two fixed QA slots rather than ephemeral preview environments. Slots are re-used across PRs, and the `qa-stop-on-merge.yml` workflow verifies the expected image matches before stopping to avoid race conditions. + +**Concurrency group:** `dokploy-${{ slot }}` with `cancel-in-progress: false` — queues slot updates rather than cancelling. Safe because a cancelled deploy mid-flight would leave the slot in an unknown state. + +### 1.5 `qa-stop-on-merge.yml` — QA Slot Cleanup + +**Triggers:** Every PR `closed` event. + +**Purpose:** When a PR that was deployed to a QA slot is merged or closed, stop the Dokploy application to reclaim resources. Guards against stopping the wrong deployment by comparing the PR's deploy comment image tag against what Dokploy currently has running. + +--- + +## 2. Why the Current Implementation Is the Way It Is + +### 2.1 Double `yarn build:packages` + +The code generator (`yarn generate`) runs in the context of `@open-mercato/app` and produces TypeScript files in `apps/mercato/.mercato/generated/`. These generated files import types from packages like `@open-mercato/core`. Therefore: + +1. Packages must be built first so the generator can import them (build #1) +2. Generator runs, producing new TypeScript source files +3. Packages that import generated types must be rebuilt against the new files (build #2) + +This is not redundancy — it is a genuine two-pass compilation requirement. + +### 2.2 Turbo `cache: false` Everywhere + +`turbo.json` has `"cache": false` on every task. The reason is `"globalPassThroughEnv": ["*"]` — Turbo's cache key includes all environment variables, and passing through every env var means any change to any env var busts the cache. With `*` pass-through, Turbo's cache would have a near-zero hit rate in CI (different secrets, different `GITHUB_RUN_ID`, etc.), making it worse than no cache at all (wasted time checking stale entries). + +The root fix is to replace `"*"` with an explicit list of env vars that actually affect build output. + +### 2.3 Integration Tests with `workers: 1` + +Playwright runs tests sequentially because the integration tests share a single ephemeral server instance and a single SQLite database. Running multiple workers against the same DB would cause test interference — e.g., one test deleting a record another test expects to exist. The current design optimises for correctness over speed. + +### 2.4 No Concurrency Groups on `ci.yml` + +This appears to be an oversight — every other workflow that could have concurrent runs has a concurrency group (`release.yml`, `qa-deploy.yml`). `ci.yml` and `snapshot.yml` do not. + +### 2.5 QA Slot Image Comparison Before Stop + +The guard in `qa-stop-on-merge.yml` that compares the expected image (from the PR comment marker) against what Dokploy currently has running is intentional: two PRs can share a slot (the second deploy overwrites the first), and merging the first PR should not stop the second PR's environment. + +--- + +## 3. Current Developer Lifecycle + +### Opening a PR + +1. Developer pushes a branch and opens a PR targeting `main` or `develop` +2. CI triggers immediately: + - `snapshot.yml` publishes a canary npm version (non-fork PRs only) and posts a comment with installable versions + - `ci.yml` starts the quality gate +3. Developer waits **~55 minutes** for CI to complete +4. If CI passes and reviews are approved, the PR is mergeable + +### Iterating on a PR + +Each additional push to the branch re-triggers both workflows. With no concurrency cancellation, if a developer pushes 3 times in 10 minutes, all 3 CI runs complete fully. The developer is waiting 55 minutes from the last push before they know if everything is green. + +### Deploying to QA + +1. Developer manually triggers `qa-deploy.yml` via GitHub Actions UI, selecting a slot and branch +2. Workflow builds a Docker image from the branch (~15 min), pushes to GHCR, updates Dokploy, and triggers a deploy +3. PR is labelled `qa:qa1` or `qa:qa2` and a comment is posted with the image tag +4. On PR merge or close, `qa-stop-on-merge.yml` stops the Dokploy application + +### Releasing to Production + +1. Maintainer manually triggers `release.yml` with patch/minor/major +2. GitHub requires approval from a configured reviewer in the `production` environment +3. After approval, versions are bumped, packages published to npm, git tag created, GitHub Release created + +--- + +## 4. Root Cause Analysis: Why CI Takes 55 Minutes + +The problem has three layers: + +### Layer 1: Integration tests run everything every time (80% of wall time) + +All 311 integration spec files run on every push, regardless of what changed. A 3-line fix to `packages/core/src/modules/sales/` triggers tests for `auth`, `catalog`, `customers`, `currencies`, and 60+ other modules that were not touched. + +### Layer 2: Turbo caching is entirely disabled + +With `cache: false` on all tasks, every run rebuilds every package from scratch. A build that took 9 seconds on the previous identical commit takes 9 seconds again. There is no incremental compilation, no cross-run reuse, no cross-branch sharing. + +### Layer 3: Ephemeral environment setup repeated across jobs + +Before our recent refactor, `ephemeral-integration` re-ran `yarn install` (1m05s) + `build:packages` × 2 + `generate` (26s) from scratch. The refactor addressed this with artifact sharing — this layer is partially resolved. + +### Combined effect + +``` +Change 1 file → rebuild 14+ packages → rerun 311 tests → 55 min +``` + +--- + +## 5. Proposed Changes + +### 5.1 Turbo Cache: Fix `globalPassThroughEnv` and Enable Caching + +**Change:** Replace `"globalPassThroughEnv": ["*"]` with an explicit allowlist of env vars that actually affect build output. Enable `"cache": true` for `build` and `typecheck`. + +```jsonc +// turbo.json +{ + "$schema": "https://turbo.build/schema.json", + "globalPassThroughEnv": [ + "NODE_ENV", + "NODE_OPTIONS", + "TURBO_TOKEN", + "TURBO_TEAM" + ], + "tasks": { + "build": { + "cache": true, + "outputs": ["dist/**", ".next/**", "!.next/cache/**"] + }, + "typecheck": { + "cache": true, + "outputs": [".tsbuildinfo"] + }, + "generate": { + "cache": false, // depends on module discovery — keep uncached + "outputs": [".mercato/**"] + } + // test, lint: cache: false is correct (side-effecting) + } +} +``` + +**Pair with Turbo remote cache.** Turbo's remote cache (Vercel free tier, or self-hosted `ducktape` / `turborepo-remote-cache`) shares build artifacts across branches. Branch A and branch B that both leave `packages/shared` untouched will both get a cache hit for `shared`'s build. + +**Add to CI workflows:** + +```yaml +env: + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} + TURBO_TEAM: ${{ secrets.TURBO_TEAM }} +``` + +**Expected impact:** On a cache hit for unchanged packages, build time drops from ~2m30s to ~5–10s. For a typical PR touching 1–3 packages, 11+ other packages are cache hits. + +--- + +### 5.2 Affected-Only Execution: `--filter=[origin/main]` + +**Change:** Add `--filter=[origin/main]...` to all build and test commands in CI. Turbo will walk the dependency graph and only run tasks for packages whose source files changed since the last commit on `main`. + +```yaml +# ci.yml — prepare job +- name: Build packages + run: yarn build:packages --filter=[origin/main]... + +- name: Prepare generated modules + run: yarn generate + # generate always runs (module discovery is global) + +- name: Rebuild packages with generated files + run: yarn build:packages --filter=[origin/main]... + +# ci.yml — test job +- name: Checking types + run: yarn typecheck --filter=[origin/main]... + +- name: Test + run: yarn test --filter=[origin/main]... +``` + +**Note:** `generate` and `build:app` must always run fully — generate discovers all modules, and the app depends on all packages. + +**Expected impact:** + +| PR changes | Packages built | Packages tested | +|---|---|---| +| 1 module in `packages/core` | 1–3 packages | 1–3 packages | +| `packages/shared` | All (everything depends on shared) | All | +| `packages/ui` only | `packages/ui` + `apps/mercato` | `packages/ui` | + +For a typical PR: build time 2m30s → **15–30s**, unit test time 1m17s → **5–15s**. + +--- + +### 5.3 Affected-Only Integration Tests + +**Change:** Extend the integration test CLI to accept a `--modules` flag. The CI workflow computes affected module names from the git diff and passes them to the test runner, which filters `discoverIntegrationSpecFiles` output. + +```bash +# Compute changed module names from git diff +CHANGED_MODULES=$(git diff origin/main --name-only \ + | grep -oP 'packages/core/src/modules/\K[^/]+' \ + | sort -u \ + | paste -sd,) + +# If nothing module-specific changed, run full suite +if [ -z "$CHANGED_MODULES" ]; then + yarn test:integration:coverage +else + yarn test:integration:coverage --modules="$CHANGED_MODULES" +fi +``` + +The `mercato test:integration:coverage` CLI command passes extra args through to the test runner. The `discoverIntegrationSpecFiles` function in `packages/cli/src/lib/testing/integration-discovery.ts` already groups spec files by module name — filtering by module is a small extension. + +**Expected impact:** + +| PR changes | Specs run | Time | +|---|---|---| +| 1 module (e.g., `sales`) | ~20–30 specs | ~2–4 min | +| 3 modules | ~60–90 specs | ~6–9 min | +| `packages/shared` or `packages/core` root | All 311 specs | ~45 min (full run) | +| No module files changed (docs, scripts, CI) | 0 specs | ~30s (skip) | + +--- + +### 5.4 Playwright Sharding for Full Runs + +For pushes to `main` and `develop` (where the full suite must run), shard integration tests across parallel runners. + +**Change:** Use `strategy.matrix` in `ephemeral-integration`: + +```yaml +ephemeral-integration: + strategy: + matrix: + shard: [1, 2, 3, 4, 5] + steps: + ... + - name: Run ephemeral integration tests + run: yarn test:integration:coverage --shard=${{ matrix.shard }}/5 +``` + +Each shard starts its own ephemeral server (the server manager already handles dynamic port selection). 311 tests ÷ 5 shards = ~62 tests per shard. + +**Coverage merging:** Each shard produces a partial `coverage-summary.json`. A final job downloads all shard artifacts and merges them: + +```yaml +merge-coverage: + needs: ephemeral-integration + steps: + - uses: actions/download-artifact@v4 + with: { pattern: integration-test-results-* } + - run: node scripts/merge-coverage.mjs +``` + +**Expected impact:** Full suite integration time 45 min → ~10–12 min (5 shards × ~62 tests each, plus ~2 min startup per shard). + +--- + +### 5.5 Concurrency Groups on `ci.yml` and `snapshot.yml` + +**Change:** Add concurrency groups to prevent stale runs from consuming compute. + +```yaml +# ci.yml — add at top level, after permissions: +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +# snapshot.yml — add at top level: +concurrency: + group: snapshot-${{ github.ref }} + cancel-in-progress: false # MUST be false — cancelling mid-publish leaves partial npm packages +``` + +**`cancel-in-progress: false` for snapshot is intentional.** `snapshot.yml` publishes multiple packages to npm atomically inside `release-snapshot.sh`. If a run is cancelled mid-publish, some packages land at the new version and others stay at the previous one — making the npm registry inconsistent. Queuing (`cancel-in-progress: false`) is the safe behaviour. Two rapid pushes to `develop` will publish sequentially; the second overwrites the first dist-tag, which is the desired outcome. + +**Expected impact:** A developer who pushes 3 times in 5 minutes sees only the last CI run complete. The first two are cancelled automatically. No wasted 55-minute runs. + +--- + +### 5.6 Playwright Runner: Pre-built Image + +**Change:** Replace `ubuntu-latest` + `npx playwright install --with-deps chromium` with the official Playwright Docker image as the runner for `ephemeral-integration`. This eliminates the 25-second Playwright install step and its transitive system dependencies. + +```yaml +ephemeral-integration: + runs-on: ubuntu-latest + container: + image: mcr.microsoft.com/playwright:v1.51.0-noble +``` + +**Note:** Requires the ephemeral server to listen correctly inside a container network. Verify `BASE_URL` and internal port handling. + +--- + +### 5.7 Docs Dockerfile: Node 20 → 24-Alpine + +`apps/docs/Dockerfile` uses `node:20-alpine` while the rest of the project targets Node 24. Node 20 receives fewer security patches and doesn't benefit from the V8 performance improvements in Node 22–24. + +```dockerfile +# Before +FROM node:20-alpine AS builder + +# After +FROM node:24-alpine AS builder +``` + +--- + +### 5.8 Self-Hosted Warm Runners (Long-term) + +GitHub-hosted `ubuntu-latest` runners are ephemeral — every job starts from a cold OS image. A persistent self-hosted runner (a VPS or dedicated machine) retains: + +- `node_modules/` from the last run (yarn install becomes seconds) +- Turbo's local cache (build artifacts from previous commits) +- Playwright binaries (no install step) +- Docker layer cache (local, not limited by GHA cache size) + +**Trade-off:** Requires maintaining runner infrastructure, handling security (runners have access to secrets), and ensuring runners stay up-to-date. For a team with existing infrastructure (Proxmox, Cozystack), this is low marginal cost. + +**Expected impact:** Combined with Turbo remote cache and affected-only execution, warm runners bring typical PR CI time to under 2 minutes on cache hits. + +--- + +## 6. Projected Impact by Change + +| Change | Effort | Typical PR (1 module) | Full run (main merge) | +|--------|--------|----------------------|----------------------| +| Baseline (current) | — | 55 min | 55 min | +| 5.5 Concurrency groups | 10 min | — | Cancels stale runs | +| 5.1 Turbo cache enabled | 2 hrs | 45 min | 45 min | +| 5.2 Affected-only build/test | 1 hr | 12 min | 45 min | +| 5.3 Affected-only integration | 4 hrs | **3–5 min** | 45 min | +| 5.4 Playwright sharding (5×) | 4 hrs | 3–5 min | **12–15 min** | +| 5.1 + 5.2 + 5.3 + 5.4 | 12 hrs | **2–4 min** | **8–12 min** | +| + 5.8 Warm runners | deferred | **30–90 sec** | 5–8 min | + +**95–99% reduction is achievable** for typical PRs with changes 5.1–5.3 combined. Full-suite runs (main merges) hit 80–85% reduction with sharding, and 90%+ with warm runners. + +--- + +## 7. Effect on Developer Workflow + +### Today + +1. Push branch → wait 55 minutes → maybe green → iterate +2. Push again to address review → wait another 55 minutes +3. Multi-push within a session? All three 55-minute runs complete, wasting 2 hours of compute + +### After proposed changes + +**Typical PR push:** +1. Push branch → stale run cancelled immediately (5.5) +2. Turbo cache hits for unchanged packages → build in 15s (5.1) +3. Only affected packages typechecked and unit-tested → 20–30s (5.2) +4. Only affected module's integration tests run → 2–5 min (5.3) +5. **Total: 3–6 minutes from push to green/red** + +**Pushing again to fix a review comment:** +- Same 3–6 minutes, previous run cancelled within seconds + +**PR touching `packages/shared`:** +- Everything depends on shared → full rebuild triggered (expected, correct) +- Affected-only integration: all 311 tests still run +- With sharding: 12–15 min instead of 45 min + +**Merge to main:** +- Full suite always runs (no affected-only filtering on protected branches) +- With sharding: 12–15 min +- With warm runners: 5–8 min + +**QA deployment:** No change — remains manual. + +**Release:** No change — remains gated by environment approval. + +### What does NOT change + +- Security: audit still gates every run, npm provenance attestations still used for releases +- Correctness: tests still run against the same ephemeral server, same test suite +- Release process: manual dispatch with human approval gate +- The PR still must be green before merge — only the time to get there changes + +--- + +## 8. Implementation Plan + +### Phase 0 — Immediate (already done, current branch) + +- [x] Extract `prepare` job with artifact upload +- [x] Extract `audit` job running in parallel +- [x] Yarn package caching: explicit `actions/cache` on `.yarn/cache` after `corepack enable` in all four jobs + - **Note:** `cache: 'yarn'` on `setup-node@v4` is NOT used. `setup-node` calls `yarn config get cacheFolder` before `corepack enable` runs, which invokes the globally-installed Yarn 1 (1.22.22) instead of Yarn 4. With `"packageManager": "yarn@4.12.0"` in `package.json`, Yarn 1 aborts immediately. The correct approach is a manual `actions/cache` step placed after `corepack enable`. +- [x] Artifact includes `packages/*/generated/` in addition to `packages/*/dist/` and `apps/mercato/.mercato/generated/` + - **Note:** Several packages (`core`, `onboarding`, `scheduler`, `integration-cozystack`) declare `#generated/*` Node.js subpath imports whose `types` condition points to source `.ts` files in `packages//generated/` — not `dist/`. The `test` job's typecheck fails unless these source files are present on disk. +- [x] Add pip cache for markitdown +- **Estimated savings:** ~2–3 min on warm cache + +### Phase 1 — Concurrency + Turbo cache (~1 day) + +1. Add concurrency groups to `ci.yml` and `snapshot.yml` +2. Replace `"globalPassThroughEnv": ["*"]` in `turbo.json` with an explicit allowlist +3. Enable `"cache": true` for `build` and `typecheck` tasks in `turbo.json` +4. Set up Turbo remote cache: + - Option A: Vercel free tier (5 min setup, requires Vercel account) + - Option B: Self-hosted `turborepo-remote-cache` on existing infra (1 hr) +5. Add `TURBO_TOKEN` + `TURBO_TEAM` to GitHub Actions secrets +6. Add env vars to all build steps in `ci.yml` +7. Validate: push an unrelated change and confirm packages build in < 15s on second run + +### Phase 2 — Affected-only build and unit tests (~1 day) + +1. Add `--filter=[origin/main]...` to `yarn build:packages` and `yarn test` in `ci.yml` +2. Keep `yarn generate` and `yarn build:app` as full runs (no filter) +3. Add `fetch-depth: 0` to checkout steps (needed for `git diff origin/main`) +4. Validate: change one file in `packages/core/src/modules/sales/` and confirm only `sales` and its dependents build + +### Phase 3 — Affected-only integration tests (~2–3 days) + +1. Add `--modules` flag support to `packages/cli/src/lib/testing/integration-discovery.ts` +2. Add a `scripts/compute-affected-modules.sh` that outputs changed module names from `git diff` +3. Wire into `ci.yml` `ephemeral-integration` job: + - Compute `CHANGED_MODULES` from git diff + - If empty (no module changes), skip integration tests or run a smoke subset + - Otherwise pass `--modules=$CHANGED_MODULES` to the test runner +4. Validate: change one file in `packages/core/src/modules/customers/` and confirm only customers integration tests run + +### Phase 4 — Playwright sharding for full runs (~1–2 days) + +1. Add `strategy.matrix.shard: [1, 2, 3, 4, 5]` to `ephemeral-integration` +2. Pass `--shard=${{ matrix.shard }}/5` to the coverage command +3. Rename artifact upload: `integration-test-results-${{ matrix.shard }}` +4. Add `merge-coverage` job: downloads all 5 artifacts, merges `coverage-summary.json` files, writes step summary +5. Write `scripts/merge-coverage.mjs` (merge 5 partial JSON files by summing covered/total fields) +6. Validate on a full run (push to develop): confirm all 5 shards complete in ~10–12 min + +### Phase 5 — Node version + Dockerfile consistency (~2 hours) + +1. Update `apps/docs/Dockerfile` Node 20-alpine → 24-alpine +2. Add `ENV NODE_OPTIONS="--max-old-space-size=4096"` to preview Dockerfile builder stage +3. Pin Node version in CI: `node-version: '24.x'` → exact patch from `.nvmrc` +4. Add `.nvmrc` with exact Node version used in production + +### Phase 6 — Self-hosted warm runners (deferred) + +Self-hosted runners would eliminate cold-start overhead (~1m per job) and enable persistent Turbo local cache. Deferred pending discussion with the open-mercato upstream team — running their CI on external infra requires coordination around secrets access, runner security, and maintenance responsibility. + +--- + +## 9. Risk Assessment + +| Risk | Likelihood | Mitigation | +|------|-----------|------------| +| Turbo cache produces stale builds (wrong cache key) | Medium | Run full rebuild weekly on `develop`; add `--force` flag to nightly scheduled run | +| Affected-only filtering misses a cross-package bug | Low | Turbo's dep graph is accurate; `--filter=[origin/main]...` includes all transitive dependents | +| Playwright sharding causes flaky tests (race conditions on DB) | Low | Each shard has an isolated ephemeral server and DB; no shared state | +| Self-hosted runner has a security incident | Low | Runners should run with minimal permissions; secrets scoped to repo; runner isolated in VLAN | +| Snapshot publishes same version twice (no concurrency) | Medium (exists today) | Concurrency group fix in Phase 1 | + +--- + +## Appendix: Workflow Reference Card + +| Workflow | Trigger | Critical path | When it blocks a merge | +|----------|---------|---------------|------------------------| +| `ci.yml` | Push/PR to main/develop | 55 min (target: 3–6 min) | Always | +| `snapshot.yml` | Push to develop / PR | ~15 min + npm propagation | Never directly | +| `release.yml` | Manual dispatch | ~5 min + human approval | Never (post-merge) | +| `qa-deploy.yml` | Manual dispatch | ~15 min | Never (optional) | +| `qa-stop-on-merge.yml` | PR closed | ~1 min | Never | From 49adae2c4b3b0a6f72ea76635bc736974d06ee87 Mon Sep 17 00:00:00 2001 From: Clay Townsend Date: Fri, 10 Apr 2026 20:16:38 +1000 Subject: [PATCH 09/24] fix(ci): track gitignored generated/ in Turbo build input hash packages/*/generated/ is in .gitignore. Turbo only hashes git-tracked files by default, so after 'yarn generate' creates packages/core/generated/entities.ids.generated.ts, the second 'yarn build:packages' computes the same hash as the first and returns a cached result that has no dist/generated/. Jest then fails at runtime: Cannot find module '../../../generated/entities.ids.generated.js' from '../core/dist/modules/attachments/lib/partitions.js' Fix: add inputs: ["$TURBO_DEFAULT$", "generated/**"] to the build task. $TURBO_DEFAULT$ preserves all non-gitignored inputs; generated/** explicitly includes the gitignored files. After generate, the hash changes, the second build is a cache miss, esbuild runs and writes dist/generated/. Co-Authored-By: Claude Sonnet 4.6 --- .ai/specs/2026-04-10-ci-cd-performance.md | 3 +++ turbo.json | 1 + 2 files changed, 4 insertions(+) diff --git a/.ai/specs/2026-04-10-ci-cd-performance.md b/.ai/specs/2026-04-10-ci-cd-performance.md index 54c514038eb..1781a0b7be4 100644 --- a/.ai/specs/2026-04-10-ci-cd-performance.md +++ b/.ai/specs/2026-04-10-ci-cd-performance.md @@ -478,6 +478,9 @@ GitHub-hosted `ubuntu-latest` runners are ephemeral — every job starts from a - **Note:** `cache: 'yarn'` on `setup-node@v4` is NOT used. `setup-node` calls `yarn config get cacheFolder` before `corepack enable` runs, which invokes the globally-installed Yarn 1 (1.22.22) instead of Yarn 4. With `"packageManager": "yarn@4.12.0"` in `package.json`, Yarn 1 aborts immediately. The correct approach is a manual `actions/cache` step placed after `corepack enable`. - [x] Artifact includes `packages/*/generated/` in addition to `packages/*/dist/` and `apps/mercato/.mercato/generated/` - **Note:** Several packages (`core`, `onboarding`, `scheduler`, `integration-cozystack`) declare `#generated/*` Node.js subpath imports whose `types` condition points to source `.ts` files in `packages//generated/` — not `dist/`. The `test` job's typecheck fails unless these source files are present on disk. +- [x] `turbo.json` build task: add `"inputs": ["$TURBO_DEFAULT$", "generated/**"]` + - **Root cause of second-build cache poisoning:** `.gitignore` includes `packages/*/generated/`. Turbo only hashes git-tracked files by default. This means after `yarn generate` creates `packages/core/generated/entities.ids.generated.ts`, Turbo computes the **same** hash for `@open-mercato/core#build` as before generate ran — so the second `yarn build:packages` is a false cache hit returning the first build's output, which has no `dist/generated/`. Jest then fails at runtime: `Cannot find module '../../../generated/entities.ids.generated.js'`. + - **Fix:** `$TURBO_DEFAULT$` preserves all non-gitignored inputs; `generated/**` explicitly adds the gitignored generated files. After generate, the hash changes → cache miss → fresh build → `dist/generated/` is populated. - [x] Add pip cache for markitdown - **Estimated savings:** ~2–3 min on warm cache diff --git a/turbo.json b/turbo.json index d723ced1d46..e5abd023962 100644 --- a/turbo.json +++ b/turbo.json @@ -4,6 +4,7 @@ "tasks": { "build": { "cache": true, + "inputs": ["$TURBO_DEFAULT$", "generated/**"], "outputs": ["dist/**", ".next/**", "!.next/cache/**"] }, "start": { From 60affd79ab49090c624e1a9cb19653c5c9cc97ac Mon Sep 17 00:00:00 2001 From: Clay Townsend Date: Fri, 10 Apr 2026 22:31:46 +1000 Subject: [PATCH 10/24] =?UTF-8?q?feat(ci):=20Phase=203+4=20=E2=80=94=20aff?= =?UTF-8?q?ected-only=20integration=20tests=20and=205-shard=20Playwright?= =?UTF-8?q?=20parallelism?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ephemeral-integration: skip when no module code changed (skip_integration output from test job) - ephemeral-integration: matrix shard — PR uses single runner with OM_INTEGRATION_MODULES filtering, push uses 5 parallel shards (["1/5"…"5/5"]) for full suite - test job: inline "Compute integration scope" step produces skip/modules outputs from git diff; full-suite patterns trigger unfiltered run, non-module changes skip integration entirely - Add merge-coverage job (push only): downloads integration-test-results-* artifacts with merge-multiple, runs scripts/merge-coverage.mjs, writes combined step summary - playwright.config.ts: filter specs by OM_INTEGRATION_MODULES when set - packages/cli/src/lib/testing/integration.ts: --shard N/M CLI flag forwarded to Playwright - scripts/merge-coverage.mjs: sum coverage-shard-*/code/coverage-summary.json into one report - .ai/specs/2026-04-10-ci-cd-performance.md: mark Phase 3 steps 2-3 and Phase 4 steps 1-5 done Co-Authored-By: Claude Sonnet 4.6 --- .ai/qa/tests/playwright.config.ts | 26 ++- .ai/specs/2026-04-10-ci-cd-performance.md | 45 +++-- .github/workflows/ci.yml | 172 +++++++++++++++++++- packages/cli/src/lib/testing/integration.ts | 31 +++- scripts/merge-coverage.mjs | 102 ++++++++++++ 5 files changed, 353 insertions(+), 23 deletions(-) create mode 100644 scripts/merge-coverage.mjs diff --git a/.ai/qa/tests/playwright.config.ts b/.ai/qa/tests/playwright.config.ts index 64b38e5cad1..606a27aa735 100644 --- a/.ai/qa/tests/playwright.config.ts +++ b/.ai/qa/tests/playwright.config.ts @@ -12,11 +12,33 @@ const STATIC_TEST_IGNORES = [ `${normalizePath(path.join(projectRoot, '.codex'))}/**`, ]; const discoveredSpecs = discoverIntegrationSpecFiles(projectRoot, path.join(projectRoot, '.ai', 'qa', 'tests')); -const discoveredSpecPaths = discoveredSpecs.map((entry) => entry.path); + +// Affected-only: when OM_INTEGRATION_MODULES is set, restrict to those modules. +// A spec is included if its moduleName is in the set, or any of its requiredModules is. +// Specs with moduleName === null (legacy .ai/qa/tests/ root specs) are always included. +const affectedModules = process.env.OM_INTEGRATION_MODULES + ? new Set( + process.env.OM_INTEGRATION_MODULES.split(',') + .map((m) => m.trim().toLowerCase()) + .filter(Boolean), + ) + : null; + +const filteredSpecs = + affectedModules && affectedModules.size > 0 + ? discoveredSpecs.filter((spec) => { + if (spec.moduleName === null) return true; + if (affectedModules.has(spec.moduleName.toLowerCase())) return true; + if (spec.requiredModules.some((m) => affectedModules.has(m.toLowerCase()))) return true; + return false; + }) + : discoveredSpecs; + +const filteredSpecPaths = filteredSpecs.map((entry) => entry.path); export default defineConfig({ testDir: projectRoot, - testMatch: discoveredSpecPaths.length > 0 ? discoveredSpecPaths : ['.ai/qa/tests/__no_tests__/*.spec.ts'], + testMatch: filteredSpecPaths.length > 0 ? filteredSpecPaths : ['.ai/qa/tests/__no_tests__/*.spec.ts'], testIgnore: [ ...STATIC_TEST_IGNORES, ], diff --git a/.ai/specs/2026-04-10-ci-cd-performance.md b/.ai/specs/2026-04-10-ci-cd-performance.md index 1781a0b7be4..bfbeb3b3179 100644 --- a/.ai/specs/2026-04-10-ci-cd-performance.md +++ b/.ai/specs/2026-04-10-ci-cd-performance.md @@ -505,23 +505,46 @@ GitHub-hosted `ubuntu-latest` runners are ephemeral — every job starts from a ### Phase 3 — Affected-only integration tests (~2–3 days) -1. Add `--modules` flag support to `packages/cli/src/lib/testing/integration-discovery.ts` -2. Add a `scripts/compute-affected-modules.sh` that outputs changed module names from `git diff` -3. Wire into `ci.yml` `ephemeral-integration` job: - - Compute `CHANGED_MODULES` from git diff - - If empty (no module changes), skip integration tests or run a smoke subset - - Otherwise pass `--modules=$CHANGED_MODULES` to the test runner +1. [x] Add module filtering to `.ai/qa/tests/playwright.config.ts` + - Reads `OM_INTEGRATION_MODULES` env var (comma-separated module names, e.g. `"sales,customers"`) + - When set, filters `discoverIntegrationSpecFiles` output: a spec is included if its `moduleName` matches, any of its `requiredModules` match, or its `moduleName` is `null` (legacy root specs always run) + - When unset or empty, all specs run unchanged (no behaviour change for existing CI) + - Uses `filteredSpecs` instead of `discoveredSpecs` for `testMatch` +2. [x] Compute affected modules inline in `ci.yml` `test` job (no separate script needed): + - "Compute integration scope" step added to `test` job; outputs `skip` and `modules` + - Full-suite patterns trigger the full run; only unmatched module paths produce a filtered list + - Non-module-only changes (CI, docs, scripts) set `skip=true` to skip integration entirely +3. [x] Wire into `ci.yml` `ephemeral-integration` job: + - `if: needs.test.outputs.skip_integration != 'true'` skips the job when no module changes + - `OM_INTEGRATION_MODULES: ${{ needs.test.outputs.affected_modules }}` passes module list + - On pushes, `affected_modules` is empty so all specs run (filtered by shard) 4. Validate: change one file in `packages/core/src/modules/customers/` and confirm only customers integration tests run ### Phase 4 — Playwright sharding for full runs (~1–2 days) -1. Add `strategy.matrix.shard: [1, 2, 3, 4, 5]` to `ephemeral-integration` -2. Pass `--shard=${{ matrix.shard }}/5` to the coverage command -3. Rename artifact upload: `integration-test-results-${{ matrix.shard }}` -4. Add `merge-coverage` job: downloads all 5 artifacts, merges `coverage-summary.json` files, writes step summary -5. Write `scripts/merge-coverage.mjs` (merge 5 partial JSON files by summing covered/total fields) +1. [x] Add `strategy.matrix` to `ephemeral-integration` — dynamic matrix: PR uses `["none"]` (single runner, affected-only), push uses `["1/5","2/5","3/5","4/5","5/5"]` (5 parallel shards, full suite) +2. [x] "Compute shard metadata" step derives `shard_flag` (`--shard N/M` or empty) and `artifact_name` from `matrix.shard`; test command passes flag conditionally +3. [x] Artifact upload uses `${{ steps.shard-meta.outputs.artifact_name }}` — `integration-test-results-N` for shards, `integration-test-results` for PR +4. [x] Add `merge-coverage` job: downloads all `integration-test-results-*` artifacts with `merge-multiple: true`, runs `node scripts/merge-coverage.mjs`, writes step summary; only runs on push +5. [x] `scripts/merge-coverage.mjs` written (no external dependencies, scans `coverage-shard-*/code/coverage-summary.json`) 6. Validate on a full run (push to develop): confirm all 5 shards complete in ~10–12 min +#### Implementation notes (completed) + +**`--shard N/M` CLI flag** (`packages/cli/src/lib/testing/integration.ts`): +- Added `shard: string | null` to `IntegrationCoverageOptions` and `PlaywrightRunOptions` (as an intersection `& { shard?: string | null }`). +- `parseIntegrationCoverageOptions` accepts both `--shard N/M` (two-token) and `--shard=N/M` (equals) forms; validates format with `/^\d+\/\d+$/`. +- `runPlaywrightSelection` pushes `--shard ` to the Playwright CLI args (placed after `--retries`, before file selection). +- `runIntegrationCoverageReport` forwards `shard` from parsed options into `runPlaywrightSelection`. + +**`scripts/merge-coverage.mjs`**: +- Accepts an optional `resultsRoot` argument (default: `.ai/qa/test-results`). +- Discovers shard files by scanning `/coverage-shard-*/code/coverage-summary.json` using `readdirSync` (no external dependencies). +- **Total merge**: sums `total`, `covered`, and `skipped` counters across all shards for each of the four Istanbul metrics (`lines`, `statements`, `functions`, `branches`); recomputes `pct = Math.round(covered/total * 10000) / 100` (0 when total is 0). +- **Per-file merge**: unions all file entries across shards; when the same file path appears in multiple shards, the shard with the higher combined `lines.covered + statements.covered` count wins (the shard that ran tests for that file will have non-zero coverage). +- Writes merged JSON to `/coverage/code/coverage-summary.json` (mkdir -p). +- Prints `[merge-coverage] Merged N shards: lines X/Y (Z%)` and exits 0; exits 1 with an error message on failure. + ### Phase 5 — Node version + Dockerfile consistency (~2 hours) 1. Update `apps/docs/Dockerfile` Node 20-alpine → 24-alpine diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3761fe2e357..50bb9fb7369 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -123,15 +123,54 @@ jobs: test: runs-on: ubuntu-latest needs: [prepare, audit] + outputs: + skip_integration: ${{ steps.integration-scope.outputs.skip }} + affected_modules: ${{ steps.integration-scope.outputs.modules }} steps: - name: Checkout repository uses: actions/checkout@v4 - name: Fetch base branch for change detection - # Needed so Turbo can compute which packages changed vs. the PR base. + # Needed so Turbo can compute which packages changed vs. the PR base, + # and so the integration scope step can diff against it. if: github.event_name == 'pull_request' run: git fetch origin ${{ github.base_ref }} --depth=1 + - name: Compute integration scope + # On PRs: determine which modules changed and whether integration tests + # should run at all. On pushes to main/develop: always run the full suite. + id: integration-scope + run: | + if [ "${{ github.event_name }}" != "pull_request" ]; then + echo "skip=false" >> "$GITHUB_OUTPUT" + echo "modules=" >> "$GITHUB_OUTPUT" + exit 0 + fi + + CHANGED=$(git diff origin/${{ github.base_ref }} --name-only) + + # These paths affect behaviour across all modules — run the full suite. + FULL_SUITE_PATTERN='^packages/shared/|^packages/ui/|^packages/events/|^packages/queue/|^packages/cache/|^packages/search/|^packages/onboarding/|^packages/webhooks/|^packages/core/src/lib/|^packages/enterprise/src/lib/|^apps/mercato/src/(app|lib|components|layout\.|page\.)' + if echo "$CHANGED" | grep -qE "$FULL_SUITE_PATTERN"; then + echo "skip=false" >> "$GITHUB_OUTPUT" + echo "modules=" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Extract module folder names from module-specific paths. + MODULES=$(echo "$CHANGED" | \ + grep -oP '(?:packages/(?:core|enterprise)|apps/mercato)/src/modules/\K[^/]+' | \ + sort -u | paste -sd,) + + if [ -z "$MODULES" ]; then + # Only non-module files changed (CI, docs, scripts) — skip integration. + echo "skip=true" >> "$GITHUB_OUTPUT" + echo "modules=" >> "$GITHUB_OUTPUT" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + echo "modules=$MODULES" >> "$GITHUB_OUTPUT" + fi + - name: Setup Node.js uses: actions/setup-node@v4 with: @@ -212,16 +251,22 @@ jobs: run: yarn build:app # ── Integration tests ─────────────────────────────────────────────────────── - # Boots an ephemeral app server and runs the full Playwright suite. + # Boots an ephemeral app server and runs the Playwright suite. # Blocked on 'test' so integration only runs when unit tests are green. # - # Performance note: at 311 spec files with workers: 1, this job accounts for - # ~45 of the ~55 min total wall time. Playwright sharding across 3 parallel - # runners (--shard=N/3) would cut this to ~16 min but requires each shard - # to start its own ephemeral server and a final step to merge coverage JSON. + # On PRs: a single runner executes only affected modules (OM_INTEGRATION_MODULES). + # If no module-level code changed the job is skipped entirely. + # On pushes to main/develop: 5 parallel shards run the full suite, cutting + # wall time from ~47 min to ~10 min. A merge-coverage job then + # combines per-shard coverage-summary.json files into one report. ephemeral-integration: runs-on: ubuntu-latest needs: test + if: needs.test.outputs.skip_integration != 'true' + strategy: + fail-fast: false + matrix: + shard: ${{ github.event_name == 'pull_request' && fromJson('["none"]') || fromJson('["1/5","2/5","3/5","4/5","5/5"]') }} env: OM_ENABLE_ENTERPRISE_MODULES: 'true' OM_ENABLE_ENTERPRISE_MODULES_SSO: 'true' @@ -229,6 +274,21 @@ jobs: JWT_SECRET: 'ci-ephemeral-test-jwt-secret' OM_SECURITY_MFA_SETUP_SECRET: 'ci-ephemeral-test-mfa-setup-secret' steps: + - name: Compute shard metadata + id: shard-meta + run: | + SHARD="${{ matrix.shard }}" + if [ "$SHARD" = "none" ]; then + echo "artifact_name=integration-test-results" >> "$GITHUB_OUTPUT" + echo "shard_flag=" >> "$GITHUB_OUTPUT" + echo "shard_index=none" >> "$GITHUB_OUTPUT" + else + INDEX="${SHARD%%/*}" + echo "artifact_name=integration-test-results-${INDEX}" >> "$GITHUB_OUTPUT" + echo "shard_flag=--shard ${SHARD}" >> "$GITHUB_OUTPUT" + echo "shard_index=${INDEX}" >> "$GITHUB_OUTPUT" + fi + - name: Checkout repository uses: actions/checkout@v4 @@ -264,10 +324,31 @@ jobs: - name: Run ephemeral integration tests with code coverage env: OM_INTEGRATION_APP_READY_TIMEOUT_SECONDS: '180' - run: yarn test:integration:coverage + OM_INTEGRATION_MODULES: ${{ needs.test.outputs.affected_modules }} + run: | + SHARD_FLAG="${{ steps.shard-meta.outputs.shard_flag }}" + if [ -n "$SHARD_FLAG" ]; then + yarn test:integration:coverage $SHARD_FLAG + else + yarn test:integration:coverage + fi + + - name: Stage coverage for shard merge + # Only needed for sharded push runs — copies per-shard summary to a + # named subdirectory so merge-coverage can collect all shards at once. + if: always() && matrix.shard != 'none' + run: | + INDEX="${{ steps.shard-meta.outputs.shard_index }}" + DEST=".ai/qa/test-results/coverage-shard-${INDEX}/code" + mkdir -p "$DEST" + SRC=".ai/qa/test-results/coverage/code/coverage-summary.json" + if [ -f "$SRC" ]; then + cp "$SRC" "$DEST/coverage-summary.json" + fi - name: Display integration coverage summary - if: always() + # PR only (shard=none) — push coverage is merged and displayed by merge-coverage job. + if: always() && matrix.shard == 'none' run: | SUMMARY_FILE=".ai/qa/test-results/coverage/code/coverage-summary.json" if [ ! -f "$SUMMARY_FILE" ]; then @@ -316,13 +397,86 @@ jobs: if: always() uses: actions/upload-artifact@v4 with: - name: integration-test-results + name: ${{ steps.shard-meta.outputs.artifact_name }} path: | .ai/qa/test-results/html/ .ai/qa/test-results/artifacts/ .ai/qa/test-results/results.json + .ai/qa/test-results/coverage-shard-${{ steps.shard-meta.outputs.shard_index }}/ if-no-files-found: ignore + # ── Merge shard coverage ───────────────────────────────────────────────────── + # Runs after all 5 ephemeral-integration shards complete (push only). + # Downloads per-shard artifacts, merges coverage-summary.json files, and + # writes a combined report to the job summary. + merge-coverage: + runs-on: ubuntu-latest + needs: ephemeral-integration + if: github.event_name == 'push' + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: Download shard artifacts + uses: actions/download-artifact@v4 + with: + pattern: integration-test-results-* + merge-multiple: true + + - name: Merge shard coverage reports + run: node scripts/merge-coverage.mjs .ai/qa/test-results + + - name: Display merged integration coverage + if: always() + run: | + SUMMARY_FILE=".ai/qa/test-results/coverage/code/coverage-summary.json" + if [ ! -f "$SUMMARY_FILE" ]; then + echo "Merged coverage summary not found at $SUMMARY_FILE" + echo "## Integration Coverage (Merged)" >> "$GITHUB_STEP_SUMMARY" + echo "Coverage summary file was not generated." >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + + node -e " + const fs = require('fs'); + const p = process.argv[1]; + const summary = JSON.parse(fs.readFileSync(p, 'utf8')); + const t = summary.total || {}; + const f = (name) => { + const m = t[name] || {}; + const covered = m.covered ?? 0; + const total = m.total ?? 0; + const pct = m.pct ?? 0; + return \`\${name}: \${covered}/\${total} (\${pct}%)\`; + }; + const lines = [ + '[coverage] Merged integration coverage summary', + f('lines'), + f('statements'), + f('functions'), + f('branches'), + ]; + console.log(lines.join('\n')); + fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, [ + '## Integration Coverage (Merged)', + '', + '| Metric | Covered/Total | Percent |', + '|---|---:|---:|', + \`| Lines | \${t.lines?.covered ?? 0}/\${t.lines?.total ?? 0} | \${t.lines?.pct ?? 0}% |\`, + \`| Statements | \${t.statements?.covered ?? 0}/\${t.statements?.total ?? 0} | \${t.statements?.pct ?? 0}% |\`, + \`| Functions | \${t.functions?.covered ?? 0}/\${t.functions?.total ?? 0} | \${t.functions?.pct ?? 0}% |\`, + \`| Branches | \${t.branches?.covered ?? 0}/\${t.branches?.total ?? 0} | \${t.branches?.pct ?? 0}% |\`, + '', + 'Source: \`.ai/qa/test-results/coverage/code/coverage-summary.json\`', + '', + ].join('\n')); + " "$SUMMARY_FILE" + # ── Docker image builds ────────────────────────────────────────────────────── # Validates all Dockerfiles build cleanly. Runs in parallel with # 'ephemeral-integration' (both need test), so it does not add to wall time. diff --git a/packages/cli/src/lib/testing/integration.ts b/packages/cli/src/lib/testing/integration.ts index d0550512cae..137c30327bf 100644 --- a/packages/cli/src/lib/testing/integration.ts +++ b/packages/cli/src/lib/testing/integration.ts @@ -73,6 +73,7 @@ type IntegrationCoverageOptions = { verbose: boolean workers: number | null retries: number | null + shard: string | null json: boolean keepRawV8: boolean forceRebuild: boolean @@ -140,7 +141,9 @@ type EphemeralEnvironmentState = { startedAt: string } -type PlaywrightRunOptions = Pick +type PlaywrightRunOptions = Pick & { + shard?: string | null +} const DEFAULT_APP_READY_TIMEOUT_MS = 90_000 const APP_READY_INTERVAL_MS = 1_000 @@ -1682,6 +1685,7 @@ export function parseIntegrationCoverageOptions(rawArgs: string[]): IntegrationC let verbose = false let workers: number | null = null let retries: number | null = null + let shard: string | null = null let json = false let keepRawV8 = false let forceRebuild = false @@ -1766,6 +1770,26 @@ export function parseIntegrationCoverageOptions(rawArgs: string[]): IntegrationC retries = parsed continue } + if (argument === '--shard') { + const value = rawArgs[index + 1] + if (!value || value.startsWith('--')) { + throw new Error('Missing value for --shard') + } + if (!/^\d+\/\d+$/.test(value)) { + throw new Error(`Invalid --shard value: ${value}. Expected format: N/M`) + } + shard = value + index += 1 + continue + } + if (argument.startsWith('--shard=')) { + const value = argument.slice('--shard='.length) + if (!/^\d+\/\d+$/.test(value)) { + throw new Error(`Invalid --shard value: ${value}. Expected format: N/M`) + } + shard = value + continue + } if (argument === '--json') { json = true continue @@ -1792,6 +1816,7 @@ export function parseIntegrationCoverageOptions(rawArgs: string[]): IntegrationC verbose, workers, retries, + shard, json, keepRawV8, forceRebuild, @@ -2228,6 +2253,7 @@ export async function runIntegrationCoverageReport(rawArgs: string[]): Promise 0) { args.push(...selection) } else if (typeof selection === 'string' && selection.length > 0) { diff --git a/scripts/merge-coverage.mjs b/scripts/merge-coverage.mjs new file mode 100644 index 00000000000..0a364975201 --- /dev/null +++ b/scripts/merge-coverage.mjs @@ -0,0 +1,102 @@ +#!/usr/bin/env node +/** + * Merges partial coverage-summary.json files produced by Playwright shards into a single + * combined report. + * + * Input: /coverage-shard-*\/code/coverage-summary.json + * Output: /coverage/code/coverage-summary.json + * + * Usage: node scripts/merge-coverage.mjs [resultsRoot] + * resultsRoot defaults to .ai/qa/test-results + */ + +import { readFileSync, writeFileSync, mkdirSync, readdirSync, existsSync } from 'node:fs' +import path from 'node:path' + +const resultsRoot = process.argv[2] ?? '.ai/qa/test-results' + +function findShardSummaryFiles(root) { + if (!existsSync(root)) { + return [] + } + const entries = readdirSync(root, { withFileTypes: true }) + const files = [] + for (const entry of entries) { + if (entry.isDirectory() && entry.name.startsWith('coverage-shard-')) { + const candidate = path.join(root, entry.name, 'code', 'coverage-summary.json') + if (existsSync(candidate)) { + files.push(candidate) + } + } + } + return files.sort() +} + +function mergeSummaries(summaries) { + const mergedTotals = { + lines: { total: 0, covered: 0, skipped: 0, pct: 0 }, + statements: { total: 0, covered: 0, skipped: 0, pct: 0 }, + functions: { total: 0, covered: 0, skipped: 0, pct: 0 }, + branches: { total: 0, covered: 0, skipped: 0, pct: 0 }, + } + const mergedFiles = {} + + for (const summary of summaries) { + for (const [key, value] of Object.entries(summary)) { + if (key === 'total') { + for (const metric of ['lines', 'statements', 'functions', 'branches']) { + const src = value[metric] ?? {} + mergedTotals[metric].total += src.total ?? 0 + mergedTotals[metric].covered += src.covered ?? 0 + mergedTotals[metric].skipped += src.skipped ?? 0 + } + } else { + const existing = mergedFiles[key] + const incomingCovered = (value.lines?.covered ?? 0) + (value.statements?.covered ?? 0) + const existingCovered = existing + ? (existing.lines?.covered ?? 0) + (existing.statements?.covered ?? 0) + : -1 + if (!existing || incomingCovered > existingCovered) { + mergedFiles[key] = value + } + } + } + } + + for (const metric of ['lines', 'statements', 'functions', 'branches']) { + const { total, covered } = mergedTotals[metric] + mergedTotals[metric].pct = total === 0 ? 0 : Math.round((covered / total) * 10000) / 100 + } + + return { total: mergedTotals, ...mergedFiles } +} + +try { + const shardFiles = findShardSummaryFiles(resultsRoot) + + if (shardFiles.length === 0) { + console.error(`[merge-coverage] No shard coverage files found under ${resultsRoot}/coverage-shard-*/code/coverage-summary.json`) + process.exit(1) + } + + const summaries = shardFiles.map((filePath) => { + const raw = readFileSync(filePath, 'utf8') + return JSON.parse(raw) + }) + + const merged = mergeSummaries(summaries) + + const outputDir = path.join(resultsRoot, 'coverage', 'code') + mkdirSync(outputDir, { recursive: true }) + const outputPath = path.join(outputDir, 'coverage-summary.json') + writeFileSync(outputPath, JSON.stringify(merged, null, 2), 'utf8') + + const lines = merged.total.lines + console.log( + `[merge-coverage] Merged ${shardFiles.length} shards: lines ${lines.covered}/${lines.total} (${lines.pct}%)`, + ) + process.exit(0) +} catch (error) { + console.error(`[merge-coverage] Error: ${error.message}`) + process.exit(1) +} From 6f176782d4550e9c3f80a7a142b81280e7181af6 Mon Sep 17 00:00:00 2001 From: Clay Townsend Date: Fri, 10 Apr 2026 22:44:56 +1000 Subject: [PATCH 11/24] perf(ci): skip audit when yarn.lock is unchanged MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cache the audit pass result keyed on yarn.lock hash. If the lockfile hasn't changed the dependency graph is identical — prior passing audit still valid, skip yarn install + yarn npm audit (~2m saved per run). Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 50bb9fb7369..ba31e86f14e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -111,11 +111,24 @@ jobs: key: yarn-${{ runner.os }}-${{ hashFiles('yarn.lock') }} restore-keys: yarn-${{ runner.os }}- + - name: Cache audit result + # Keyed on yarn.lock — if the lockfile is unchanged the dependency + # graph is identical and a prior passing audit is still valid. + id: audit-cache + uses: actions/cache@v4 + with: + path: .audit-passed + key: audit-${{ runner.os }}-${{ hashFiles('yarn.lock') }} + - name: Install dependencies + if: steps.audit-cache.outputs.cache-hit != 'true' run: yarn install --immutable - name: Audit dependencies for known CVEs - run: yarn npm audit --all --recursive --severity high + if: steps.audit-cache.outputs.cache-hit != 'true' + run: | + yarn npm audit --all --recursive --severity high + echo "passed" > .audit-passed # ── Quality checks ────────────────────────────────────────────────────────── # Typechecking, unit tests, i18n sync, and the Next.js app build. From 00743da133c729c55ba86d50d97a935261ce7889 Mon Sep 17 00:00:00 2001 From: Clay Townsend Date: Fri, 10 Apr 2026 22:51:06 +1000 Subject: [PATCH 12/24] perf(ci): cache node_modules to eliminate 85s yarn install per job With nodeLinker:node-modules, yarn extracts zips from .yarn/cache into node_modules on every install even when the package cache hits. The extraction alone accounts for ~85s per job (prepare, test, integration). Cache node_modules keyed on yarn.lock hash and skip install entirely on hit. First run saves; subsequent runs on the same yarn.lock restore in ~10-15s instead of ~85s. Saves ~4min across the 3 jobs that need it. The audit job already skips everything on audit-cache hit; node_modules cache is only checked when audit-cache misses (i.e. yarn.lock changed). Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 51 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba31e86f14e..6543b2edfe4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,19 @@ jobs: key: yarn-${{ runner.os }}-${{ hashFiles('yarn.lock') }} restore-keys: yarn-${{ runner.os }}- + - name: Cache node_modules + # With nodeLinker:node-modules, yarn extracts zips into node_modules even + # when .yarn/cache hits — the extraction alone is ~85s. Caching node_modules + # directly and skipping install on hit eliminates that cost. + id: nm-cache + uses: actions/cache@v4 + with: + path: | + node_modules + packages/*/node_modules + apps/*/node_modules + key: node-modules-${{ runner.os }}-${{ hashFiles('yarn.lock') }} + - name: Cache Turbo build outputs uses: actions/cache@v4 with: @@ -54,6 +67,7 @@ jobs: turbo-${{ runner.os }}- - name: Install dependencies + if: steps.nm-cache.outputs.cache-hit != 'true' run: yarn install --immutable - name: Build packages @@ -120,8 +134,21 @@ jobs: path: .audit-passed key: audit-${{ runner.os }}-${{ hashFiles('yarn.lock') }} - - name: Install dependencies + - name: Cache node_modules + # Only needed when audit-cache misses (i.e. yarn.lock changed). + # Shared key with other jobs — whichever runs first saves it. if: steps.audit-cache.outputs.cache-hit != 'true' + id: nm-cache + uses: actions/cache@v4 + with: + path: | + node_modules + packages/*/node_modules + apps/*/node_modules + key: node-modules-${{ runner.os }}-${{ hashFiles('yarn.lock') }} + + - name: Install dependencies + if: steps.audit-cache.outputs.cache-hit != 'true' && steps.nm-cache.outputs.cache-hit != 'true' run: yarn install --immutable - name: Audit dependencies for known CVEs @@ -199,6 +226,16 @@ jobs: key: yarn-${{ runner.os }}-${{ hashFiles('yarn.lock') }} restore-keys: yarn-${{ runner.os }}- + - name: Cache node_modules + id: nm-cache + uses: actions/cache@v4 + with: + path: | + node_modules + packages/*/node_modules + apps/*/node_modules + key: node-modules-${{ runner.os }}-${{ hashFiles('yarn.lock') }} + - name: Cache pip packages uses: actions/cache@v4 with: @@ -210,6 +247,7 @@ jobs: run: python3 -m pip install --upgrade pip markitdown - name: Install dependencies + if: steps.nm-cache.outputs.cache-hit != 'true' run: yarn install --immutable - name: Cache Turbo build outputs @@ -320,7 +358,18 @@ jobs: key: yarn-${{ runner.os }}-${{ hashFiles('yarn.lock') }} restore-keys: yarn-${{ runner.os }}- + - name: Cache node_modules + id: nm-cache + uses: actions/cache@v4 + with: + path: | + node_modules + packages/*/node_modules + apps/*/node_modules + key: node-modules-${{ runner.os }}-${{ hashFiles('yarn.lock') }} + - name: Install dependencies + if: steps.nm-cache.outputs.cache-hit != 'true' run: yarn install --immutable - name: Download build artifacts From 09b69aebd74dfc4c23386a6ef73ffe9206590845 Mon Sep 17 00:00:00 2001 From: Clay Townsend Date: Fri, 10 Apr 2026 22:59:45 +1000 Subject: [PATCH 13/24] perf(ci): use Microsoft Playwright container for integration job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the per-run 'npx playwright install --with-deps chromium' step with the pre-built mcr.microsoft.com/playwright:v1.50.0-jammy container. Chromium and all system dependencies are already in the image — saves ~25s per shard (5 shards = ~2min on push runs). Tag must stay in sync with @playwright/test in .ai/qa/package.json. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6543b2edfe4..a61a42db744 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -305,6 +305,11 @@ jobs: # Boots an ephemeral app server and runs the Playwright suite. # Blocked on 'test' so integration only runs when unit tests are green. # + # Uses the official Microsoft Playwright image so Chromium and all its + # system dependencies are pre-installed — no 'playwright install' step needed. + # Tag must match the @playwright/test version in .ai/qa/package.json. + # Update together when bumping Playwright. + # # On PRs: a single runner executes only affected modules (OM_INTEGRATION_MODULES). # If no module-level code changed the job is skipped entirely. # On pushes to main/develop: 5 parallel shards run the full suite, cutting @@ -312,6 +317,7 @@ jobs: # combines per-shard coverage-summary.json files into one report. ephemeral-integration: runs-on: ubuntu-latest + container: mcr.microsoft.com/playwright:v1.50.0-jammy needs: test if: needs.test.outputs.skip_integration != 'true' strategy: @@ -380,9 +386,6 @@ jobs: - name: Build app run: yarn workspace @open-mercato/app build - - name: Install Playwright browser - run: npx playwright install --with-deps chromium - - name: Run ephemeral integration tests with code coverage env: OM_INTEGRATION_APP_READY_TIMEOUT_SECONDS: '180' From 7784240b74de5a1af8107fc24f412ffd5225389f Mon Sep 17 00:00:00 2001 From: Clay Townsend Date: Fri, 10 Apr 2026 23:13:21 +1000 Subject: [PATCH 14/24] =?UTF-8?q?fix(ci):=20revert=20Playwright=20containe?= =?UTF-8?q?r=20=E2=80=94=20breaks=20Docker-in-Docker=20for=20ephemeral=20s?= =?UTF-8?q?erver?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The integration environment uses Docker CLI to start the app server. Running inside a container (mcr.microsoft.com/playwright) means Docker is not available in PATH, causing the test runner to fail immediately. Cache Playwright browser binaries at ~/.cache/ms-playwright instead — same skip-on-hit effect, no Docker conflict. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a61a42db744..571e034417d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -305,11 +305,6 @@ jobs: # Boots an ephemeral app server and runs the Playwright suite. # Blocked on 'test' so integration only runs when unit tests are green. # - # Uses the official Microsoft Playwright image so Chromium and all its - # system dependencies are pre-installed — no 'playwright install' step needed. - # Tag must match the @playwright/test version in .ai/qa/package.json. - # Update together when bumping Playwright. - # # On PRs: a single runner executes only affected modules (OM_INTEGRATION_MODULES). # If no module-level code changed the job is skipped entirely. # On pushes to main/develop: 5 parallel shards run the full suite, cutting @@ -317,7 +312,6 @@ jobs: # combines per-shard coverage-summary.json files into one report. ephemeral-integration: runs-on: ubuntu-latest - container: mcr.microsoft.com/playwright:v1.50.0-jammy needs: test if: needs.test.outputs.skip_integration != 'true' strategy: @@ -386,6 +380,17 @@ jobs: - name: Build app run: yarn workspace @open-mercato/app build + - name: Cache Playwright browsers + id: playwright-cache + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-chromium-${{ runner.os }}-v1.50.0 + + - name: Install Playwright browser + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: npx playwright install --with-deps chromium + - name: Run ephemeral integration tests with code coverage env: OM_INTEGRATION_APP_READY_TIMEOUT_SECONDS: '180' From c2df32035220dfa8d3585062da8f34c9cefaa0fb Mon Sep 17 00:00:00 2001 From: Clay Townsend Date: Sat, 11 Apr 2026 10:26:48 +1000 Subject: [PATCH 15/24] perf(ci): shard full-suite runs on PRs, not just pushes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously PRs always used a single runner regardless of scope. PRs that touch packages/shared, packages/ui, or other cross-cutting packages still trigger the full 311-test suite — which takes 20+ min on one runner. The shard_matrix output from the test job now drives the strategy: - Full suite (push OR PR touching shared/ui/core-lib): 5 parallel shards - Affected-only (PR touching specific modules): single runner with filter - No module changes: skip entirely merge-coverage triggers on shard_matrix != '["none"]' (replaces push-only). Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 571e034417d..624c323f80e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -166,6 +166,7 @@ jobs: outputs: skip_integration: ${{ steps.integration-scope.outputs.skip }} affected_modules: ${{ steps.integration-scope.outputs.modules }} + shard_matrix: ${{ steps.integration-scope.outputs.shard_matrix }} steps: - name: Checkout repository uses: actions/checkout@v4 @@ -177,23 +178,30 @@ jobs: run: git fetch origin ${{ github.base_ref }} --depth=1 - name: Compute integration scope - # On PRs: determine which modules changed and whether integration tests - # should run at all. On pushes to main/develop: always run the full suite. + # Determines which integration tests to run and whether to shard them. + # Full-suite runs (push OR PR touching shared packages) always use 5 shards. + # Affected-only runs (PR touching specific modules) use a single runner. + # CI/docs/scripts-only PRs skip integration entirely. id: integration-scope run: | + FULL_SHARDS='["1/5","2/5","3/5","4/5","5/5"]' + SINGLE_SHARD='["none"]' + if [ "${{ github.event_name }}" != "pull_request" ]; then echo "skip=false" >> "$GITHUB_OUTPUT" echo "modules=" >> "$GITHUB_OUTPUT" + echo "shard_matrix=${FULL_SHARDS}" >> "$GITHUB_OUTPUT" exit 0 fi CHANGED=$(git diff origin/${{ github.base_ref }} --name-only) - # These paths affect behaviour across all modules — run the full suite. + # These paths affect behaviour across all modules — run the full sharded suite. FULL_SUITE_PATTERN='^packages/shared/|^packages/ui/|^packages/events/|^packages/queue/|^packages/cache/|^packages/search/|^packages/onboarding/|^packages/webhooks/|^packages/core/src/lib/|^packages/enterprise/src/lib/|^apps/mercato/src/(app|lib|components|layout\.|page\.)' if echo "$CHANGED" | grep -qE "$FULL_SUITE_PATTERN"; then echo "skip=false" >> "$GITHUB_OUTPUT" echo "modules=" >> "$GITHUB_OUTPUT" + echo "shard_matrix=${FULL_SHARDS}" >> "$GITHUB_OUTPUT" exit 0 fi @@ -206,9 +214,12 @@ jobs: # Only non-module files changed (CI, docs, scripts) — skip integration. echo "skip=true" >> "$GITHUB_OUTPUT" echo "modules=" >> "$GITHUB_OUTPUT" + echo "shard_matrix=${SINGLE_SHARD}" >> "$GITHUB_OUTPUT" else + # Module-specific changes — single runner with affected-only filter. echo "skip=false" >> "$GITHUB_OUTPUT" echo "modules=$MODULES" >> "$GITHUB_OUTPUT" + echo "shard_matrix=${SINGLE_SHARD}" >> "$GITHUB_OUTPUT" fi - name: Setup Node.js @@ -317,7 +328,7 @@ jobs: strategy: fail-fast: false matrix: - shard: ${{ github.event_name == 'pull_request' && fromJson('["none"]') || fromJson('["1/5","2/5","3/5","4/5","5/5"]') }} + shard: ${{ fromJson(needs.test.outputs.shard_matrix) }} env: OM_ENABLE_ENTERPRISE_MODULES: 'true' OM_ENABLE_ENTERPRISE_MODULES_SSO: 'true' @@ -481,8 +492,8 @@ jobs: # writes a combined report to the job summary. merge-coverage: runs-on: ubuntu-latest - needs: ephemeral-integration - if: github.event_name == 'push' + needs: [test, ephemeral-integration] + if: needs.test.outputs.shard_matrix != '["none"]' steps: - name: Checkout repository uses: actions/checkout@v4 From 4ec4fe64457fd5654c37ae8bd7b1f90d98b17a53 Mon Sep 17 00:00:00 2001 From: Clay Townsend Date: Sat, 11 Apr 2026 17:22:32 +1000 Subject: [PATCH 16/24] ci: opt into Node.js 24 actions runtime, fix merge-coverage on missing shards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true to ci.yml, qa-deploy.yml, and snapshot.yml to silence Node 20 deprecation warnings ahead of the June 2 forced migration (actions/checkout@v4, actions/cache@v4, actions/setup-node@v4, actions/upload-artifact@v4, docker/build-push-action@v6, docker/setup-buildx-action@v3) - Change merge-coverage.mjs to exit 0 (warn) instead of exit 1 when no shard coverage files are found — coverage not being generated is not a CI-blocking condition Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 3 +++ .github/workflows/qa-deploy.yml | 3 +++ .github/workflows/snapshot.yml | 3 +++ scripts/merge-coverage.mjs | 4 ++-- 4 files changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 624c323f80e..a851b8fc02a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,9 @@ name: CI for Develop&Main permissions: contents: read +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' + on: push: branches: diff --git a/.github/workflows/qa-deploy.yml b/.github/workflows/qa-deploy.yml index d030a712104..f6b904e6a41 100644 --- a/.github/workflows/qa-deploy.yml +++ b/.github/workflows/qa-deploy.yml @@ -1,5 +1,8 @@ name: Deploy to Dokploy QA +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' + on: workflow_dispatch: inputs: diff --git a/.github/workflows/snapshot.yml b/.github/workflows/snapshot.yml index e115a089b5e..7f5335a71da 100644 --- a/.github/workflows/snapshot.yml +++ b/.github/workflows/snapshot.yml @@ -1,5 +1,8 @@ name: Snapshot Release +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' + on: push: branches: [develop] diff --git a/scripts/merge-coverage.mjs b/scripts/merge-coverage.mjs index 0a364975201..390793933af 100644 --- a/scripts/merge-coverage.mjs +++ b/scripts/merge-coverage.mjs @@ -75,8 +75,8 @@ try { const shardFiles = findShardSummaryFiles(resultsRoot) if (shardFiles.length === 0) { - console.error(`[merge-coverage] No shard coverage files found under ${resultsRoot}/coverage-shard-*/code/coverage-summary.json`) - process.exit(1) + console.warn(`[merge-coverage] No shard coverage files found under ${resultsRoot}/coverage-shard-*/code/coverage-summary.json — skipping merge`) + process.exit(0) } const summaries = shardFiles.map((filePath) => { From 3168c85a0c09d2930b50afc87e6be39778e16562 Mon Sep 17 00:00:00 2001 From: Clay Townsend Date: Sat, 11 Apr 2026 19:42:20 +1000 Subject: [PATCH 17/24] ci: add lint job, share app build across shards, fix merge-coverage exit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add parallel `lint` job (ESLint) that gates `test` alongside audit/prepare, implementing "Static Analysis First" principle - Upload `apps/mercato/.next/` from `test` job as `app-build` artifact; `ephemeral-integration` downloads it instead of rebuilding each shard (~96s × 5 shards = ~8 min saved per full run) - Fix `scripts/merge-coverage.mjs` to exit 0 with a warning when no shard coverage files are found rather than failing the job Co-Authored-By: Claude Sonnet 4.6 --- .ai/specs/2026-04-10-ci-cd-performance.md | 16 +++--- .github/workflows/ci.yml | 63 +++++++++++++++++++++-- 2 files changed, 69 insertions(+), 10 deletions(-) diff --git a/.ai/specs/2026-04-10-ci-cd-performance.md b/.ai/specs/2026-04-10-ci-cd-performance.md index bfbeb3b3179..ea0d549b9cf 100644 --- a/.ai/specs/2026-04-10-ci-cd-performance.md +++ b/.ai/specs/2026-04-10-ci-cd-performance.md @@ -20,16 +20,18 @@ There are five GitHub Actions workflows. Each has a distinct purpose. ``` prepare ──┐ - ├──► test ──► ephemeral-integration -audit ──┘ └──► docker-build + ├──► test ──► ephemeral-integration ──► merge-coverage +audit ──┤ └──► docker-build +lint ──┘ ``` | Job | What it does | Why | |-----|-------------|-----| | `prepare` | Install deps, build all packages twice (before and after `generate`), upload `dist/` + `.mercato/generated/` as artifact | Packages must be compiled before any other job can typecheck or test them. The double build is required because `generate` produces TypeScript files that packages then import. | | `audit` | Install deps, `yarn npm audit --severity high` | Security gate — run in parallel with `prepare` since it only needs `yarn.lock`, not built packages. | -| `test` | Download artifact, install markitdown, run dep-version check, i18n sync/usage check, `tsc --noEmit`, Jest unit tests, Next.js app build | Validates code correctness without a live server. Must run after `prepare` (needs compiled packages) and `audit` (security gate). | -| `ephemeral-integration` | Download artifact, build Next.js app, install Playwright, boot the full app in-process, run 311 Playwright spec files with `workers: 1` | End-to-end validation that modules interact correctly. Only runs when unit tests are green. | +| `lint` | Install deps, run `yarn lint` (ESLint) | Fast static analysis — runs in parallel with `prepare`/`audit`, fails fast before heavy jobs. | +| `test` | Download artifact, install markitdown, run dep-version check, i18n sync/usage check, `tsc --noEmit`, Jest unit tests, Next.js app build, upload app build artifact | Validates code correctness without a live server. Must run after `prepare` (needs compiled packages), `audit` (security gate), and `lint`. | +| `ephemeral-integration` | Download artifacts (packages + app build), install Playwright, boot the full app in-process, run 311 Playwright spec files with `workers: 1` | End-to-end validation that modules interact correctly. Only runs when unit tests are green. App build is reused from `test` job — not rebuilt. | | `docker-build` | Build three Dockerfiles using GitHub Actions layer cache | Validates production images build cleanly. Runs in parallel with ephemeral-integration. Not on critical path. | **Measured wall times (run 24178370484):** @@ -526,8 +528,10 @@ GitHub-hosted `ubuntu-latest` runners are ephemeral — every job starts from a 2. [x] "Compute shard metadata" step derives `shard_flag` (`--shard N/M` or empty) and `artifact_name` from `matrix.shard`; test command passes flag conditionally 3. [x] Artifact upload uses `${{ steps.shard-meta.outputs.artifact_name }}` — `integration-test-results-N` for shards, `integration-test-results` for PR 4. [x] Add `merge-coverage` job: downloads all `integration-test-results-*` artifacts with `merge-multiple: true`, runs `node scripts/merge-coverage.mjs`, writes step summary; only runs on push -5. [x] `scripts/merge-coverage.mjs` written (no external dependencies, scans `coverage-shard-*/code/coverage-summary.json`) -6. Validate on a full run (push to develop): confirm all 5 shards complete in ~10–12 min +5. [x] `scripts/merge-coverage.mjs` written (no external dependencies, scans `coverage-shard-*/code/coverage-summary.json`); exits 0 with warning when no shard files found (graceful degradation when coverage not produced) +6. [x] App build artifact sharing: `test` job uploads `apps/mercato/.next/` as `app-build` artifact after `yarn build:app`; `ephemeral-integration` downloads it instead of rebuilding (~96s × 5 shards = ~8 min saved) +7. [x] Lint job: runs ESLint in parallel with `prepare`/`audit`; `test` job now needs `[prepare, audit, lint]` +8. Validate on a full run (push to develop): confirm all 5 shards complete in ~10–12 min #### Implementation notes (completed) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a851b8fc02a..8eb0fe86ce0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -160,12 +160,53 @@ jobs: yarn npm audit --all --recursive --severity high echo "passed" > .audit-passed + # ── Lint ───────────────────────────────────────────────────────────────────── + # Fast static analysis — runs in parallel with prepare and audit. + # ESLint doesn't need compiled packages, so it can fail fast before heavy jobs. + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: Enable Corepack + run: corepack enable + + - name: Cache Yarn packages + uses: actions/cache@v4 + with: + path: .yarn/cache + key: yarn-${{ runner.os }}-${{ hashFiles('yarn.lock') }} + restore-keys: yarn-${{ runner.os }}- + + - name: Cache node_modules + id: nm-cache + uses: actions/cache@v4 + with: + path: | + node_modules + packages/*/node_modules + apps/*/node_modules + key: node-modules-${{ runner.os }}-${{ hashFiles('yarn.lock') }} + + - name: Install dependencies + if: steps.nm-cache.outputs.cache-hit != 'true' + run: yarn install --immutable + + - name: Lint + run: yarn lint + # ── Quality checks ────────────────────────────────────────────────────────── # Typechecking, unit tests, i18n sync, and the Next.js app build. - # Blocked on both 'prepare' (needs compiled packages) and 'audit' (security gate). + # Blocked on prepare (needs compiled packages), audit (security gate), and lint. test: runs-on: ubuntu-latest - needs: [prepare, audit] + needs: [prepare, audit, lint] outputs: skip_integration: ${{ steps.integration-scope.outputs.skip }} affected_modules: ${{ steps.integration-scope.outputs.modules }} @@ -315,6 +356,15 @@ jobs: - name: Build run: yarn build:app + - name: Upload app build + # Shares the Next.js output with integration shards so they skip rebuilding (~96s saved each). + uses: actions/upload-artifact@v4 + with: + name: app-build + path: apps/mercato/.next/ + retention-days: 1 + if-no-files-found: error + # ── Integration tests ─────────────────────────────────────────────────────── # Boots an ephemeral app server and runs the Playwright suite. # Blocked on 'test' so integration only runs when unit tests are green. @@ -391,8 +441,13 @@ jobs: with: name: build-artifacts - - name: Build app - run: yarn workspace @open-mercato/app build + - name: Download app build + # Reuses the Next.js build produced by the test job — avoids rebuilding + # (~96s) in each of the 5 parallel shards (~8 min saved total). + uses: actions/download-artifact@v4 + with: + name: app-build + path: apps/mercato/.next/ - name: Cache Playwright browsers id: playwright-cache From ced677609aa3a6080f93f1477d2588a3602c6b49 Mon Sep 17 00:00:00 2001 From: Clay Townsend Date: Mon, 13 Apr 2026 16:10:00 +1000 Subject: [PATCH 18/24] perf(ci): parallel integration, 15 shards, fix lint and distDir Key architectural changes to hit the 5-8 min target: Job graph before: prepare -> [test (build:app + typecheck + units)] -> integration (5 shards) Job graph after: prepare (build:app) -> test (typecheck+units) \ audit (parallel) -> integration (15 shards) -> merge-coverage (final gate) lint (parallel) / - Move build:app and scope computation from test into prepare so integration shards start the moment the build is ready, without waiting 3+ min for typecheck/unit tests - Integration now runs in parallel with test (not after it) - merge-coverage is the final gate: requires both test AND integration - 5 shards -> 15 shards for full-suite runs (~6 min vs ~19 min) - App build uploaded as separate artifact from prepare; each shard downloads it instead of rebuilding (~96s x 15 = ~24 min saved) - Fix distDir: output is .mercato/next/, not .next/ - Fix lint: exclude @open-mercato/app (next lint needs ESLint config) - Lint runs parallel with prepare/audit; gates test + integration Projected wall time for full push runs: ~8 min (was 26 min) Projected wall time for typical module PRs: ~3-5 min Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 201 +++++++++++++++++++++------------------ 1 file changed, 109 insertions(+), 92 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8eb0fe86ce0..a04c6c9c77b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,22 +16,82 @@ on: - develop # Cancel any in-progress run on the same branch when a new push arrives. -# Prevents multiple 55-min runs queuing up from rapid pushes. +# Prevents multiple long runs queuing up from rapid pushes. concurrency: group: ci-${{ github.ref }} cancel-in-progress: true jobs: # ── Shared build ──────────────────────────────────────────────────────────── - # Installs deps, compiles all packages, and runs the code generator. - # Uploads the compiled dist/ + generated .mercato/ as an artifact so every - # downstream job can skip this work entirely. + # Installs deps, compiles all packages, runs the code generator, and builds + # the Next.js app. Also computes integration scope so shards can start the + # moment this job finishes — without waiting for typecheck or unit tests. + # + # Uploads two artifacts: + # build-artifacts — packages/*/dist/ + generated files (needed by test) + # app-build — apps/mercato/.mercato/next/ (needed by integration shards) prepare: runs-on: ubuntu-latest + outputs: + skip_integration: ${{ steps.integration-scope.outputs.skip }} + affected_modules: ${{ steps.integration-scope.outputs.modules }} + shard_matrix: ${{ steps.integration-scope.outputs.shard_matrix }} steps: - name: Checkout repository uses: actions/checkout@v4 + - name: Fetch base branch for change detection + # Needed so the scope step can diff against the PR base. + if: github.event_name == 'pull_request' + run: git fetch origin ${{ github.base_ref }} --depth=1 + + - name: Compute integration scope + # Determines which integration tests to run and whether to shard them. + # Full-suite runs (push OR PR touching shared packages) use 15 shards. + # Affected-only runs (PR touching specific modules) use a single runner. + # CI/docs/scripts-only PRs skip integration entirely. + # Runs in prepare so shards can start the moment the build is ready, + # without waiting for typecheck or unit tests to complete. + id: integration-scope + run: | + FULL_SHARDS='["1/15","2/15","3/15","4/15","5/15","6/15","7/15","8/15","9/15","10/15","11/15","12/15","13/15","14/15","15/15"]' + SINGLE_SHARD='["none"]' + + if [ "${{ github.event_name }}" != "pull_request" ]; then + echo "skip=false" >> "$GITHUB_OUTPUT" + echo "modules=" >> "$GITHUB_OUTPUT" + echo "shard_matrix=${FULL_SHARDS}" >> "$GITHUB_OUTPUT" + exit 0 + fi + + CHANGED=$(git diff origin/${{ github.base_ref }} --name-only) + + # These paths affect behaviour across all modules — run the full sharded suite. + FULL_SUITE_PATTERN='^packages/shared/|^packages/ui/|^packages/events/|^packages/queue/|^packages/cache/|^packages/search/|^packages/onboarding/|^packages/webhooks/|^packages/core/src/lib/|^packages/enterprise/src/lib/|^apps/mercato/src/(app|lib|components|layout\.|page\.)' + if echo "$CHANGED" | grep -qE "$FULL_SUITE_PATTERN"; then + echo "skip=false" >> "$GITHUB_OUTPUT" + echo "modules=" >> "$GITHUB_OUTPUT" + echo "shard_matrix=${FULL_SHARDS}" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Extract module folder names from module-specific paths. + MODULES=$(echo "$CHANGED" | \ + grep -oP '(?:packages/(?:core|enterprise)|apps/mercato)/src/modules/\K[^/]+' | \ + sort -u | paste -sd,) + + if [ -z "$MODULES" ]; then + # Only non-module files changed (CI, docs, scripts) — skip integration. + echo "skip=true" >> "$GITHUB_OUTPUT" + echo "modules=" >> "$GITHUB_OUTPUT" + echo "shard_matrix=${SINGLE_SHARD}" >> "$GITHUB_OUTPUT" + else + # Module-specific changes — single runner with affected-only filter. + echo "skip=false" >> "$GITHUB_OUTPUT" + echo "modules=$MODULES" >> "$GITHUB_OUTPUT" + echo "shard_matrix=${SINGLE_SHARD}" >> "$GITHUB_OUTPUT" + fi + - name: Setup Node.js uses: actions/setup-node@v4 with: @@ -77,7 +137,7 @@ jobs: # Always builds all packages — no filter. Turbo cache handles the speedup: # unchanged packages are cache hits and restored to disk in milliseconds. # Filtering here would produce an incomplete artifact, breaking downstream - # jobs that need every package's dist/ output (test, ephemeral-integration). + # jobs that need every package's dist/ output. run: yarn build:packages env: TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} @@ -94,6 +154,12 @@ jobs: TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_TEAM: ${{ secrets.TURBO_TEAM }} + - name: Build app + # Build the Next.js app once here so all 15 integration shards can reuse + # it — avoids ~96s x 15 = ~24 min of redundant per-shard rebuilds. + # distDir is .mercato/next (set in apps/mercato/next.config.ts). + run: yarn build:app + - name: Upload build artifacts uses: actions/upload-artifact@v4 with: @@ -105,6 +171,16 @@ jobs: retention-days: 1 if-no-files-found: error + - name: Upload app build + # Separate artifact so the test job (typecheck/unit tests) does not need + # to download the large Next.js output unnecessarily. + uses: actions/upload-artifact@v4 + with: + name: app-build + path: apps/mercato/.mercato/next/ + retention-days: 1 + if-no-files-found: error + # ── Security audit ────────────────────────────────────────────────────────── # Runs in parallel with 'prepare' — only needs yarn install, not the full build. audit: @@ -162,7 +238,9 @@ jobs: # ── Lint ───────────────────────────────────────────────────────────────────── # Fast static analysis — runs in parallel with prepare and audit. - # ESLint doesn't need compiled packages, so it can fail fast before heavy jobs. + # ESLint does not need compiled packages, so it can fail fast before heavy jobs. + # @open-mercato/app is excluded: its next lint script requires an ESLint config + # that is not yet present in the repo. lint: runs-on: ubuntu-latest steps: @@ -199,73 +277,23 @@ jobs: run: yarn install --immutable - name: Lint - run: yarn lint + run: yarn turbo run lint --filter=!@open-mercato/app # ── Quality checks ────────────────────────────────────────────────────────── - # Typechecking, unit tests, i18n sync, and the Next.js app build. - # Blocked on prepare (needs compiled packages), audit (security gate), and lint. + # Typechecking and unit tests. Runs in parallel with ephemeral-integration + # so typecheck cost does not add to the integration wall time. + # merge-coverage and docker-build both wait for this job to complete. test: runs-on: ubuntu-latest needs: [prepare, audit, lint] - outputs: - skip_integration: ${{ steps.integration-scope.outputs.skip }} - affected_modules: ${{ steps.integration-scope.outputs.modules }} - shard_matrix: ${{ steps.integration-scope.outputs.shard_matrix }} steps: - name: Checkout repository uses: actions/checkout@v4 - name: Fetch base branch for change detection - # Needed so Turbo can compute which packages changed vs. the PR base, - # and so the integration scope step can diff against it. if: github.event_name == 'pull_request' run: git fetch origin ${{ github.base_ref }} --depth=1 - - name: Compute integration scope - # Determines which integration tests to run and whether to shard them. - # Full-suite runs (push OR PR touching shared packages) always use 5 shards. - # Affected-only runs (PR touching specific modules) use a single runner. - # CI/docs/scripts-only PRs skip integration entirely. - id: integration-scope - run: | - FULL_SHARDS='["1/5","2/5","3/5","4/5","5/5"]' - SINGLE_SHARD='["none"]' - - if [ "${{ github.event_name }}" != "pull_request" ]; then - echo "skip=false" >> "$GITHUB_OUTPUT" - echo "modules=" >> "$GITHUB_OUTPUT" - echo "shard_matrix=${FULL_SHARDS}" >> "$GITHUB_OUTPUT" - exit 0 - fi - - CHANGED=$(git diff origin/${{ github.base_ref }} --name-only) - - # These paths affect behaviour across all modules — run the full sharded suite. - FULL_SUITE_PATTERN='^packages/shared/|^packages/ui/|^packages/events/|^packages/queue/|^packages/cache/|^packages/search/|^packages/onboarding/|^packages/webhooks/|^packages/core/src/lib/|^packages/enterprise/src/lib/|^apps/mercato/src/(app|lib|components|layout\.|page\.)' - if echo "$CHANGED" | grep -qE "$FULL_SUITE_PATTERN"; then - echo "skip=false" >> "$GITHUB_OUTPUT" - echo "modules=" >> "$GITHUB_OUTPUT" - echo "shard_matrix=${FULL_SHARDS}" >> "$GITHUB_OUTPUT" - exit 0 - fi - - # Extract module folder names from module-specific paths. - MODULES=$(echo "$CHANGED" | \ - grep -oP '(?:packages/(?:core|enterprise)|apps/mercato)/src/modules/\K[^/]+' | \ - sort -u | paste -sd,) - - if [ -z "$MODULES" ]; then - # Only non-module files changed (CI, docs, scripts) — skip integration. - echo "skip=true" >> "$GITHUB_OUTPUT" - echo "modules=" >> "$GITHUB_OUTPUT" - echo "shard_matrix=${SINGLE_SHARD}" >> "$GITHUB_OUTPUT" - else - # Module-specific changes — single runner with affected-only filter. - echo "skip=false" >> "$GITHUB_OUTPUT" - echo "modules=$MODULES" >> "$GITHUB_OUTPUT" - echo "shard_matrix=${SINGLE_SHARD}" >> "$GITHUB_OUTPUT" - fi - - name: Setup Node.js uses: actions/setup-node@v4 with: @@ -331,8 +359,6 @@ jobs: - name: Checking types # On PRs: scope to packages/app that changed since the base branch. - # Generated files are available from the artifact download, so the app - # can be typechecked safely if app-level code changed. # On pushes to protected branches: full typecheck, no filter. run: | if [ "${{ github.event_name }}" = "pull_request" ]; then @@ -353,35 +379,25 @@ jobs: yarn test fi - - name: Build - run: yarn build:app - - - name: Upload app build - # Shares the Next.js output with integration shards so they skip rebuilding (~96s saved each). - uses: actions/upload-artifact@v4 - with: - name: app-build - path: apps/mercato/.next/ - retention-days: 1 - if-no-files-found: error - # ── Integration tests ─────────────────────────────────────────────────────── # Boots an ephemeral app server and runs the Playwright suite. - # Blocked on 'test' so integration only runs when unit tests are green. + # Starts as soon as 'prepare' completes — runs in PARALLEL with 'test' so + # typecheck/unit-test time does not add to the integration wall time. + # 'merge-coverage' is the final gate and waits for both jobs. # # On PRs: a single runner executes only affected modules (OM_INTEGRATION_MODULES). # If no module-level code changed the job is skipped entirely. - # On pushes to main/develop: 5 parallel shards run the full suite, cutting - # wall time from ~47 min to ~10 min. A merge-coverage job then - # combines per-shard coverage-summary.json files into one report. + # On pushes to main/develop: 15 parallel shards run the full suite, cutting + # wall time from ~45 min to ~6 min. A merge-coverage job then + # combines per-shard coverage reports. ephemeral-integration: runs-on: ubuntu-latest - needs: test - if: needs.test.outputs.skip_integration != 'true' + needs: [prepare, audit, lint] + if: needs.prepare.outputs.skip_integration != 'true' strategy: fail-fast: false matrix: - shard: ${{ fromJson(needs.test.outputs.shard_matrix) }} + shard: ${{ fromJson(needs.prepare.outputs.shard_matrix) }} env: OM_ENABLE_ENTERPRISE_MODULES: 'true' OM_ENABLE_ENTERPRISE_MODULES_SSO: 'true' @@ -442,12 +458,13 @@ jobs: name: build-artifacts - name: Download app build - # Reuses the Next.js build produced by the test job — avoids rebuilding - # (~96s) in each of the 5 parallel shards (~8 min saved total). + # Reuses the Next.js build produced by prepare — avoids rebuilding + # (~96s) in each of the 15 parallel shards (~24 min saved total). + # distDir is .mercato/next (set in apps/mercato/next.config.ts). uses: actions/download-artifact@v4 with: name: app-build - path: apps/mercato/.next/ + path: apps/mercato/.mercato/next/ - name: Cache Playwright browsers id: playwright-cache @@ -463,7 +480,7 @@ jobs: - name: Run ephemeral integration tests with code coverage env: OM_INTEGRATION_APP_READY_TIMEOUT_SECONDS: '180' - OM_INTEGRATION_MODULES: ${{ needs.test.outputs.affected_modules }} + OM_INTEGRATION_MODULES: ${{ needs.prepare.outputs.affected_modules }} run: | SHARD_FLAG="${{ steps.shard-meta.outputs.shard_flag }}" if [ -n "$SHARD_FLAG" ]; then @@ -545,13 +562,13 @@ jobs: if-no-files-found: ignore # ── Merge shard coverage ───────────────────────────────────────────────────── - # Runs after all 5 ephemeral-integration shards complete (push only). - # Downloads per-shard artifacts, merges coverage-summary.json files, and - # writes a combined report to the job summary. + # Final gate — runs after all integration shards AND the test job complete. + # Ensures both typecheck/units and integration must pass before reporting green. + # On push: downloads per-shard artifacts, merges coverage reports, writes summary. merge-coverage: runs-on: ubuntu-latest - needs: [test, ephemeral-integration] - if: needs.test.outputs.shard_matrix != '["none"]' + needs: [prepare, test, ephemeral-integration] + if: needs.prepare.outputs.shard_matrix != '["none"]' steps: - name: Checkout repository uses: actions/checkout@v4 @@ -617,8 +634,8 @@ jobs: " "$SUMMARY_FILE" # ── Docker image builds ────────────────────────────────────────────────────── - # Validates all Dockerfiles build cleanly. Runs in parallel with - # 'ephemeral-integration' (both need test), so it does not add to wall time. + # Validates all Dockerfiles build cleanly. Runs after test, in parallel with + # merge-coverage, so it does not add to wall time. docker-build: runs-on: ubuntu-latest needs: test From 70684262c2a522b4b7046b6e7de2046d90395fae Mon Sep 17 00:00:00 2001 From: Clay Townsend Date: Mon, 13 Apr 2026 16:53:21 +1000 Subject: [PATCH 19/24] fix(ci): tar app build before upload to avoid colon-in-filename error actions/upload-artifact rejects filenames containing colons. Next.js generates chunk files like [externals]_node:fs_promises_*.js in the .mercato/next/ output directory. Tar the directory into a single archive before uploading and extract after downloading in each shard. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a04c6c9c77b..df5dfc7e1b4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -171,13 +171,19 @@ jobs: retention-days: 1 if-no-files-found: error + - name: Archive app build + # upload-artifact rejects filenames containing colons — Next.js chunk + # files include colons (e.g. [externals]_node:fs_promises_*.js). + # Tar the directory first so the artifact is a single clean file. + run: tar -czf app-build.tar.gz -C apps/mercato .mercato/next + - name: Upload app build # Separate artifact so the test job (typecheck/unit tests) does not need # to download the large Next.js output unnecessarily. uses: actions/upload-artifact@v4 with: name: app-build - path: apps/mercato/.mercato/next/ + path: app-build.tar.gz retention-days: 1 if-no-files-found: error @@ -460,11 +466,14 @@ jobs: - name: Download app build # Reuses the Next.js build produced by prepare — avoids rebuilding # (~96s) in each of the 15 parallel shards (~24 min saved total). - # distDir is .mercato/next (set in apps/mercato/next.config.ts). uses: actions/download-artifact@v4 with: name: app-build - path: apps/mercato/.mercato/next/ + + - name: Extract app build + # Restore the tarball into apps/mercato/ so .mercato/next/ exists + # at the path expected by the Next.js server. + run: tar -xzf app-build.tar.gz -C apps/mercato/ - name: Cache Playwright browsers id: playwright-cache From dbc0ccba5620c7746c53c90601ce5ea8ee45fa20 Mon Sep 17 00:00:00 2001 From: Clay Townsend Date: Tue, 14 Apr 2026 21:31:15 +1000 Subject: [PATCH 20/24] perf(ci): skip docker-build and app build for CI-only PRs, run docker in parallel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For branches that only touch CI/scripts/docs files (turbo.json, scripts/, packages/cli/testing/, .github/), the docker-build job was rebuilding the entire Next.js app from scratch because these files sit in early Dockerfile COPY layers and fully bust the GHA Docker layer cache. With docker-build running sequentially after test, this produced an 18 min wall time for a branch that never changed any app code. Three changes: 1. docker-build now needs prepare (not test), running in parallel with test and ephemeral-integration. Removes test time from docker critical path. 2. docker-build is skipped when skip_integration == 'true' (CI/docs-only PRs). The Docker image is functionally unchanged; there is nothing to validate. This eliminates the 10+ min rebuild cost for such PRs. 3. Build app, Archive app build, and Upload app build steps in prepare are skipped when skip_integration == 'true'. No integration shard will ever download the artifact, so the ~95s build + tar + upload are pure waste. Also improves .dockerignore to exclude **/testing/ directories and CI-only scripts so future changes to testing utilities and merge helpers do not bust Docker layer cache when they change alongside app code. Expected wall time for CI-only PRs: Before: prepare (4 min) → test (4 min) → docker-build (10+ min) = 18 min After: prepare (2.5 min) → test (4 min) = 6.5 min Co-Authored-By: Claude Sonnet 4.6 --- .ai/specs/2026-04-10-ci-cd-performance.md | 40 +++++++++++++++++++---- .dockerignore | 8 ++++- .github/workflows/ci.yml | 28 ++++++++++++---- 3 files changed, 62 insertions(+), 14 deletions(-) diff --git a/.ai/specs/2026-04-10-ci-cd-performance.md b/.ai/specs/2026-04-10-ci-cd-performance.md index ea0d549b9cf..49e630ad1b3 100644 --- a/.ai/specs/2026-04-10-ci-cd-performance.md +++ b/.ai/specs/2026-04-10-ci-cd-performance.md @@ -20,19 +20,25 @@ There are five GitHub Actions workflows. Each has a distinct purpose. ``` prepare ──┐ - ├──► test ──► ephemeral-integration ──► merge-coverage -audit ──┤ └──► docker-build -lint ──┘ + ├──► test ──────────────────────────────► merge-coverage +audit ──┤ ▲ +lint ──┘── ephemeral-integration (parallel) ──────────┘ + └── docker-build (parallel, skipped for CI-only PRs) ``` +Key properties: +- `docker-build` and `ephemeral-integration` both start the instant `prepare` finishes — they no longer wait for `test`. +- For CI/docs/scripts-only PRs (`skip_integration == 'true'`): `ephemeral-integration`, `docker-build`, and the app build in `prepare` are all skipped. Wall time = `prepare` (no app build, ~2.5 min) + `test` (~4 min) = **~6.5 min**. +- For module PRs: single shard runs in parallel with `test`; `docker-build` also runs in parallel. + | Job | What it does | Why | |-----|-------------|-----| -| `prepare` | Install deps, build all packages twice (before and after `generate`), upload `dist/` + `.mercato/generated/` as artifact | Packages must be compiled before any other job can typecheck or test them. The double build is required because `generate` produces TypeScript files that packages then import. | +| `prepare` | Install deps, build all packages twice (before and after `generate`), upload `dist/` + `.mercato/generated/` artifact; also builds and uploads the Next.js app when integration tests will run | Packages must be compiled before any other job can typecheck or test them. The double build is required because `generate` produces TypeScript files that packages then import. App build is skipped for CI-only PRs since no integration shard will consume it. | | `audit` | Install deps, `yarn npm audit --severity high` | Security gate — run in parallel with `prepare` since it only needs `yarn.lock`, not built packages. | | `lint` | Install deps, run `yarn lint` (ESLint) | Fast static analysis — runs in parallel with `prepare`/`audit`, fails fast before heavy jobs. | -| `test` | Download artifact, install markitdown, run dep-version check, i18n sync/usage check, `tsc --noEmit`, Jest unit tests, Next.js app build, upload app build artifact | Validates code correctness without a live server. Must run after `prepare` (needs compiled packages), `audit` (security gate), and `lint`. | -| `ephemeral-integration` | Download artifacts (packages + app build), install Playwright, boot the full app in-process, run 311 Playwright spec files with `workers: 1` | End-to-end validation that modules interact correctly. Only runs when unit tests are green. App build is reused from `test` job — not rebuilt. | -| `docker-build` | Build three Dockerfiles using GitHub Actions layer cache | Validates production images build cleanly. Runs in parallel with ephemeral-integration. Not on critical path. | +| `test` | Download artifact, install markitdown, run dep-version check, i18n sync/usage check, `tsc --noEmit`, Jest unit tests | Validates code correctness without a live server. Must run after `prepare` (needs compiled packages), `audit` (security gate), and `lint`. | +| `ephemeral-integration` | Download artifacts (packages + app build), install Playwright, boot the full app in-process, run Playwright specs | End-to-end validation that modules interact correctly. Starts in parallel with `test` — does not wait for unit tests. App build is shared from `prepare`. Skipped entirely for CI/docs/scripts-only PRs. | +| `docker-build` | Build three Dockerfiles using GitHub Actions layer cache | Validates production images build cleanly. Runs in parallel with `test` and `ephemeral-integration`. Skipped for CI/docs-only PRs to avoid 10+ min rebuilds caused by Docker layer cache busting (e.g. when `turbo.json` or `scripts/` change without app code changes). | **Measured wall times (run 24178370484):** @@ -556,6 +562,26 @@ GitHub-hosted `ubuntu-latest` runners are ephemeral — every job starts from a 3. Pin Node version in CI: `node-version: '24.x'` → exact patch from `.nvmrc` 4. Add `.nvmrc` with exact Node version used in production +### Phase 5b — Docker-build decoupling and CI-only skip (current work) + +**Problem:** CI/infra-only PRs (touching only `turbo.json`, `scripts/`, `.github/`, `packages/cli/src/lib/testing/`) caused Docker layer cache to fully bust. The `docker-build` job then rebuilt the Next.js app from scratch inside Docker (~10 min) and ran sequentially after `test` — producing an 18 min wall time for a branch that never changed app code. + +**Root cause:** `turbo.json` sits in the first `COPY` layer of the Dockerfile (before `RUN yarn install`). Any change to it invalidates all subsequent layers, including the expensive `RUN yarn build` step. + +**Changes made:** + +1. **`docker-build: needs: prepare`** (was `needs: test`) — docker and test now run in parallel. For module PRs where Docker rebuilds (~10 min) this removes 4 min of wasted wait. + +2. **Skip `docker-build` when `skip_integration == 'true'`** — CI/docs/scripts-only PRs have `skip_integration=true`. These PRs have not changed any app source or Dockerfiles; the Docker image is functionally identical to the last build. Skipping saves 10+ min rebuild cost. + +3. **Skip app build in `prepare` when `skip_integration == 'true'`** — the Next.js build (95s) + tar + upload are unnecessary for CI-only PRs since no integration shard will consume the artifact. Saves ~2 min in the prepare job. + +4. **`.dockerignore` improvements** — added `**/testing/` (testing utilities like `packages/cli/src/lib/testing/`) and CI-only scripts (`scripts/merge-coverage.mjs`, `scripts/i18n-check-sync.ts`, `scripts/i18n-check-usage.ts`) to prevent these files from busting Docker layer cache in future PRs where they change alongside app code. + +**Result for CI-only PRs:** +- Before: `prepare (4 min) → test (4 min) → docker-build (10 min)` = 18 min (sequential) +- After: `prepare (2.5 min) → test (4 min)` = 6.5 min (docker-build skipped) + ### Phase 6 — Self-hosted warm runners (deferred) Self-hosted runners would eliminate cold-start overhead (~1m per job) and enable persistent Turbo local cache. Deferred pending discussion with the open-mercato upstream team — running their CI on external infra requires coordination around secrets access, runner security, and maintenance responsibility. diff --git a/.dockerignore b/.dockerignore index 701985aca35..382e217e985 100644 --- a/.dockerignore +++ b/.dockerignore @@ -50,13 +50,19 @@ apps/docs/.docusaurus Dockerfile docker-compose*.yml -# Test files +# Test files and testing utilities (not used in production builds) **/*.test.ts **/*.test.tsx **/*.spec.ts **/*.spec.tsx **/__tests__ **/tests +**/testing/ + +# CI-only scripts (not used during Docker builds) +scripts/merge-coverage.mjs +scripts/i18n-check-sync.ts +scripts/i18n-check-usage.ts # Development certificates certs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index df5dfc7e1b4..77ba68e7882 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,8 @@ jobs: # Uploads two artifacts: # build-artifacts — packages/*/dist/ + generated files (needed by test) # app-build — apps/mercato/.mercato/next/ (needed by integration shards) + # Only uploaded when integration tests will actually run; + # skipped for CI/docs-only PRs to save ~2 min. prepare: runs-on: ubuntu-latest outputs: @@ -158,6 +160,9 @@ jobs: # Build the Next.js app once here so all 15 integration shards can reuse # it — avoids ~96s x 15 = ~24 min of redundant per-shard rebuilds. # distDir is .mercato/next (set in apps/mercato/next.config.ts). + # Skipped for CI/docs-only PRs where integration is also skipped — no + # shard will download the artifact, so there is nothing to produce. + if: steps.integration-scope.outputs.skip != 'true' run: yarn build:app - name: Upload build artifacts @@ -175,11 +180,13 @@ jobs: # upload-artifact rejects filenames containing colons — Next.js chunk # files include colons (e.g. [externals]_node:fs_promises_*.js). # Tar the directory first so the artifact is a single clean file. + if: steps.integration-scope.outputs.skip != 'true' run: tar -czf app-build.tar.gz -C apps/mercato .mercato/next - name: Upload app build # Separate artifact so the test job (typecheck/unit tests) does not need # to download the large Next.js output unnecessarily. + if: steps.integration-scope.outputs.skip != 'true' uses: actions/upload-artifact@v4 with: name: app-build @@ -643,15 +650,24 @@ jobs: " "$SUMMARY_FILE" # ── Docker image builds ────────────────────────────────────────────────────── - # Validates all Dockerfiles build cleanly. Runs after test, in parallel with - # merge-coverage, so it does not add to wall time. + # Validates all Dockerfiles build cleanly. Runs in parallel with test and + # integration — no longer sequenced after test — so it does not add to wall + # time on PRs where Docker rebuilds are fast (GHA cache hit). + # + # Skipped for CI/docs/scripts-only PRs (skip_integration == 'true'): those + # changes do not affect app source or Dockerfiles, so the Docker image is + # identical to the last build and there is nothing to validate. This avoids + # a 10+ min rebuild caused by Docker layer cache busting when files like + # turbo.json or scripts/ are the only things that changed. docker-build: runs-on: ubuntu-latest - needs: test - # Only run on non-fork PRs and direct pushes (forks don't have access to GHA cache) + needs: prepare + # Only run on non-fork PRs and direct pushes (forks don't have access to GHA cache). + # Also skip for CI/docs-only PRs — no app code changed, image is unchanged. if: | - github.event_name == 'push' || - github.event.pull_request.head.repo.full_name == github.repository + needs.prepare.outputs.skip_integration != 'true' && + (github.event_name == 'push' || + github.event.pull_request.head.repo.full_name == github.repository) steps: - name: Checkout repository uses: actions/checkout@v4 From a29cb2b3048c95175cc3195e0cd1f1b030919a64 Mon Sep 17 00:00:00 2001 From: Clay Townsend Date: Tue, 14 Apr 2026 21:42:02 +1000 Subject: [PATCH 21/24] ci: upgrade actions to Node.js 24-native versions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves "Node.js 20 is deprecated" warnings in all workflow jobs. actions/checkout v4 → v6 actions/setup-node v4 → v6 actions/cache v4 → v5 actions/upload-artifact v4 → v7 actions/download-artifact v4 → v8 Applied to ci.yml (all jobs), qa-deploy.yml, and snapshot.yml. release.yml and snapshot.yml checkout/setup-node were already on v6. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 70 ++++++++++++++++----------------- .github/workflows/qa-deploy.yml | 2 +- .github/workflows/snapshot.yml | 2 +- 3 files changed, 37 insertions(+), 37 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 77ba68e7882..7a7ed878733 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,7 +40,7 @@ jobs: shard_matrix: ${{ steps.integration-scope.outputs.shard_matrix }} steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Fetch base branch for change detection # Needed so the scope step can diff against the PR base. @@ -95,7 +95,7 @@ jobs: fi - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: 24 @@ -103,7 +103,7 @@ jobs: run: corepack enable - name: Cache Yarn packages - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: .yarn/cache key: yarn-${{ runner.os }}-${{ hashFiles('yarn.lock') }} @@ -114,7 +114,7 @@ jobs: # when .yarn/cache hits — the extraction alone is ~85s. Caching node_modules # directly and skipping install on hit eliminates that cost. id: nm-cache - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | node_modules @@ -123,7 +123,7 @@ jobs: key: node-modules-${{ runner.os }}-${{ hashFiles('yarn.lock') }} - name: Cache Turbo build outputs - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: .turbo key: turbo-${{ runner.os }}-${{ github.sha }} @@ -166,7 +166,7 @@ jobs: run: yarn build:app - name: Upload build artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: build-artifacts path: | @@ -187,7 +187,7 @@ jobs: # Separate artifact so the test job (typecheck/unit tests) does not need # to download the large Next.js output unnecessarily. if: steps.integration-scope.outputs.skip != 'true' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: app-build path: app-build.tar.gz @@ -200,10 +200,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: 24 @@ -211,7 +211,7 @@ jobs: run: corepack enable - name: Cache Yarn packages - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: .yarn/cache key: yarn-${{ runner.os }}-${{ hashFiles('yarn.lock') }} @@ -221,7 +221,7 @@ jobs: # Keyed on yarn.lock — if the lockfile is unchanged the dependency # graph is identical and a prior passing audit is still valid. id: audit-cache - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: .audit-passed key: audit-${{ runner.os }}-${{ hashFiles('yarn.lock') }} @@ -231,7 +231,7 @@ jobs: # Shared key with other jobs — whichever runs first saves it. if: steps.audit-cache.outputs.cache-hit != 'true' id: nm-cache - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | node_modules @@ -258,10 +258,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: 24 @@ -269,7 +269,7 @@ jobs: run: corepack enable - name: Cache Yarn packages - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: .yarn/cache key: yarn-${{ runner.os }}-${{ hashFiles('yarn.lock') }} @@ -277,7 +277,7 @@ jobs: - name: Cache node_modules id: nm-cache - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | node_modules @@ -301,14 +301,14 @@ jobs: needs: [prepare, audit, lint] steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Fetch base branch for change detection if: github.event_name == 'pull_request' run: git fetch origin ${{ github.base_ref }} --depth=1 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: 24 @@ -316,7 +316,7 @@ jobs: run: corepack enable - name: Cache Yarn packages - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: .yarn/cache key: yarn-${{ runner.os }}-${{ hashFiles('yarn.lock') }} @@ -324,7 +324,7 @@ jobs: - name: Cache node_modules id: nm-cache - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | node_modules @@ -333,7 +333,7 @@ jobs: key: node-modules-${{ runner.os }}-${{ hashFiles('yarn.lock') }} - name: Cache pip packages - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ~/.cache/pip key: pip-markitdown-v1 @@ -347,7 +347,7 @@ jobs: run: yarn install --immutable - name: Cache Turbo build outputs - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: .turbo key: turbo-${{ runner.os }}-${{ github.sha }} @@ -356,7 +356,7 @@ jobs: turbo-${{ runner.os }}- - name: Download build artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: build-artifacts @@ -434,10 +434,10 @@ jobs: fi - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: 24 @@ -445,7 +445,7 @@ jobs: run: corepack enable - name: Cache Yarn packages - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: .yarn/cache key: yarn-${{ runner.os }}-${{ hashFiles('yarn.lock') }} @@ -453,7 +453,7 @@ jobs: - name: Cache node_modules id: nm-cache - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | node_modules @@ -466,14 +466,14 @@ jobs: run: yarn install --immutable - name: Download build artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: build-artifacts - name: Download app build # Reuses the Next.js build produced by prepare — avoids rebuilding # (~96s) in each of the 15 parallel shards (~24 min saved total). - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: app-build @@ -484,7 +484,7 @@ jobs: - name: Cache Playwright browsers id: playwright-cache - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ~/.cache/ms-playwright key: playwright-chromium-${{ runner.os }}-v1.50.0 @@ -567,7 +567,7 @@ jobs: - name: Upload integration test artifacts if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: ${{ steps.shard-meta.outputs.artifact_name }} path: | @@ -587,15 +587,15 @@ jobs: if: needs.prepare.outputs.shard_matrix != '["none"]' steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: 24 - name: Download shard artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: pattern: integration-test-results-* merge-multiple: true @@ -670,7 +670,7 @@ jobs: github.event.pull_request.head.repo.full_name == github.repository) steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 diff --git a/.github/workflows/qa-deploy.yml b/.github/workflows/qa-deploy.yml index f6b904e6a41..80c1afbfb1c 100644 --- a/.github/workflows/qa-deploy.yml +++ b/.github/workflows/qa-deploy.yml @@ -47,7 +47,7 @@ jobs: steps: - name: Checkout selected branch - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: ref: ${{ github.event.inputs.branch }} diff --git a/.github/workflows/snapshot.yml b/.github/workflows/snapshot.yml index 7f5335a71da..93193aa0ce5 100644 --- a/.github/workflows/snapshot.yml +++ b/.github/workflows/snapshot.yml @@ -337,7 +337,7 @@ jobs: - name: Upload test artifacts if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: standalone-integration-results path: | From 7b83cf0b1a83d976683b3c91679e0d450322e258 Mon Sep 17 00:00:00 2001 From: Clay Townsend Date: Wed, 15 Apr 2026 22:50:33 +1000 Subject: [PATCH 22/24] fix(ci): restore test:scripts step and OM_WEBHOOKS_ALLOW_PRIVATE_URLS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add yarn test:scripts after the Turbo test step — root-level scripts are never picked up by turbo run test (M1) - Restore OM_WEBHOOKS_ALLOW_PRIVATE_URLS=1 to ephemeral-integration env so webhook tests delivering to localhost do not fail (M2) Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7a7ed878733..049d55e9080 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -392,6 +392,10 @@ jobs: yarn test fi + - name: Test scripts + # Turbo never picks up root-level scripts — run explicitly. + run: yarn test:scripts + # ── Integration tests ─────────────────────────────────────────────────────── # Boots an ephemeral app server and runs the Playwright suite. # Starts as soon as 'prepare' completes — runs in PARALLEL with 'test' so @@ -417,6 +421,7 @@ jobs: OM_ENABLE_ENTERPRISE_MODULES_SECURITY: 'true' JWT_SECRET: 'ci-ephemeral-test-jwt-secret' OM_SECURITY_MFA_SETUP_SECRET: 'ci-ephemeral-test-mfa-setup-secret' + OM_WEBHOOKS_ALLOW_PRIVATE_URLS: '1' steps: - name: Compute shard metadata id: shard-meta From 789dd748f71649883fa4687e7268223adc1bbab3 Mon Sep 17 00:00:00 2001 From: Clay Townsend Date: Thu, 16 Apr 2026 17:50:15 +1000 Subject: [PATCH 23/24] fix(ci): catch all workspace modules; fix coverage double-counting ci.yml: expand affected-module regex from hardcoded packages/core, packages/enterprise, apps/mercato to all workspace packages and apps (packages/[^/]+|apps/[^/]+). PRs touching modules in checkout, gateway-stripe, content, ai-assistant, etc. were falling into the skip=true branch and bypassing integration tests entirely. scripts/merge-coverage.mjs: recompute merged totals from the deduplicated per-file map instead of summing each shard's totals directly. Summing shard totals double-counts files exercised by more than one shard, making merged percentages incorrect. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 3 ++- scripts/merge-coverage.mjs | 45 ++++++++++++++++++++------------------ 2 files changed, 26 insertions(+), 22 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 049d55e9080..43109a3b3ee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,8 +78,9 @@ jobs: fi # Extract module folder names from module-specific paths. + # Match any workspace package or app that follows the src/modules/ convention. MODULES=$(echo "$CHANGED" | \ - grep -oP '(?:packages/(?:core|enterprise)|apps/mercato)/src/modules/\K[^/]+' | \ + grep -oP '(?:packages/[^/]+|apps/[^/]+)/src/modules/\K[^/]+' | \ sort -u | paste -sd,) if [ -z "$MODULES" ]; then diff --git a/scripts/merge-coverage.mjs b/scripts/merge-coverage.mjs index 390793933af..a1a64fd3b22 100644 --- a/scripts/merge-coverage.mjs +++ b/scripts/merge-coverage.mjs @@ -33,33 +33,36 @@ function findShardSummaryFiles(root) { } function mergeSummaries(summaries) { + const mergedFiles = {} + + for (const summary of summaries) { + for (const [key, value] of Object.entries(summary)) { + if (key === 'total') continue // recomputed below from deduplicated file entries + const existing = mergedFiles[key] + const incomingCovered = (value.lines?.covered ?? 0) + (value.statements?.covered ?? 0) + const existingCovered = existing + ? (existing.lines?.covered ?? 0) + (existing.statements?.covered ?? 0) + : -1 + if (!existing || incomingCovered > existingCovered) { + mergedFiles[key] = value + } + } + } + + // Recompute totals from the deduplicated per-file map to avoid double-counting + // files that appear in more than one shard. const mergedTotals = { lines: { total: 0, covered: 0, skipped: 0, pct: 0 }, statements: { total: 0, covered: 0, skipped: 0, pct: 0 }, functions: { total: 0, covered: 0, skipped: 0, pct: 0 }, branches: { total: 0, covered: 0, skipped: 0, pct: 0 }, } - const mergedFiles = {} - - for (const summary of summaries) { - for (const [key, value] of Object.entries(summary)) { - if (key === 'total') { - for (const metric of ['lines', 'statements', 'functions', 'branches']) { - const src = value[metric] ?? {} - mergedTotals[metric].total += src.total ?? 0 - mergedTotals[metric].covered += src.covered ?? 0 - mergedTotals[metric].skipped += src.skipped ?? 0 - } - } else { - const existing = mergedFiles[key] - const incomingCovered = (value.lines?.covered ?? 0) + (value.statements?.covered ?? 0) - const existingCovered = existing - ? (existing.lines?.covered ?? 0) + (existing.statements?.covered ?? 0) - : -1 - if (!existing || incomingCovered > existingCovered) { - mergedFiles[key] = value - } - } + for (const fileEntry of Object.values(mergedFiles)) { + for (const metric of ['lines', 'statements', 'functions', 'branches']) { + const src = fileEntry[metric] ?? {} + mergedTotals[metric].total += src.total ?? 0 + mergedTotals[metric].covered += src.covered ?? 0 + mergedTotals[metric].skipped += src.skipped ?? 0 } } From 899c620aa6557d2eed9aed0afa29a83e2adbf621 Mon Sep 17 00:00:00 2001 From: Clay Townsend Date: Thu, 16 Apr 2026 18:06:36 +1000 Subject: [PATCH 24/24] fix(ci): restore docker action versions to latest (v4/v7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setup-buildx-action@v3 → @v4 (latest, released 2026-03-05) build-push-action@v6 → @v7 (latest, released 2026-04-10) develop already had the correct versions; the previous commit incorrectly downgraded them. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 43109a3b3ee..1aa485f2eac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -679,10 +679,10 @@ jobs: uses: actions/checkout@v6 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Build docs Dockerfile - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: . file: apps/docs/Dockerfile @@ -691,7 +691,7 @@ jobs: cache-to: type=gha,mode=max - name: Build fullapp (main app) - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: . file: Dockerfile @@ -700,7 +700,7 @@ jobs: cache-to: type=gha,mode=max - name: Build opencode container - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: ./docker/opencode push: false