From 4ec3b6d5e89a6cdd38a6f8302240478b1522b48e Mon Sep 17 00:00:00 2001 From: Jay Zeng Date: Sat, 8 Aug 2026 17:56:29 -0700 Subject: [PATCH] ci: speed up GitHub Actions Signed-off-by: Jay Zeng --- .github/workflows/ci.yml | 155 +++--------------------- .github/workflows/e2e.yml | 31 +++++ .github/workflows/windows-qmd-smoke.yml | 123 +++++++++++++++++++ CHANGELOG.md | 3 + test/unit.test.ts | 37 ++++++ 5 files changed, 211 insertions(+), 138 deletions(-) create mode 100644 .github/workflows/e2e.yml create mode 100644 .github/workflows/windows-qmd-smoke.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba08597..5d74f76 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,168 +2,47 @@ name: CI on: push: + branches: [main] pull_request: +concurrency: + group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + permissions: contents: read jobs: - lint: - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, windows-latest] - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: npm - - run: npm ci - - run: npm run lint - - build: - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, windows-latest] - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: npm - - run: npm ci - - run: npm run build - - unit: - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, windows-latest] + verify-ubuntu: + name: verify (ubuntu-latest) + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 24 cache: npm - uses: oven-sh/setup-bun@v2 with: bun-version: latest - run: npm ci + - run: npm run lint + - run: npm run build - name: Run unit tests run: npm test - windows-qmd-smoke: - name: windows-qmd-smoke (PR #11 verification) + unit-windows: + name: unit (windows-latest) runs-on: windows-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 22 + node-version: 24 cache: npm - - run: npm ci - - - name: Install qmd via npm - shell: pwsh - run: npm install -g @tobilu/qmd - - - name: Ensure qmd prerequisites are on PATH - shell: pwsh - run: | - $npmPrefix = npm prefix -g - Write-Host "Adding to PATH: $npmPrefix" - Add-Content -Path $env:GITHUB_PATH -Value $npmPrefix - $gitUsrBin = Join-Path $env:ProgramFiles "Git\usr\bin" - if (Test-Path $gitUsrBin) { - Write-Host "Adding to PATH: $gitUsrBin" - Add-Content -Path $env:GITHUB_PATH -Value $gitUsrBin - } - - - name: Confirm qmd is callable (via the same direct-node path the wrapper uses) - shell: pwsh - run: | - $prefix = (npm prefix -g).Trim() - Write-Host "--- npm prefix shims ($prefix) ---" - Get-ChildItem -Path $prefix -Filter "qmd*" -ErrorAction SilentlyContinue | Format-Table -AutoSize Name, Length - Write-Host "--- Get-Command qmd -All ---" - Get-Command qmd -All | Format-Table -AutoSize - # cmd-shim writes literal `/bin/sh` into both qmd.cmd and qmd.ps1 on - # Windows, so neither shim is callable directly. The extension bypasses - # this by invoking qmd's JS entry with node. Verify that path exists - # and works. - $qmdJs = Join-Path $prefix "node_modules\@tobilu\qmd\dist\cli\qmd.js" - Write-Host "--- expected qmd.js: $qmdJs ---" - if (-not (Test-Path $qmdJs)) { - Write-Error "qmd.js not found at $qmdJs" - exit 1 - } - node $qmdJs --version - if ($LASTEXITCODE -ne 0) { - Write-Error "node $qmdJs --version failed (exit $LASTEXITCODE)" - exit 1 - } - - # Captures stock Node execFile behavior for the runner's qmd shim. This - # is informational because shim behavior varies by package manager. - - name: Smoke (informational) — stock Node execFile -> qmd - shell: pwsh - continue-on-error: true - run: | - node -e "const { execFile } = require('node:child_process'); execFile('qmd', ['--version'], (err, stdout, stderr) => { if (err) { console.error('STOCK execFile FAILED (expected on Windows):', err.code, err.message); process.exit(1); } console.log('STOCK execFile OK:', stdout.trim()); });" - - # Exercises the extension's qmd execFile wrapper under Node. The - # setupQmdCollection() calls qmd via the wrapped execFile, so a - # successful run here verifies Windows shell invocation. - - name: Smoke — extension wrapper -> qmd (must succeed) - shell: pwsh - run: | - node --import tsx -e "import('./index.ts').then(async (m) => { m.ensureDirs(); const ok = await m.setupQmdCollection(); if (!ok) { console.error('setupQmdCollection returned false — wrapper did not fix qmd invocation'); process.exit(1); } console.log('OK: extension wrapper successfully invoked qmd through the Windows shell'); }).catch((e) => { console.error('FAILED:', e); process.exit(1); });" - - # Verifies resolveMemoryDir() falls back to USERPROFILE when HOME - # is unset (the second bug PR #11 fixes). We import the module in a - # subprocess with HOME deliberately removed, then have it create the - # default memory directory and print its location. - - name: Smoke — resolveMemoryDir USERPROFILE fallback - shell: pwsh - run: | - Remove-Item Env:HOME -ErrorAction SilentlyContinue - Remove-Item Env:PI_MEMORY_DIR -ErrorAction SilentlyContinue - if (-not $env:USERPROFILE) { Write-Error "USERPROFILE is not set on this runner"; exit 1 } - $expectedPrefix = Join-Path $env:USERPROFILE ".pi\agent\memory" - Write-Host "Expected prefix: $expectedPrefix" - $actual = node --import tsx -e "import('./index.ts').then(m => { console.log(m.dailyPath('2026-01-01')); });" - Write-Host "ensureDirs/dailyPath returned: $actual" - if ($actual -notlike "$expectedPrefix*") { - Write-Error "FAILED: resolveMemoryDir did not honor USERPROFILE fallback. Got: $actual" - exit 1 - } - if ($actual -like "*~\.pi*") { - Write-Error "FAILED: literal '~' subdirectory still present in resolved path" - exit 1 - } - Write-Host "OK: memory directory resolved under USERPROFILE" - - test: - runs-on: ubuntu-latest - env: - OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - PI_E2E_PROVIDER: openai - PI_E2E_MODEL: gpt-4o-mini - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: oven-sh/setup-bun@v2 with: - node-version: 20 - cache: npm + bun-version: latest - run: npm ci - - name: Run e2e tests - if: ${{ env.OPENAI_API_KEY != '' }} + - name: Run unit tests run: npm test - - name: Skip e2e tests (missing OPENAI_API_KEY) - if: ${{ env.OPENAI_API_KEY == '' }} - run: echo "Skipping e2e tests. Add an OPENAI_API_KEY repository secret to enable." diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..d92eadc --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,31 @@ +name: E2E + +on: + workflow_dispatch: + +concurrency: + group: e2e-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + e2e: + runs-on: ubuntu-latest + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + PI_E2E_PROVIDER: openai + PI_E2E_MODEL: gpt-4o-mini + steps: + - name: Require API key + shell: bash + run: test -n "$OPENAI_API_KEY" + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + - run: npm ci + - name: Run e2e tests + run: npm run test:e2e diff --git a/.github/workflows/windows-qmd-smoke.yml b/.github/workflows/windows-qmd-smoke.yml new file mode 100644 index 0000000..ff2af8c --- /dev/null +++ b/.github/workflows/windows-qmd-smoke.yml @@ -0,0 +1,123 @@ +name: Windows qmd smoke + +on: + push: + branches: [main] + paths: + - index.ts + - package.json + - package-lock.json + - test/qmd-cache.ts + - .github/workflows/windows-qmd-smoke.yml + pull_request: + paths: + - index.ts + - package.json + - package-lock.json + - test/qmd-cache.ts + - .github/workflows/windows-qmd-smoke.yml + workflow_dispatch: + +concurrency: + group: qmd-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + windows-qmd-smoke: + name: windows-qmd-smoke + runs-on: windows-latest + env: + QMD_VERSION: "2.5.3" + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + + - name: Set isolated qmd prefix + id: qmd-prefix + shell: pwsh + run: | + $prefix = Join-Path $env:RUNNER_TEMP "qmd" + "path=$prefix" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + "QMD_PREFIX=$prefix" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + + - name: Cache qmd installation + id: qmd-cache + uses: actions/cache@v4 + with: + path: ${{ steps.qmd-prefix.outputs.path }} + key: qmd-${{ runner.os }}-node-22-${{ env.QMD_VERSION }} + + - name: Install qmd via npm + if: steps.qmd-cache.outputs.cache-hit != 'true' + shell: pwsh + run: npm install --global --prefix "$env:QMD_PREFIX" "@tobilu/qmd@$env:QMD_VERSION" + + - name: Add qmd and Git prerequisites to PATH + shell: pwsh + run: | + Write-Host "Adding to PATH: $env:QMD_PREFIX" + Add-Content -Path $env:GITHUB_PATH -Value $env:QMD_PREFIX + $gitUsrBin = Join-Path $env:ProgramFiles "Git\usr\bin" + if (Test-Path $gitUsrBin) { + Write-Host "Adding to PATH: $gitUsrBin" + Add-Content -Path $env:GITHUB_PATH -Value $gitUsrBin + } + + - name: Confirm qmd is callable through its direct-node path + shell: pwsh + run: | + Write-Host "--- npm prefix shims ($env:QMD_PREFIX) ---" + Get-ChildItem -Path $env:QMD_PREFIX -Filter "qmd*" -ErrorAction SilentlyContinue | Format-Table -AutoSize Name, Length + Write-Host "--- Get-Command qmd -All ---" + Get-Command qmd -All | Format-Table -AutoSize + $qmdJs = Join-Path $env:QMD_PREFIX "node_modules\@tobilu\qmd\dist\cli\qmd.js" + Write-Host "--- expected qmd.js: $qmdJs ---" + if (-not (Test-Path $qmdJs)) { + Write-Error "qmd.js not found at $qmdJs" + exit 1 + } + node $qmdJs --version + if ($LASTEXITCODE -ne 0) { + Write-Error "node $qmdJs --version failed (exit $LASTEXITCODE)" + exit 1 + } + + # Captures stock Node execFile behavior for the runner's qmd shim. This + # is informational because shim behavior varies by package manager. + - name: Smoke (informational) — stock Node execFile -> qmd + shell: pwsh + continue-on-error: true + run: | + node -e "const { execFile } = require('node:child_process'); execFile('qmd', ['--version'], (err, stdout, stderr) => { if (err) { console.error('STOCK execFile FAILED (expected on Windows):', err.code, err.message); process.exit(1); } console.log('STOCK execFile OK:', stdout.trim()); });" + + - name: Smoke — extension wrapper -> qmd (must succeed) + shell: pwsh + run: | + node --import tsx -e "import('./index.ts').then(async (m) => { m.ensureDirs(); const ok = await m.setupQmdCollection(); if (!ok) { console.error('setupQmdCollection returned false — wrapper did not fix qmd invocation'); process.exit(1); } console.log('OK: extension wrapper successfully invoked qmd through the Windows shell'); }).catch((e) => { console.error('FAILED:', e); process.exit(1); });" + + - name: Smoke — resolveMemoryDir USERPROFILE fallback + shell: pwsh + run: | + Remove-Item Env:HOME -ErrorAction SilentlyContinue + Remove-Item Env:PI_MEMORY_DIR -ErrorAction SilentlyContinue + if (-not $env:USERPROFILE) { Write-Error "USERPROFILE is not set on this runner"; exit 1 } + $expectedPrefix = Join-Path $env:USERPROFILE ".pi\agent\memory" + Write-Host "Expected prefix: $expectedPrefix" + $actual = node --import tsx -e "import('./index.ts').then(m => { console.log(m.dailyPath('2026-01-01')); });" + Write-Host "ensureDirs/dailyPath returned: $actual" + if ($actual -notlike "$expectedPrefix*") { + Write-Error "FAILED: resolveMemoryDir did not honor USERPROFILE fallback. Got: $actual" + exit 1 + } + if ($actual -like "*~\.pi*") { + Write-Error "FAILED: literal '~' subdirectory still present in resolved path" + exit 1 + } + Write-Host "OK: memory directory resolved under USERPROFILE" diff --git a/CHANGELOG.md b/CHANGELOG.md index dae6452..c3f26aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ follows [Semantic Versioning](https://semver.org/). ### Changed +- Reduced pull-request CI duplication and setup overhead by consolidating the + fast verification jobs, canceling superseded runs, moving API-backed e2e to + an explicit workflow, and caching the path-filtered Windows qmd smoke test. - Migrated Pi runtime imports and peer dependencies from the retired `@mariozechner` scope to `@earendil-works` 0.81.1+, including its unified TypeBox exports and compatibility API. The minimum Node.js version is now diff --git a/test/unit.test.ts b/test/unit.test.ts index 18362f0..12863a3 100644 --- a/test/unit.test.ts +++ b/test/unit.test.ts @@ -150,6 +150,43 @@ describe("runtime package scope", () => { }); }); +describe("GitHub Actions workflows", () => { + const ciWorkflow = fs.readFileSync(new URL("../.github/workflows/ci.yml", import.meta.url), "utf-8"); + const e2eWorkflow = fs.readFileSync(new URL("../.github/workflows/e2e.yml", import.meta.url), "utf-8"); + const qmdWorkflow = fs.readFileSync(new URL("../.github/workflows/windows-qmd-smoke.yml", import.meta.url), "utf-8"); + + test("runs feature-branch CI once and cancels superseded runs", () => { + expect(ciWorkflow).toContain("push:\n branches: [main]\n pull_request:"); + expect(ciWorkflow).toContain( + `group: ci-\${{ github.workflow }}-\${{ github.event.pull_request.number || github.ref }}`, + ); + expect(ciWorkflow).toContain("cancel-in-progress: true"); + }); + + test("installs once per OS for the fast verification path", () => { + expect(ciWorkflow.match(/- run: npm ci/g)).toHaveLength(2); + expect(ciWorkflow).toContain("name: verify (ubuntu-latest)"); + expect(ciWorkflow).toContain("name: unit (windows-latest)"); + expect(ciWorkflow).not.toContain("matrix:"); + expect(ciWorkflow).not.toContain("windows-qmd-smoke"); + expect(ciWorkflow).not.toContain("OPENAI_API_KEY"); + }); + + test("pins and caches the path-filtered Windows qmd smoke", () => { + expect(qmdWorkflow).toContain("name: Windows qmd smoke"); + expect(qmdWorkflow).toContain("paths:"); + expect(qmdWorkflow).toContain('QMD_VERSION: "2.5.3"'); + expect(qmdWorkflow).toContain("uses: actions/cache@v4"); + expect(qmdWorkflow).toContain('"@tobilu/qmd@$env:QMD_VERSION"'); + }); + + test("keeps API-backed e2e explicit and uses the e2e command", () => { + expect(e2eWorkflow).toContain("workflow_dispatch:"); + expect(e2eWorkflow).not.toContain("pull_request:"); + expect(e2eWorkflow).toContain("run: npm run test:e2e"); + }); +}); + // We need to import the default export to register tools import registerExtension from "../index.js";