From d2be2d330d11fa5ddd37bc679bfa875282e1b247 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Fri, 16 Oct 2026 01:31:08 +0000 Subject: [PATCH 1/3] fix(ci): reuse npm downloads across lockfile updates --- .github/workflows/node-ci.yml | 21 ++++++ .github/workflows/windows-npm-profile.yml | 74 ++++++++++++++++++++ sdk/typescript/tests-ts/skeleton.test.ts | 85 ++++++++++++++++++++++- 3 files changed, 179 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/windows-npm-profile.yml diff --git a/.github/workflows/node-ci.yml b/.github/workflows/node-ci.yml index 5d0d661af..6a7ff6c9b 100644 --- a/.github/workflows/node-ci.yml +++ b/.github/workflows/node-ci.yml @@ -268,11 +268,30 @@ jobs: - *checkout-repository - &set-up-node-js name: Set up Node.js + id: node uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: ${{ matrix.node }} cache: npm cache-dependency-path: sdk/typescript/pnpm-lock.yaml + - &resolve-npm-cache + name: Resolve npm download cache + id: npm-cache + if: steps.node.outputs.cache-hit != 'true' + shell: bash + env: + PRIMARY_CACHE_KEY: ${{ steps.node.outputs.cache-primary-key }} + run: | + echo "path=$(npm config get cache)" >> "$GITHUB_OUTPUT" + echo "restore-prefix=${PRIMARY_CACHE_KEY%-*}-" >> "$GITHUB_OUTPUT" + - &restore-npm-cache + name: Restore previous npm downloads + if: steps.node.outputs.cache-hit != 'true' + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ steps.npm-cache.outputs.path }} + key: ${{ steps.node.outputs.cache-primary-key }} + restore-keys: ${{ steps.npm-cache.outputs.restore-prefix }} - *download-package-for-this-commit - name: Inspect installed package working-directory: sdk/typescript @@ -556,6 +575,8 @@ jobs: - *checkout-repository - *set-up-python - *set-up-node-js + - *resolve-npm-cache + - *restore-npm-cache - *download-package-for-this-commit - name: Inspect installed package working-directory: sdk/typescript diff --git a/.github/workflows/windows-npm-profile.yml b/.github/workflows/windows-npm-profile.yml new file mode 100644 index 000000000..fcb84a9ea --- /dev/null +++ b/.github/workflows/windows-npm-profile.yml @@ -0,0 +1,74 @@ +name: Windows npm install profile + +on: + push: + branches: [mdangelo/codex/windows-npm-install-performance] + +permissions: + contents: read + actions: read + +jobs: + profile: + name: Windows npm / ${{ matrix.cache }} + runs-on: windows-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + cache: [cold, fallback] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: "24.21.0" + package-manager-cache: false + - name: Resolve npm cache + id: npm + shell: bash + run: printf 'path=%s\n' "$(npm config get cache)" >> "$GITHUB_OUTPUT" + - name: Restore downloads from a different primary key + if: matrix.cache == 'fallback' + id: restore + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ steps.npm.outputs.path }} + key: node-cache-Windows-x64-npm-profile-${{ github.run_id }} + restore-keys: node-cache-Windows-x64-npm- + - name: Verify fallback matched an existing cache + if: matrix.cache == 'fallback' + shell: bash + env: + PRIMARY_HIT: ${{ steps.restore.outputs.cache-hit }} + MATCHED_KEY: ${{ steps.restore.outputs.cache-matched-key }} + run: | + test "$PRIMARY_HIT" != true + test -n "$MATCHED_KEY" + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + artifact-ids: 12017995827 + run-id: 38831680764 + github-token: ${{ github.token }} + path: dist + merge-multiple: true + - name: Profile the complete installed-package verification + working-directory: sdk/typescript + shell: bash + env: + npm_config_cache: ${{ matrix.cache == 'cold' && format('{0}/npm-empty', runner.temp) || steps.npm.outputs.path }} + npm_config_timing: "true" + npm_config_loglevel: http + npm_config_logs_dir: ${{ runner.temp }}/npm-install-logs + run: node scripts/check-package.mjs ../../dist/*.tgz + - name: Retain npm install timing and debug logs + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: windows-npm-${{ matrix.cache }} + path: ${{ runner.temp }}/npm-install-logs + retention-days: 7 diff --git a/sdk/typescript/tests-ts/skeleton.test.ts b/sdk/typescript/tests-ts/skeleton.test.ts index d167645d0..802ff933e 100644 --- a/sdk/typescript/tests-ts/skeleton.test.ts +++ b/sdk/typescript/tests-ts/skeleton.test.ts @@ -1,4 +1,6 @@ -import { readFile } from "node:fs/promises"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, expect, test } from "bun:test"; import { parse } from "smol-toml"; import { @@ -10,9 +12,11 @@ import { } from "../src/index.js"; import { main } from "../src/cli.js"; import { capture } from "./cli-fixtures.js"; +import { bashCommand, runCommand } from "./support/shell.js"; interface WorkflowStep { name?: string; + id?: string; uses?: string; with?: Record; run?: string; @@ -206,6 +210,85 @@ describe("TypeScript package skeleton", () => { } }); + test("reuses npm downloads after lockfile changes without skipping package installation", async () => { + const { jobs } = await workflow("node-ci.yml"); + for (const name of ["compatibility", "windows-verify"]) { + const steps = jobs[name]!.steps!; + const setup = steps.findIndex(({ id }) => id === "node"); + const resolver = steps.findIndex(({ id }) => id === "npm-cache"); + const restore = steps.findIndex(({ uses }) => + uses?.startsWith("actions/cache/restore@"), + ); + const inspection = steps.findIndex( + ({ name }) => name === "Inspect installed package", + ); + expect(setup).toBeGreaterThanOrEqual(0); + expect(setup).toBeLessThan(resolver); + expect(resolver).toBeLessThan(restore); + expect(restore).toBeLessThan(inspection); + expect(steps[setup]?.with?.["cache"]).toBe("npm"); + expect(steps[resolver]?.env).toEqual({ + PRIMARY_CACHE_KEY: "${{ steps.node.outputs.cache-primary-key }}", + }); + for (const index of [resolver, restore]) { + expect(steps[index]?.if).toBe("steps.node.outputs.cache-hit != 'true'"); + } + expect(steps[restore]?.with).toEqual({ + path: "${{ steps.npm-cache.outputs.path }}", + key: "${{ steps.node.outputs.cache-primary-key }}", + "restore-keys": "${{ steps.npm-cache.outputs.restore-prefix }}", + }); + expect(steps[inspection]).not.toHaveProperty("if"); + } + }); + + test("npm cache fallback shares lockfile versions within each platform and architecture", async () => { + const { jobs } = await workflow("node-ci.yml"); + const script = jobs["compatibility"]!.steps!.find( + ({ id }) => id === "npm-cache", + )!.run!; + const directory = await mkdtemp(join(tmpdir(), "npm-cache-workflow-")); + try { + for (const platform of ["Windows-x64", "Windows-arm64", "Linux-x64"]) { + for (const hash of ["a".repeat(64), "b".repeat(64)]) { + const cachePath = platform.startsWith("Windows") + ? "C:\\npm cache" + : "/tmp/npm cache"; + const output = join(directory, "output.txt"); + await rm(output, { force: true }); + const result = await runCommand( + bashCommand(), + [ + "-e", + "-o", + "pipefail", + "-c", + `npm() { test "$*" = "config get cache" && printf '%s\\n' "$TEST_NPM_CACHE"; }\n${script}`, + ], + { + cwd: directory, + env: { + ...process.env, + PRIMARY_CACHE_KEY: `node-cache-${platform}-npm-${hash}`, + GITHUB_OUTPUT: "output.txt", + TEST_NPM_CACHE: cachePath, + }, + }, + ); + expect(result.status, result.stderr).toBe(0); + expect( + (await readFile(output, "utf8")).trim().split(/\r?\n/u), + ).toEqual([ + `path=${cachePath}`, + `restore-prefix=node-cache-${platform}-npm-`, + ]); + } + } + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + test("randomizes tests and keeps the default and Windows CI timeouts", async () => { const packageJson = JSON.parse( await readFile(new URL("../package.json", import.meta.url), "utf8"), From ae9f7594e627fccced099d900d219428277246c8 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Fri, 16 Oct 2026 01:48:47 +0000 Subject: [PATCH 2/3] test(ci): finish Windows npm cache profiling --- .github/workflows/windows-npm-profile.yml | 74 ----------------------- 1 file changed, 74 deletions(-) delete mode 100644 .github/workflows/windows-npm-profile.yml diff --git a/.github/workflows/windows-npm-profile.yml b/.github/workflows/windows-npm-profile.yml deleted file mode 100644 index fcb84a9ea..000000000 --- a/.github/workflows/windows-npm-profile.yml +++ /dev/null @@ -1,74 +0,0 @@ -name: Windows npm install profile - -on: - push: - branches: [mdangelo/codex/windows-npm-install-performance] - -permissions: - contents: read - actions: read - -jobs: - profile: - name: Windows npm / ${{ matrix.cache }} - runs-on: windows-latest - timeout-minutes: 20 - strategy: - fail-fast: false - matrix: - cache: [cold, fallback] - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: "3.12" - - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 - with: - node-version: "24.21.0" - package-manager-cache: false - - name: Resolve npm cache - id: npm - shell: bash - run: printf 'path=%s\n' "$(npm config get cache)" >> "$GITHUB_OUTPUT" - - name: Restore downloads from a different primary key - if: matrix.cache == 'fallback' - id: restore - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: ${{ steps.npm.outputs.path }} - key: node-cache-Windows-x64-npm-profile-${{ github.run_id }} - restore-keys: node-cache-Windows-x64-npm- - - name: Verify fallback matched an existing cache - if: matrix.cache == 'fallback' - shell: bash - env: - PRIMARY_HIT: ${{ steps.restore.outputs.cache-hit }} - MATCHED_KEY: ${{ steps.restore.outputs.cache-matched-key }} - run: | - test "$PRIMARY_HIT" != true - test -n "$MATCHED_KEY" - - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 - with: - artifact-ids: 12017995827 - run-id: 38831680764 - github-token: ${{ github.token }} - path: dist - merge-multiple: true - - name: Profile the complete installed-package verification - working-directory: sdk/typescript - shell: bash - env: - npm_config_cache: ${{ matrix.cache == 'cold' && format('{0}/npm-empty', runner.temp) || steps.npm.outputs.path }} - npm_config_timing: "true" - npm_config_loglevel: http - npm_config_logs_dir: ${{ runner.temp }}/npm-install-logs - run: node scripts/check-package.mjs ../../dist/*.tgz - - name: Retain npm install timing and debug logs - if: always() - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: windows-npm-${{ matrix.cache }} - path: ${{ runner.temp }}/npm-install-logs - retention-days: 7 From 9c3abd777eea98ab3f4249112ddb562ce444e47f Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Fri, 16 Oct 2026 03:01:34 +0000 Subject: [PATCH 3/3] test(ci): format parameterized report identities correctly --- sdk/typescript/tests-ts/cli-skills.test.ts | 2 +- sdk/typescript/tests-ts/scan-skill-paths.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/sdk/typescript/tests-ts/cli-skills.test.ts b/sdk/typescript/tests-ts/cli-skills.test.ts index ef7970cff..b696cda29 100644 --- a/sdk/typescript/tests-ts/cli-skills.test.ts +++ b/sdk/typescript/tests-ts/cli-skills.test.ts @@ -158,7 +158,7 @@ describe("CLI skill commands", () => { ); test.each([0, 2])( - "combines inline, file, and directory context and cleans up after exit %s", + "combines inline, file, and directory context and cleans up after exit %d", async (status) => { const directory = await temporaryDirectory("validation-context-"); await mkdir(join(directory, "context", "nested"), { recursive: true }); diff --git a/sdk/typescript/tests-ts/scan-skill-paths.test.ts b/sdk/typescript/tests-ts/scan-skill-paths.test.ts index 7893d803c..ece28dd84 100644 --- a/sdk/typescript/tests-ts/scan-skill-paths.test.ts +++ b/sdk/typescript/tests-ts/scan-skill-paths.test.ts @@ -40,7 +40,7 @@ async function fixture() { } test.each([false, true])( - "disables cached skill files while preserving effective selection (profile=%s)", + "disables cached skill files while preserving effective selection (profile=%j)", async (profile) => { const { root, home, plugin, blocked } = await fixture(); const unrelated = { @@ -117,7 +117,7 @@ test("leaves plugins outside the credential home and absent skills unchanged", a }); test.each([false, true])( - "preserves malformed native skill settings (profile=%s)", + "preserves malformed native skill settings (profile=%j)", async (profile) => { const { home, plugin } = await fixture(); for (const skills of [