name: CI on: push: branches: [main, 'release/**'] tags: ['v*'] pull_request: branches: [main] # Prevent duplicate runs concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true # Minimal permissions permissions: contents: read jobs: test: name: Test (${{ matrix.os }}, Node ${{ matrix.node }}, ${{ matrix.pm }}, ${{ matrix.scope }}) runs-on: ${{ matrix.os }} # The serial Windows full lane reaches its final installer cases at about # 30 minutes; retain a bounded completion window. timeout-minutes: 45 strategy: fail-fast: false # Two kinds of lane. `full` runs the whole suite; `pm` runs only the suites # that read CLAUDE_CODE_PACKAGE_MANAGER, which is all the package-manager # axis ever changed. The old 33-way cross product ran the full suite 33 # times per push, with Windows lanes at 30 to 40 minutes each. matrix: include: # Full suite: Linux on every supported Node line, current LTS on Windows and macOS. - { os: ubuntu-latest, node: '18.x', pm: npm, scope: full } - { os: ubuntu-latest, node: '20.x', pm: npm, scope: full } - { os: ubuntu-latest, node: '22.x', pm: npm, scope: full } - { os: windows-latest, node: '22.x', pm: npm, scope: full } - { os: macos-latest, node: '22.x', pm: npm, scope: full } # Package-manager lanes on current LTS. Bun has limited Windows support. - { os: ubuntu-latest, node: '22.x', pm: pnpm, scope: pm } - { os: ubuntu-latest, node: '22.x', pm: yarn, scope: pm } - { os: ubuntu-latest, node: '22.x', pm: bun, scope: pm } - { os: windows-latest, node: '22.x', pm: pnpm, scope: pm } - { os: windows-latest, node: '22.x', pm: yarn, scope: pm } - { os: macos-latest, node: '22.x', pm: pnpm, scope: pm } - { os: macos-latest, node: '22.x', pm: yarn, scope: pm } - { os: macos-latest, node: '22.x', pm: bun, scope: pm } steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Setup Node.js ${{ matrix.node }} uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: ${{ matrix.node }} - name: Install and verify ripgrep (Linux) if: runner.os == 'Linux' shell: bash run: | sudo apt-get update sudo apt-get install --no-install-recommends -y ripgrep rg --version printf 'ecc-pcre2-probe\n' | rg --pcre2 -- '^ecc-pcre2-probe$' - name: Install and verify ripgrep (macOS) if: runner.os == 'macOS' shell: bash run: | if ! command -v rg >/dev/null 2>&1; then brew install ripgrep; fi rg --version printf 'ecc-pcre2-probe\n' | rg --pcre2 -- '^ecc-pcre2-probe$' - name: Install and verify ripgrep (Windows) if: runner.os == 'Windows' shell: pwsh timeout-minutes: 5 run: | $ErrorActionPreference = 'Stop' if (-not (Get-Command rg -CommandType Application -ErrorAction SilentlyContinue)) { if ($env:RUNNER_ARCH -ne 'X64') { throw 'Pinned ripgrep archive requires an X64 runner' } $ripgrepVersion = '15.2.0' # Official BurntSushi/ripgrep release archive and published SHA256. $archiveName = "ripgrep-$ripgrepVersion-x86_64-pc-windows-msvc.zip" $expectedHash = '71b2fef860abe467217a538ff31de02f5258807c0129f771846f87bd029aafc5' $downloadRoot = Join-Path $env:RUNNER_TEMP ("ecc-ripgrep-" + [Guid]::NewGuid().ToString('N')) New-Item -ItemType Directory -Path $downloadRoot | Out-Null $archivePath = Join-Path $downloadRoot $archiveName $archiveUrl = "https://github.com/BurntSushi/ripgrep/releases/download/$ripgrepVersion/$archiveName" Invoke-WebRequest -Uri $archiveUrl -OutFile $archivePath -MaximumRetryCount 2 -RetryIntervalSec 2 -ConnectionTimeoutSeconds 15 -OperationTimeoutSeconds 60 -MaximumRedirection 5 $actualHash = (Get-FileHash -LiteralPath $archivePath -Algorithm SHA256).Hash.ToLowerInvariant() if ($actualHash -ne $expectedHash) { throw 'ripgrep archive SHA256 verification failed' } Expand-Archive -LiteralPath $archivePath -DestinationPath $downloadRoot $ripgrepDir = Join-Path $downloadRoot "ripgrep-$ripgrepVersion-x86_64-pc-windows-msvc" $ripgrepExe = Join-Path $ripgrepDir 'rg.exe' if (-not (Test-Path -LiteralPath $ripgrepExe -PathType Leaf)) { throw 'Verified archive has no rg.exe' } & $ripgrepExe --version if ($LASTEXITCODE -ne 0) { throw 'Verified ripgrep executable failed' } Add-Content -LiteralPath $env:GITHUB_PATH -Value $ripgrepDir -Encoding utf8 $env:PATH = "$ripgrepDir;$env:PATH" } rg --version if ($LASTEXITCODE -ne 0) { throw 'ripgrep executable is unavailable' } # PowerShell writes CRLF to native stdin; retain anchored matching. 'ecc-pcre2-probe' | rg --pcre2 --crlf -- '^ecc-pcre2-probe$' if ($LASTEXITCODE -ne 0) { throw 'ripgrep PCRE2 support is required' } # Package manager setup - name: Setup pnpm if: matrix.pm == 'pnpm' && matrix.node != '18.x' uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 with: # Keep an explicit pnpm major because this repo's packageManager is Yarn. version: 10 - name: Setup pnpm (via Corepack) if: matrix.pm == 'pnpm' && matrix.node == '18.x' shell: bash run: | corepack enable corepack prepare pnpm@9 --activate - name: Setup Yarn (via Corepack) if: matrix.pm == 'yarn' shell: bash run: | corepack enable corepack prepare yarn@stable --activate - name: Setup Bun if: matrix.pm == 'bun' uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 # Install dependencies # COREPACK_ENABLE_STRICT=0 allows pnpm to install even though # package.json declares "packageManager": "yarn@..." - name: Install dependencies shell: bash env: COREPACK_ENABLE_STRICT: '0' npm_config_ignore_scripts: 'true' YARN_ENABLE_SCRIPTS: 'false' run: | case "${{ matrix.pm }}" in npm) npm ci --ignore-scripts ;; # pnpm v10 can fail CI on ignored native build scripts # (for example msgpackr-extract) even though this repo is Yarn-native # and pnpm is only exercised here as a compatibility lane. pnpm) pnpm install --ignore-scripts --config.strict-dep-builds=false --no-frozen-lockfile ;; # Yarn Berry (v4+) removed --ignore-engines; engine checking is no longer a core feature yarn) yarn install --mode=skip-build ;; bun) bun install --ignore-scripts ;; *) echo "Unsupported package manager: ${{ matrix.pm }}" && exit 1 ;; esac - name: Check observer and revision identity if: matrix.scope == 'full' shell: bash run: | node tests/lib/observer-sessions.test.js node tests/commands/issue-review.test.js # Run tests. Full lanes run every file through a 4-process pool on Linux # and macOS; Windows stays serial because its process spawn and temp-dir # cleanup are the flaky part. pm lanes derive their file list from the # suites that actually read the package-manager variable, so the lane # cannot drift out of date as tests are added. - name: Run tests shell: bash run: | if [ "${{ matrix.scope }}" = "pm" ]; then ECC_TEST_ONLY=$(grep -rlE --include='*.test.js' 'CLAUDE(_CODE)?_PACKAGE_MANAGER' tests | sed 's#^tests/##' | sort | paste -sd, -) export ECC_TEST_ONLY echo "pm lane files: $ECC_TEST_ONLY" fi # Suites that run scripts/build-opencode.js, or rename a checkout path # to a .backup- copy and restore it, mutate the shared checkout; # suites that assert a wall-clock budget cannot share cores with the # pool. Both groups run last, one at a time. ECC_TEST_SERIAL=$(grep -rlE --include='*.test.js' 'build-opencode|\.backup-|dist\.backup|time budget' tests | sed 's#^tests/##' | sort | paste -sd, -) export ECC_TEST_SERIAL echo "serial after the pool: $ECC_TEST_SERIAL" node tests/run-all.js env: CLAUDE_CODE_PACKAGE_MANAGER: ${{ matrix.pm }} ECC_TEST_JOBS: ${{ runner.os == 'Windows' && '1' || '4' }} # Upload test artifacts on failure - name: Upload test artifacts if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: test-results-${{ matrix.os }}-node${{ matrix.node }}-${{ matrix.pm }}-${{ matrix.scope }} path: | tests/ !tests/node_modules/ pack-installer: name: Pack Installer Artifact runs-on: ubuntu-latest timeout-minutes: 10 outputs: package_file: ${{ steps.pack.outputs.package_file }} package_sha256: ${{ steps.pack.outputs.package_sha256 }} steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '20.x' - name: Install dependencies run: npm ci --ignore-scripts - name: Pack exact installer artifact id: pack run: | npm pack --json > npm-pack.json node -e "const crypto = require('crypto'); const fs = require('fs'); const data = JSON.parse(fs.readFileSync('npm-pack.json', 'utf8')); const file = data[0]?.filename; if (!/^ecc-universal-[0-9A-Za-z.+-]+\.tgz$/.test(file || '')) throw new Error('Unexpected packed filename'); const archives = fs.readdirSync('.').filter(name => name.endsWith('.tgz')); if (archives.length !== 1 || archives[0] !== file) throw new Error('Expected exactly one packed archive'); const digest = crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); fs.appendFileSync(process.env.GITHUB_OUTPUT, 'package_file=' + file + '\npackage_sha256=' + digest + '\n')" - name: Upload exact installer artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: ecc-ci-installer-artifact path: ${{ steps.pack.outputs.package_file }} if-no-files-found: error packed-install-lifecycle: name: Packed Install (${{ matrix.os }}) needs: pack-installer runs-on: ${{ matrix.os }} timeout-minutes: 15 strategy: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] steps: - name: Checkout lifecycle test uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '20.x' - name: Download exact installer artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: ecc-ci-installer-artifact path: release-artifacts - name: Verify packed install lifecycle env: ECC_RELEASE_PACKAGE: release-artifacts/${{ needs.pack-installer.outputs.package_file }} ECC_RELEASE_SHA256: ${{ needs.pack-installer.outputs.package_sha256 }} run: node tests/ci/packed-artifact-lifecycle.js validate: name: Validate Components runs-on: ubuntu-latest timeout-minutes: 5 steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '20.x' - name: Install validation dependencies run: npm ci --ignore-scripts - name: Validate agents run: node scripts/ci/validate-agents.js continue-on-error: false - name: Validate hooks run: node scripts/ci/validate-hooks.js continue-on-error: false - name: Validate commands run: node scripts/ci/validate-commands.js continue-on-error: false - name: Validate skills run: node scripts/ci/validate-skills.js continue-on-error: false - name: Validate skill host compatibility run: node scripts/ci/validate-skill-host-compat.js continue-on-error: false - name: Validate install manifests run: node scripts/ci/validate-install-manifests.js continue-on-error: false - name: Validate workflow security run: node scripts/ci/validate-workflow-security.js continue-on-error: false - name: Validate rules run: node scripts/ci/validate-rules.js continue-on-error: false - name: Validate catalog counts run: node scripts/ci/catalog.js --text continue-on-error: false - name: Validate command registry run: npm run command-registry:check continue-on-error: false - name: Check unicode safety run: node scripts/ci/check-unicode-safety.js continue-on-error: false - name: Validate no personal paths run: node scripts/ci/validate-no-personal-paths.js continue-on-error: false pi-core: name: Pi Core Profile runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '20.x' # Rebuild runs frontmatter validation and the pi/core safety checks # (no callable endpoints, no runtime downloads, no secrets, no absolute # home paths, no symlinks, no duplicate skill names); the diff check # proves the committed profile is up to date. - name: Rebuild pi/core and verify it is up to date run: | node scripts/build-pi-core.js git diff --exit-code pi/core - name: Install Pi coding agent CLI run: npm install --global --ignore-scripts --no-audit --no-fund @mariozechner/pi-coding-agent@0.73.1 # --extension only loads a package's pi.extensions entries; pi/core is a # skills+prompts-only package, so the equivalent --skill/--prompt-template # resource flags are used. The load-test script additionally asserts via # RPC that every curated command actually loaded. - name: Offline load test with the Pi CLI run: | PI_OFFLINE=1 pi --offline --mode rpc --no-session --no-context-files --no-extensions \ --skill pi/core/skills --prompt-template pi/core/commands /dev/null node scripts/ci/pi-core-load-test.js python-tests: name: Python Lint, Type Check & Test runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Setup Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.11' - name: Install Python dependencies run: python -m pip install --upgrade pip && python -m pip install -e '.[dev]' - name: Run ruff (lint) run: python -m ruff check src tests - name: Run mypy (type check) run: python -m mypy src - name: Run Python tests run: python -m pytest tests/test_*.py -m "not integration" - name: Test minimum supported OpenAI SDK run: | python -m pip install 'openai==2.34.0' python -m pytest tests/test_provider_tools.py tests/test_atlas_provider.py tests/test_astraflow_provider.py tests/test_resolver.py security: name: Security Scan runs-on: ubuntu-latest timeout-minutes: 5 steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '20.x' - name: Install audit dependencies run: npm ci --ignore-scripts - name: Run npm audit run: | npm audit signatures # Runtime/package advisories are release blockers. Development-only # lint tooling remains covered by signature and IOC verification. npm audit --omit=dev --audit-level=high - name: Run supply-chain IOC scan run: npm run security:ioc-scan coverage: name: Coverage runs-on: ubuntu-latest timeout-minutes: 20 steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '20.x' - name: Install dependencies run: npm ci --ignore-scripts - name: Install and verify ripgrep run: | sudo apt-get update sudo apt-get install --no-install-recommends -y ripgrep rg --version printf 'ecc-pcre2-probe\n' | rg --pcre2 -- '^ecc-pcre2-probe$' - name: Run coverage run: npm run coverage - name: Upload coverage report if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: coverage-ubuntu-node20-npm path: coverage/ lint: name: Lint runs-on: ubuntu-latest timeout-minutes: 5 steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '20.x' - name: Install dependencies run: npm ci --ignore-scripts - name: Run lint run: npm run lint