From 8892f659f66a9eb4f5a591101d9029e42763bd17 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Wed, 14 Oct 2026 10:08:35 +0000 Subject: [PATCH] Reduce repeated CLI builds and isolate CI publication --- .github/workflows/cli-deno-tests.yml | 103 ++++++++------ .github/workflows/cli-docker-validation.yml | 87 ++++++++++++ .github/workflows/cli-docker.yml | 78 +++++------ .github/workflows/cli-p2p-tests.yml | 88 ++++++++++++ .github/workflows/deploy-pages.yml | 20 ++- .github/workflows/unit-ci.yml | 24 ++++ src/apps/cli/testdeno/ci-tasks.json | 17 +++ src/apps/cli/testdeno/run-ci-suite.ts | 18 +-- src/apps/cli/testdeno/test_dev_deno.md | 6 +- utils/ci-pages.unit.spec.ts | 72 ++++++++++ utils/cli-ci.unit.spec.ts | 143 ++++++++++++++++++++ 11 files changed, 547 insertions(+), 109 deletions(-) create mode 100644 .github/workflows/cli-docker-validation.yml create mode 100644 .github/workflows/cli-p2p-tests.yml create mode 100644 src/apps/cli/testdeno/ci-tasks.json create mode 100644 utils/ci-pages.unit.spec.ts create mode 100644 utils/cli-ci.unit.spec.ts diff --git a/.github/workflows/cli-deno-tests.yml b/.github/workflows/cli-deno-tests.yml index 98423fcd..76abc996 100644 --- a/.github/workflows/cli-deno-tests.yml +++ b/.github/workflows/cli-deno-tests.yml @@ -8,14 +8,34 @@ on: paths: - '.github/workflows/cli-deno-tests.yml' - 'src/apps/cli/**' - - 'test/bench-network/**' + - '!src/apps/cli/**/*.md' + - 'src/LiveSyncBaseCore.ts' + - 'src/common/**' + - 'src/serviceFeatures/**' + - 'src/serviceModules/**' + - 'src/integrations/**' + - 'src/modules/AbstractModule.ts' + - 'src/modules/main/**' + - 'src/modules/coreFeatures/**' + - 'manifest.json' + - 'tsconfig.json' - 'package.json' - 'package-lock.json' pull_request: paths: - '.github/workflows/cli-deno-tests.yml' - 'src/apps/cli/**' - - 'test/bench-network/**' + - '!src/apps/cli/**/*.md' + - 'src/LiveSyncBaseCore.ts' + - 'src/common/**' + - 'src/serviceFeatures/**' + - 'src/serviceModules/**' + - 'src/integrations/**' + - 'src/modules/AbstractModule.ts' + - 'src/modules/main/**' + - 'src/modules/coreFeatures/**' + - 'manifest.json' + - 'tsconfig.json' - 'package.json' - 'package-lock.json' workflow_dispatch: @@ -40,23 +60,34 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: prepare: runs-on: ubuntu-latest + timeout-minutes: 15 + env: + NODE_OPTIONS: --max-old-space-size=3072 outputs: task_matrix: ${{ steps.select.outputs.task_matrix }} steps: + - name: Checkout + uses: actions/checkout@v5 + - name: Select task matrix id: select shell: bash + env: + SELECTED_TASK: ${{ github.event_name == 'workflow_dispatch' && inputs.test_task || 'test:ci' }} run: | set -euo pipefail - SELECTED_TASK="${{ github.event_name == 'workflow_dispatch' && inputs.test_task || 'test:ci' }}" echo "[INFO] Selected task set: $SELECTED_TASK" case "$SELECTED_TASK" in test:ci) - TASK_MATRIX='["test:setup-put-cat","test:mirror","test:daemon","test:daemon-startup","test:push-pull","test:decoupled-vault","test:sync-two-local","test:sync-locked-remote","test:remote-commands","test:e2e-matrix:couchdb-enc0","test:e2e-matrix:couchdb-enc1","test:e2e-matrix:minio-enc0","test:e2e-matrix:minio-enc1"]' + TASK_MATRIX=$(jq -c . src/apps/cli/testdeno/ci-tasks.json) ;; test:local) TASK_MATRIX='["test:setup-put-cat","test:mirror","test:daemon"]' @@ -72,6 +103,27 @@ jobs: echo "task_matrix=$TASK_MATRIX" >> "$GITHUB_OUTPUT" + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + node-version: '24.x' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build CLI + working-directory: src/apps/cli + run: npm run build + + - name: Upload CLI bundle + uses: actions/upload-artifact@v6 + with: + name: cli-bundle + path: src/apps/cli/dist/ + if-no-files-found: error + retention-days: 1 + test: needs: prepare runs-on: ubuntu-latest @@ -109,9 +161,11 @@ jobs: - name: Install dependencies run: npm ci - - name: Build CLI - working-directory: src/apps/cli - run: npm run build + - name: Download CLI bundle + uses: actions/download-artifact@v8 + with: + name: cli-bundle + path: src/apps/cli/dist/ - name: Create .test.env working-directory: src/apps/cli @@ -145,38 +199,3 @@ jobs: run: | docker stop couchdb-test minio-test relay-test coturn-test >/dev/null 2>&1 || true docker rm couchdb-test minio-test relay-test coturn-test >/dev/null 2>&1 || true - - compose-p2p-e2e: - runs-on: ubuntu-latest - timeout-minutes: 45 - steps: - - name: Checkout - uses: actions/checkout@v5 - - - name: Show Docker versions - run: | - docker --version - docker compose version - - - name: Run Compose CLI P2P E2E - env: - CLI_E2E_TASK: test:p2p:ci - RELAY: ws://nostr-relay:7777/ - PEERS_TIMEOUT: '20' - SYNC_TIMEOUT: '60' - LIVESYNC_USE_COTURN: '0' - TURN_SERVERS: none - LIVESYNC_P2P_PEERS_RETRY: '1' - LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS: '60000' - BENCH_LIVESYNC_TEST_TEE: '0' - run: docker compose -f test/bench-network/compose.yml run --build --rm bench-runner run-livesync-cli-e2e - - - name: Show Compose diagnostics - if: failure() - run: | - docker compose -f test/bench-network/compose.yml ps - docker compose -f test/bench-network/compose.yml logs --no-color couchdb nostr-relay || true - - - name: Stop Compose services - if: always() - run: docker compose -f test/bench-network/compose.yml down -v --remove-orphans diff --git a/.github/workflows/cli-docker-validation.yml b/.github/workflows/cli-docker-validation.yml new file mode 100644 index 00000000..fc58f972 --- /dev/null +++ b/.github/workflows/cli-docker-validation.yml @@ -0,0 +1,87 @@ +name: CLI Docker validation + +on: + pull_request: + paths: + - '.github/workflows/cli-docker.yml' + - '.github/workflows/cli-docker-validation.yml' + - 'src/apps/cli/**' + - '!src/apps/cli/**/*.md' + - 'src/LiveSyncBaseCore.ts' + - 'src/common/**' + - 'src/serviceFeatures/**' + - 'src/serviceModules/**' + - 'src/integrations/**' + - 'src/modules/AbstractModule.ts' + - 'src/modules/main/**' + - 'src/modules/coreFeatures/**' + - 'manifest.json' + - 'tsconfig.json' + - 'package.json' + - 'package-lock.json' + - '.dockerignore' + workflow_call: + inputs: + force: + type: boolean + default: false + outputs: + e2e_passed: + description: Whether the Docker CLI E2E suite passed + value: ${{ jobs.validate.outputs.e2e_passed }} + +permissions: + contents: read + +concurrency: + group: cli-docker-validation-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + validate: + runs-on: ubuntu-latest + timeout-minutes: 45 + outputs: + e2e_passed: ${{ steps.e2e.outcome == 'success' }} + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Build Docker test image + uses: docker/build-push-action@v7 + with: + context: . + file: src/apps/cli/Dockerfile + load: true + tags: livesync-cli + platforms: linux/amd64 + cache-from: type=gha,scope=cli-image + cache-to: type=gha,scope=cli-image,mode=max + + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + node-version: '24.x' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run CLI E2E (docker) + id: e2e + continue-on-error: ${{ github.event_name == 'workflow_dispatch' && inputs.force }} + working-directory: src/apps/cli + env: + CI: true + run: npm --ignore-scripts run test:e2e:docker:all + + - name: Stop test containers + if: always() + working-directory: src/apps/cli + run: | + bash ./util/couchdb-stop.sh >/dev/null 2>&1 || true + bash ./util/minio-stop.sh >/dev/null 2>&1 || true + bash ./util/p2p-stop.sh >/dev/null 2>&1 || true diff --git a/.github/workflows/cli-docker.yml b/.github/workflows/cli-docker.yml index ed43e230..b963506f 100644 --- a/.github/workflows/cli-docker.yml +++ b/.github/workflows/cli-docker.yml @@ -1,6 +1,6 @@ -# Build and push the CLI Docker image to GitHub Container Registry (GHCR).# -# Image tag format: --cli -# Example: 0.25.56-1743500000-cli +# Build and push the CLI Docker image to GitHub Container Registry (GHCR). +# Image tag format: -cli +# Example: 1.0.36-cli # # Stable releases are also tagged with their major-minor version and 'latest'. # Pre-releases receive immutable version and SHA-qualified tags only. @@ -13,16 +13,24 @@ on: - main tags: - "*.*.*-cli" - paths-ignore: - - "docs/**" - - "*.md" - - "images/**" - - "assets/**" - - "instruction_images/**" - - "src/apps/webapp/**" - - "src/apps/webpeer/**" - - ".github/workflows/release.yml" - - ".github/workflows/unit-ci.yml" + paths: + - ".github/workflows/cli-docker.yml" + - ".github/workflows/cli-docker-validation.yml" + - "src/apps/cli/**" + - "!src/apps/cli/**/*.md" + - "src/LiveSyncBaseCore.ts" + - "src/common/**" + - "src/serviceFeatures/**" + - "src/serviceModules/**" + - "src/integrations/**" + - "src/modules/AbstractModule.ts" + - "src/modules/main/**" + - "src/modules/coreFeatures/**" + - "manifest.json" + - "tsconfig.json" + - "package.json" + - "package-lock.json" + - ".dockerignore" workflow_dispatch: inputs: dry_run: @@ -36,8 +44,21 @@ on: type: boolean default: false +concurrency: + group: cli-docker-publish-${{ github.ref }} + cancel-in-progress: false + jobs: + validate: + uses: ./.github/workflows/cli-docker-validation.yml + with: + force: ${{ github.event_name == 'workflow_dispatch' && inputs.force || false }} + permissions: + contents: read + build-and-push: + needs: validate + if: ${{ needs.validate.outputs.e2e_passed == 'true' || (github.event_name == 'workflow_dispatch' && inputs.force) }} runs-on: ubuntu-latest timeout-minutes: 90 permissions: @@ -100,34 +121,7 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 - - name: Setup Node.js - uses: actions/setup-node@v5 - with: - node-version: "24.x" - cache: "npm" - - - name: Install dependencies - run: npm ci - - - name: Run CLI E2E (docker) - id: e2e - continue-on-error: ${{ github.event_name == 'workflow_dispatch' && inputs.force }} - working-directory: src/apps/cli - env: - CI: true - run: npm run test:e2e:docker:all - - - name: Stop test containers (safety net) - if: always() - working-directory: src/apps/cli - run: | - # Keep this as a safety net for future suites/steps that may leave containers running. - bash ./util/couchdb-stop.sh >/dev/null 2>&1 || true - bash ./util/minio-stop.sh >/dev/null 2>&1 || true - bash ./util/p2p-stop.sh >/dev/null 2>&1 || true - - name: Build and push - if: ${{ steps.e2e.outcome == 'success' || (github.event_name == 'workflow_dispatch' && inputs.force) }} uses: docker/build-push-action@v7 with: context: . @@ -135,5 +129,5 @@ jobs: push: ${{ steps.meta.outputs.push }} tags: ${{ steps.meta.outputs.tags }} platforms: linux/amd64,linux/arm64 - cache-from: type=gha - cache-to: type=gha,mode=max + cache-from: type=gha,scope=cli-image + cache-to: type=gha,scope=cli-image,mode=max diff --git a/.github/workflows/cli-p2p-tests.yml b/.github/workflows/cli-p2p-tests.yml new file mode 100644 index 00000000..17ffa039 --- /dev/null +++ b/.github/workflows/cli-p2p-tests.yml @@ -0,0 +1,88 @@ +name: CLI P2P Compose tests + +on: + push: + branches: + - main + - beta + paths: + - '.github/workflows/cli-p2p-tests.yml' + - 'src/apps/cli/**' + - '!src/apps/cli/**/*.md' + - 'src/LiveSyncBaseCore.ts' + - 'src/common/**' + - 'src/serviceFeatures/**' + - 'src/serviceModules/**' + - 'src/integrations/**' + - 'src/modules/AbstractModule.ts' + - 'src/modules/main/**' + - 'src/modules/coreFeatures/**' + - 'manifest.json' + - 'tsconfig.json' + - 'package.json' + - 'package-lock.json' + - '.dockerignore' + - 'test/bench-network/**' + pull_request: + paths: + - '.github/workflows/cli-p2p-tests.yml' + - 'src/apps/cli/**' + - '!src/apps/cli/**/*.md' + - 'src/LiveSyncBaseCore.ts' + - 'src/common/**' + - 'src/serviceFeatures/**' + - 'src/serviceModules/**' + - 'src/integrations/**' + - 'src/modules/AbstractModule.ts' + - 'src/modules/main/**' + - 'src/modules/coreFeatures/**' + - 'manifest.json' + - 'tsconfig.json' + - 'package.json' + - 'package-lock.json' + - '.dockerignore' + - 'test/bench-network/**' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + compose-p2p-e2e: + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Show Docker versions + run: | + docker --version + docker compose version + + - name: Run Compose CLI P2P E2E + env: + CLI_E2E_TASK: test:p2p:ci + RELAY: ws://nostr-relay:7777/ + PEERS_TIMEOUT: '20' + SYNC_TIMEOUT: '60' + LIVESYNC_USE_COTURN: '0' + TURN_SERVERS: none + LIVESYNC_P2P_PEERS_RETRY: '1' + LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS: '60000' + BENCH_LIVESYNC_TEST_TEE: '0' + run: docker compose -f test/bench-network/compose.yml run --build --rm bench-runner run-livesync-cli-e2e + + - name: Show Compose diagnostics + if: failure() + run: | + docker compose -f test/bench-network/compose.yml ps + docker compose -f test/bench-network/compose.yml logs --no-color couchdb nostr-relay || true + + - name: Stop Compose services + if: always() + run: docker compose -f test/bench-network/compose.yml down -v --remove-orphans diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index bd440243..7683c7da 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -9,6 +9,7 @@ on: - 'aggregator.html' - 'src/**' - 'test/browser-apps/**' + - 'manifest.json' - 'package.json' - 'package-lock.json' - '.github/workflows/deploy-pages.yml' @@ -17,18 +18,17 @@ on: - 'aggregator.html' - 'src/**' - 'test/browser-apps/**' + - 'manifest.json' - 'package.json' - 'package-lock.json' - '.github/workflows/deploy-pages.yml' permissions: contents: read - pages: write - id-token: write concurrency: - group: pages - cancel-in-progress: false + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: build: @@ -84,9 +84,6 @@ jobs: - name: Validate packaged Pages site run: npm run test:browser-apps:pages - - name: Configure GitHub Pages - uses: actions/configure-pages@v6 - - name: Upload GitHub Pages artifact uses: actions/upload-pages-artifact@v5 with: @@ -97,10 +94,19 @@ jobs: if: github.event_name != 'pull_request' needs: build runs-on: ubuntu-latest + concurrency: + group: pages + cancel-in-progress: false + permissions: + pages: write + id-token: write environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} steps: + - name: Configure GitHub Pages + uses: actions/configure-pages@v6 + - name: Deploy GitHub Pages id: deployment uses: actions/deploy-pages@v5 diff --git a/.github/workflows/unit-ci.yml b/.github/workflows/unit-ci.yml index f8b0f80c..326567b6 100644 --- a/.github/workflows/unit-ci.yml +++ b/.github/workflows/unit-ci.yml @@ -8,8 +8,11 @@ on: - main - beta paths: + - 'manifest.json' + - 'updates.md' - 'src/**' - 'test/**' + - '_tools/**' - 'package.json' - 'package-lock.json' - 'tsconfig.json' @@ -19,8 +22,13 @@ on: - 'eslint.config.mjs' - 'eslint.config.common.mjs' - 'eslint.community.config.mjs' + - 'terser.config.mjs' - 'update-workspaces.mjs' - 'version-bump.mjs' + - 'utils/check-compatibility.js' + - 'utils/livesync-commonlib-version.test.ts' + - 'utils/livesync-commonlib-version.ts' + - 'utils/*ci*.unit.spec.ts' - 'utils/release-*.mjs' - 'utils/release-*.unit.spec.ts' - 'utils/couchdb/**' @@ -29,11 +37,18 @@ on: - '.github/workflows/prepare-release.yml' - '.github/workflows/finalise-release.yml' - '.github/workflows/release.yml' + - '.github/workflows/cli-deno-tests.yml' + - '.github/workflows/cli-p2p-tests.yml' + - '.github/workflows/cli-docker-validation.yml' + - '.github/workflows/cli-docker.yml' - '.github/workflows/unit-ci.yml' pull_request: paths: + - 'manifest.json' + - 'updates.md' - 'src/**' - 'test/**' + - '_tools/**' - 'package.json' - 'package-lock.json' - 'tsconfig.json' @@ -43,8 +58,13 @@ on: - 'eslint.config.mjs' - 'eslint.config.common.mjs' - 'eslint.community.config.mjs' + - 'terser.config.mjs' - 'update-workspaces.mjs' - 'version-bump.mjs' + - 'utils/check-compatibility.js' + - 'utils/livesync-commonlib-version.test.ts' + - 'utils/livesync-commonlib-version.ts' + - 'utils/*ci*.unit.spec.ts' - 'utils/release-*.mjs' - 'utils/release-*.unit.spec.ts' - 'utils/couchdb/**' @@ -53,6 +73,10 @@ on: - '.github/workflows/prepare-release.yml' - '.github/workflows/finalise-release.yml' - '.github/workflows/release.yml' + - '.github/workflows/cli-deno-tests.yml' + - '.github/workflows/cli-p2p-tests.yml' + - '.github/workflows/cli-docker-validation.yml' + - '.github/workflows/cli-docker.yml' - '.github/workflows/unit-ci.yml' permissions: diff --git a/src/apps/cli/testdeno/ci-tasks.json b/src/apps/cli/testdeno/ci-tasks.json new file mode 100644 index 00000000..4748666a --- /dev/null +++ b/src/apps/cli/testdeno/ci-tasks.json @@ -0,0 +1,17 @@ +[ + "test:settings-writeback", + "test:remote-administration-exit-codes", + "test:setup-put-cat", + "test:mirror", + "test:daemon", + "test:daemon-startup", + "test:push-pull", + "test:decoupled-vault", + "test:sync-two-local", + "test:sync-locked-remote", + "test:remote-commands", + "test:e2e-matrix:couchdb-enc0", + "test:e2e-matrix:couchdb-enc1", + "test:e2e-matrix:minio-enc0", + "test:e2e-matrix:minio-enc1" +] diff --git a/src/apps/cli/testdeno/run-ci-suite.ts b/src/apps/cli/testdeno/run-ci-suite.ts index 7c391ce9..0eb1edfc 100644 --- a/src/apps/cli/testdeno/run-ci-suite.ts +++ b/src/apps/cli/testdeno/run-ci-suite.ts @@ -1,20 +1,4 @@ -const TASKS = [ - "test:settings-writeback", - "test:remote-administration-exit-codes", - "test:setup-put-cat", - "test:mirror", - "test:daemon", - "test:daemon-startup", - "test:push-pull", - "test:decoupled-vault", - "test:sync-two-local", - "test:sync-locked-remote", - "test:remote-commands", - "test:e2e-matrix:couchdb-enc0", - "test:e2e-matrix:couchdb-enc1", - "test:e2e-matrix:minio-enc0", - "test:e2e-matrix:minio-enc1", -] as const; +import TASKS from "./ci-tasks.json" with { type: "json" }; for (const [index, task] of TASKS.entries()) { console.log(`\n[CLI E2E ${index + 1}/${TASKS.length}] ${task}`); diff --git a/src/apps/cli/testdeno/test_dev_deno.md b/src/apps/cli/testdeno/test_dev_deno.md index 889a4fe2..bb00f8d2 100644 --- a/src/apps/cli/testdeno/test_dev_deno.md +++ b/src/apps/cli/testdeno/test_dev_deno.md @@ -323,7 +323,11 @@ deno task test:sync-two-local ## Continuous Integration -The GitHub Actions workflow `.github/workflows/cli-deno-tests.yml` runs automatically on pushes and pull requests affecting the CLI, executing the non-P2P test suite (`test:ci`). P2P tests (`test:p2p`) are excluded from automatic execution and must be run via manual dispatch (`workflow_dispatch`). You can optionally check the "Enable verbose and debug logging" checkbox during a manual dispatch to produce detailed trace logs for troubleshooting. +The GitHub Actions workflow `.github/workflows/cli-deno-tests.yml` runs automatically on pushes and pull requests affecting the CLI or its shared runtime inputs. It builds the CLI once, then distributes the bundle to the non-P2P test jobs. The local `test:ci` runner and the automatic matrix both read `ci-tasks.json`. During a manual dispatch, select a test subset and optionally enable 'Enable verbose and debug logging' for detailed troubleshooting traces. + +The separate `.github/workflows/cli-p2p-tests.yml` workflow runs the canonical Compose P2P suite automatically for changes to the CLI, its shared runtime inputs, or `test/bench-network/`. A change confined to the Compose fixtures does not start the non-P2P matrix. Host-direct `test:p2p` tasks remain available for explicit diagnostics. + +The `.github/workflows/cli-docker-validation.yml` workflow tests the production Docker image on relevant pull requests and is also called before container publication. Validation runs without registry credentials. The publishing workflow, `.github/workflows/cli-docker.yml`, reuses the Docker build cache and retains the existing main-branch, release-tag, and manual publication conditions. --- diff --git a/utils/ci-pages.unit.spec.ts b/utils/ci-pages.unit.spec.ts new file mode 100644 index 00000000..5130c2bb --- /dev/null +++ b/utils/ci-pages.unit.spec.ts @@ -0,0 +1,72 @@ +import { readFileSync } from "node:fs"; +import { minimatch } from "minimatch"; +import { describe, expect, it } from "vitest"; +import { parse } from "yaml"; + +type Trigger = "push" | "pull_request"; +type Concurrency = { + group?: string; + "cancel-in-progress"?: boolean | string; +}; +type Workflow = { + on?: Partial>; + concurrency?: Concurrency; + jobs?: Record; +}; + +function readWorkflow(filename: string): Workflow { + const source = readFileSync(new URL(`../.github/workflows/${filename}`, import.meta.url), "utf8"); + return parse(source) as Workflow; +} + +function matchesAnyPath(patterns: string[], filename: string): boolean { + return patterns.some((pattern) => minimatch(filename, pattern)); +} + +const pagesWorkflow = readWorkflow("deploy-pages.yml"); +const unitWorkflow = readWorkflow("unit-ci.yml"); + +describe("CI workflow path filters", () => { + it.each(["push", "pull_request"] as const)("runs Pages checks for the manifest on %s", (trigger) => { + const patterns = pagesWorkflow.on?.[trigger]?.paths ?? []; + expect(matchesAnyPath(patterns, "manifest.json")).toBe(true); + }); + + it.each(["push", "pull_request"] as const)("runs unit CI for required inputs on %s", (trigger) => { + const patterns = unitWorkflow.on?.[trigger]?.paths ?? []; + const requiredInputs = [ + "manifest.json", + "updates.md", + "_tools/bakei18n.ts", + "terser.config.mjs", + "utils/check-compatibility.js", + "utils/livesync-commonlib-version.test.ts", + "utils/livesync-commonlib-version.ts", + "utils/ci-pages.unit.spec.ts", + "utils/cli-ci.unit.spec.ts", + ".github/workflows/cli-docker.yml", + ".github/workflows/cli-deno-tests.yml", + ".github/workflows/cli-p2p-tests.yml", + ".github/workflows/cli-docker-validation.yml", + ]; + + expect(requiredInputs.filter((filename) => !matchesAnyPath(patterns, filename))).toEqual([]); + expect(matchesAnyPath(patterns, "utils/unrelated.unit.spec.ts")).toBe(false); + }); +}); + +describe("Pages workflow concurrency", () => { + it("isolates pull requests from publishing refs and cancels superseded pull request runs", () => { + expect(pagesWorkflow.concurrency).toEqual({ + group: "${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}", + "cancel-in-progress": "${{ github.event_name == 'pull_request' }}", + }); + }); + + it("serialises deployments without cancelling an active deployment", () => { + expect(pagesWorkflow.jobs?.deploy?.concurrency).toEqual({ + group: "pages", + "cancel-in-progress": false, + }); + }); +}); diff --git a/utils/cli-ci.unit.spec.ts b/utils/cli-ci.unit.spec.ts new file mode 100644 index 00000000..42934c62 --- /dev/null +++ b/utils/cli-ci.unit.spec.ts @@ -0,0 +1,143 @@ +import { readFileSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { minimatch } from "minimatch"; +import { describe, expect, it } from "vitest"; +import { parse } from "yaml"; + +const root = new URL("../", import.meta.url); +type Step = { id?: string; run?: string; uses?: string; with?: Record }; +type Workflow = { + on: Record; + jobs: Record< + string, + { steps?: Step[]; needs?: string | string[]; uses?: string; if?: string; permissions?: Record } + >; +}; +function workflow(name: string): Workflow { + return parse(readFileSync(new URL(`.github/workflows/${name}`, root), "utf8")) as Workflow; +} +function matches(paths: string[], file: string): boolean { + let included = false; + for (const pattern of paths) { + const negative = pattern.startsWith("!"); + if (minimatch(file, negative ? pattern.slice(1) : pattern)) included = !negative; + } + return included; +} +function localTasks(): string[] { + const runner = readFileSync(new URL("src/apps/cli/testdeno/run-ci-suite.ts", root), "utf8"); + expect(runner).toContain('import TASKS from "./ci-tasks.json" with { type: "json" }'); + return JSON.parse(readFileSync(new URL("src/apps/cli/testdeno/ci-tasks.json", root), "utf8")) as string[]; +} +function selectedTasks(task: string): string[] { + const select = workflow("cli-deno-tests.yml").jobs.prepare.steps?.find((step) => step.id === "select"); + expect(select?.run).toBeDefined(); + const directory = mkdtempSync(join(tmpdir(), "livesync-ci-matrix-")); + const output = join(directory, "output"); + try { + const script = select!.run!.replace(/\$\{\{[^}]+\}\}/g, task); + const result = spawnSync("bash", ["-c", script], { + cwd: root, + env: { ...process.env, SELECTED_TASK: task, GITHUB_OUTPUT: output }, + encoding: "utf8", + }); + expect(result.status, result.stderr).toBe(0); + const line = readFileSync(output, "utf8") + .split("\n") + .find((line) => line.startsWith("task_matrix=")); + return JSON.parse(line!.slice("task_matrix=".length)) as string[]; + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +describe("CLI CI selection", () => { + it("runs every task in the local standard suite automatically", () => { + expect(selectedTasks("test:ci")).toEqual(localTasks()); + expect(selectedTasks("test:ci")).toEqual( + expect.arrayContaining(["test:settings-writeback", "test:remote-administration-exit-codes"]) + ); + const tasks = JSON.parse(readFileSync(new URL("src/apps/cli/testdeno/deno.json", root), "utf8")).tasks; + expect(localTasks().filter((task) => !(task in tasks))).toEqual([]); + expect(new Set(localTasks()).size).toBe(localTasks().length); + }); + it("keeps the selected manual subsets", () => { + expect(selectedTasks("test:local")).toEqual(["test:setup-put-cat", "test:mirror", "test:daemon"]); + expect(selectedTasks("test:e2e-matrix")).toEqual( + localTasks().filter((task) => task.startsWith("test:e2e-matrix:")) + ); + }); + it.each([ + ["cli-deno-tests.yml", "push"], + ["cli-deno-tests.yml", "pull_request"], + ["cli-p2p-tests.yml", "push"], + ["cli-p2p-tests.yml", "pull_request"], + ["cli-docker.yml", "push"], + ["cli-docker-validation.yml", "pull_request"], + ])("validates shared CLI runtime inputs in %s on %s", (name, event) => { + const paths = workflow(name).on[event].paths ?? []; + const inputs = [ + "src/apps/cli/main.ts", + "src/LiveSyncBaseCore.ts", + "src/serviceFeatures/replicationScheduling.ts", + "src/common/replicatorProviders.ts", + "src/common/messages/en.ts", + "src/modules/AbstractModule.ts", + "src/modules/main/ModuleLiveSyncMain.ts", + "src/modules/coreFeatures/ModuleResolveMismatchedTweaks.ts", + "src/serviceModules/FileHandler.ts", + "src/integrations/cloudflare/turnCredentials.ts", + "manifest.json", + "package.json", + "package-lock.json", + "tsconfig.json", + ]; + expect(inputs.filter((file) => !matches(paths, file))).toEqual([]); + expect(matches(paths, "src/modules/features/ModuleObsidianSettingTab.ts")).toBe(false); + expect(matches(paths, "docs/settings.md")).toBe(false); + expect(matches(paths, "src/apps/cli/testdeno/CONTRIBUTING_TESTS.md")).toBe(false); + }); + it("runs Compose alone for a change to its fixture", () => { + const file = "test/bench-network/compose.yml"; + expect(matches(workflow("cli-p2p-tests.yml").on.pull_request.paths ?? [], file)).toBe(true); + expect(matches(workflow("cli-deno-tests.yml").on.pull_request.paths ?? [], file)).toBe(false); + expect(matches(workflow("cli-docker-validation.yml").on.pull_request.paths ?? [], file)).toBe(false); + }); + it("passes a single CLI build to every standard test job", () => { + const { prepare, test } = workflow("cli-deno-tests.yml").jobs; + expect(test.needs).toBe("prepare"); + expect(prepare.steps?.filter((step) => step.run === "npm run build")).toHaveLength(1); + expect(test.steps?.some((step) => step.run?.includes("npm run build"))).toBe(false); + const upload = prepare.steps?.find((step) => step.uses?.startsWith("actions/upload-artifact@")); + const download = test.steps?.find((step) => step.uses?.startsWith("actions/download-artifact@")); + expect(upload?.with?.["if-no-files-found"]).toBe("error"); + expect(download?.with?.name).toBe(upload?.with?.name); + expect(download?.with?.path).toBe(upload?.with?.path); + }); + it("keeps registry credentials out of Docker validation and gates publication on it", () => { + const validation = workflow("cli-docker-validation.yml"); + const publication = workflow("cli-docker.yml"); + expect(validation.on.push).toBeUndefined(); + const validationJob = validation.jobs.validate; + expect(validationJob.steps?.some((step) => step.uses?.startsWith("docker/login-action@"))).toBe(false); + const build = validationJob.steps?.find((step) => step.uses?.startsWith("docker/build-push-action@")); + expect(build?.with?.load).toBe(true); + expect(build?.with?.push).toBeUndefined(); + expect(validationJob.steps?.find((step) => step.id === "e2e")?.run).toBe( + "npm --ignore-scripts run test:e2e:docker:all" + ); + expect(publication.jobs.validate.uses).toBe("./.github/workflows/cli-docker-validation.yml"); + expect(publication.jobs.validate.permissions).toEqual({ contents: "read" }); + expect(publication.jobs["build-and-push"].needs).toBe("validate"); + expect(publication.jobs["build-and-push"].if).toBe( + "${{ needs.validate.outputs.e2e_passed == 'true' || (github.event_name == 'workflow_dispatch' && inputs.force) }}" + ); + const publishBuild = publication.jobs["build-and-push"].steps?.find((step) => + step.uses?.startsWith("docker/build-push-action@") + ); + expect(publishBuild?.with?.["cache-from"]).toBe(build?.with?.["cache-from"]); + expect(publishBuild?.with?.["cache-to"]).toBe(build?.with?.["cache-to"]); + }); +});