From d5cb8aee063065a48233458e04771635167825c8 Mon Sep 17 00:00:00 2001 From: Ysopwnable <161784452+N0zoM1z0@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:22:03 +0800 Subject: [PATCH] fix(ci): scope verification and reuse native runners (#1344) * fix(ci): scope documentation checks and cancel superseded runs * ci: reuse package runners for native Inspector checks * test(ci): share deterministic workflow Git fixtures --- .github/workflows/ci.yml | 65 ++---- .../boundary/filesystem/ciChangeScope.test.ts | 203 ++++++++++++++++++ .../filesystem/releaseCheckpoint.test.ts | 33 +-- tests/support/workflowGit.ts | 33 +++ 4 files changed, 259 insertions(+), 75 deletions(-) create mode 100644 tests/boundary/filesystem/ciChangeScope.test.ts create mode 100644 tests/support/workflowGit.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea474dbf0..97025afe7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,9 @@ on: concurrency: group: ci-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} + # The latest main push verifies the full tree, including earlier changes. + # Publication uses its own release workflow and concurrency group. + cancel-in-progress: true env: HUSKY: "0" @@ -47,15 +49,22 @@ jobs: BASE_SHA: ${{ github.event.pull_request.base.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | - if [[ "${{ github.event_name }}" != "pull_request" ]]; then + if [[ "${GITHUB_EVENT_NAME}" != "pull_request" ]]; then echo "required=true" >> "${GITHUB_OUTPUT}" - elif git diff --quiet "${BASE_SHA}" "${HEAD_SHA}" -- \ + # Compare the PR's changes, excluding unrelated updates on its base. + elif git diff --quiet "${BASE_SHA}...${HEAD_SHA}" -- \ . \ ':(exclude)docs/**' \ + ':(top,exclude,glob)README*.md' \ ':(exclude)AGENTS.md' \ ':(exclude)CONTRIBUTING.md'; then echo "required=false" >> "${GITHUB_OUTPUT}" else + diff_status=$? + # A failed comparison must fail classification, not report a scope. + if [[ "${diff_status}" -ne 1 ]]; then + exit "${diff_status}" + fi echo "required=true" >> "${GITHUB_OUTPUT}" fi @@ -302,7 +311,7 @@ jobs: - run: npm run verify:windows-native:artifact - run: npm run verify:package - name: Verify real Node Inspector discovery and observation - if: matrix.platform == 'darwin' + timeout-minutes: 10 run: npm run verify:inspector apple-artifacts: @@ -365,48 +374,6 @@ jobs: if-no-files-found: error retention-days: 30 - javascript-runtime-observation: - name: JavaScript runtime observation (${{ matrix.os }}) - needs: changes - if: github.event_name == 'push' || needs.changes.outputs.code == 'true' - strategy: - fail-fast: false - max-parallel: 2 - matrix: - include: - - os: ubuntu-latest - platform: linux - architecture: x64 - - os: ubuntu-24.04-arm - platform: linux - architecture: arm64 - - os: windows-latest - platform: win32 - architecture: x64 - runs-on: ${{ matrix.os }} - timeout-minutes: 10 - env: - NODE_OPTIONS: "--max-old-space-size=1024" - UV_THREADPOOL_SIZE: "2" - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version-file: .nvmrc - cache: npm - - name: Verify native runtime identity - env: - EXPECTED_CI_PLATFORM: ${{ matrix.platform }} - EXPECTED_CI_ARCHITECTURE: ${{ matrix.architecture }} - run: >- - node -e "const assert = require('node:assert/strict'); - assert.equal(process.platform, process.env.EXPECTED_CI_PLATFORM); - assert.equal(process.arch, process.env.EXPECTED_CI_ARCHITECTURE); - console.log(JSON.stringify({ platform: process.platform, architecture: process.arch, node: process.version }));" - - run: npm ci - - name: Verify real Node Inspector discovery and observation - run: npm run verify:inspector - windows-curated: name: Windows capability and package lane needs: [changes, windows-native-build] @@ -475,6 +442,12 @@ jobs: tests/process-global/updateMaintenance.test.ts tests/conformance/providers/ghidra/realGhidraWindowsWorkflow.test.ts - run: npm run verify:windows-native + - name: Verify real Node Inspector discovery and observation + timeout-minutes: 10 + env: + NODE_OPTIONS: "--max-old-space-size=1024" + UV_THREADPOOL_SIZE: "2" + run: npm run verify:inspector - run: npm run verify:package:windows - name: Verify native directory identity on Node 24 run: >- diff --git a/tests/boundary/filesystem/ciChangeScope.test.ts b/tests/boundary/filesystem/ciChangeScope.test.ts new file mode 100644 index 000000000..107ba8809 --- /dev/null +++ b/tests/boundary/filesystem/ciChangeScope.test.ts @@ -0,0 +1,203 @@ +import { mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; + +import { expect, it } from "vitest"; +import { parse } from "yaml"; +import { z } from "zod"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { + executeWorkflowFixture as execFileAsync, + workflowGit as git, +} from "../../support/workflowGit.js"; + +const workflowSchema = z.object({ + jobs: z.object({ + changes: z.object({ + steps: z.array( + z.object({ id: z.string().optional(), run: z.string().optional() }), + ), + }), + }), +}); + +async function commit(directory: string) { + await git(directory, ["add", "."]); + await git(directory, ["commit", "-m", "fix: fixture change"]); + return git(directory, ["rev-parse", "HEAD"]); +} + +async function change( + directory: string, + paths: readonly string[], + content = "Changed fixture content\n", +) { + for (const path of paths) { + const file = join(directory, path); + await mkdir(dirname(file), { recursive: true }); + await writeFile(file, content); + } + return commit(directory); +} + +async function fixture() { + const directory = await createTestTempDirectory("rea-ci-scope-"); + await git(directory, ["init", "--initial-branch=main"]); + const base = await change( + directory, + ["src/provider.ts", "README.md"], + "Initial fixture content\n", + ); + return { directory, base }; +} + +async function classify( + directory: string, + base: string, + head: string, + event = "pull_request", +) { + const workflow = workflowSchema.parse( + parse( + await readFile( + new URL("../../../.github/workflows/ci.yml", import.meta.url), + "utf8", + ), + ), + ); + const command = z + .string() + .parse( + workflow.jobs.changes.steps.find((step) => step.id === "package")?.run, + ); + const output = join(directory, "scope-output"); + await writeFile(output, ""); + await execFileAsync("bash", ["-e", "-o", "pipefail", "-c", command], { + cwd: directory, + env: { + ...process.env, + BASE_SHA: base, + HEAD_SHA: head, + GITHUB_EVENT_NAME: event, + GITHUB_OUTPUT: output, + }, + }); + return readFile(output, "utf8"); +} + +it("keeps every translated root README and authored guide on the documentation lane", async () => { + const { directory, base } = await fixture(); + const readmes = (await readdir(new URL("../../../", import.meta.url))).filter( + (name) => /^README(?:_.*)?\.md$/u.test(name), + ); + expect(readmes).toContain("README.md"); + const head = await change(directory, [ + ...readmes, + "docs/guide.md", + "AGENTS.md", + "CONTRIBUTING.md", + ]); + expect(await classify(directory, base, head)).toBe("required=false\n"); +}); + +it("does not charge a documentation PR for unrelated implementation updates on main", async () => { + const { directory, base } = await fixture(); + await git(directory, ["switch", "-c", "docs/change"]); + const head = await change(directory, ["README_zh-TW.md"]); + await git(directory, ["switch", "main"]); + const advancedBase = await change(directory, ["src/anotherProvider.ts"]); + expect(advancedBase).not.toBe(base); + expect(await classify(directory, advancedBase, head)).toBe( + "required=false\n", + ); +}); + +it.each([ + "src/README.md", + "README.md.ts", + "package.json", + "bridge/ghidra/provider.py", + "tests/fixture.test.ts", + ".github/workflows/ci.yml", + "unknown-file", +])( + "retains complete checks when documentation is mixed with %s", + async (path) => { + const { directory, base } = await fixture(); + const head = await change(directory, ["README.md", path]); + expect(await classify(directory, base, head)).toBe("required=true\n"); + }, +); + +it("retains complete checks when implementation is renamed into documentation", async () => { + const { directory, base } = await fixture(); + await mkdir(join(directory, "docs")); + await rename( + join(directory, "src/provider.ts"), + join(directory, "docs/provider.md"), + ); + const head = await commit(directory); + expect(await classify(directory, base, head)).toBe("required=true\n"); +}); + +it("retains the complete main-push baseline even for documentation", async () => { + const { directory, base } = await fixture(); + const head = await change(directory, ["README_ja.md"]); + expect(await classify(directory, base, head, "push")).toBe("required=true\n"); +}); + +it("fails classification rather than treating an invalid Git comparison as a scope", async () => { + const { directory, base } = await fixture(); + await expect(classify(directory, "missing-ref", base)).rejects.toMatchObject({ + code: 128, + }); + expect(await readFile(join(directory, "scope-output"), "utf8")).toBe(""); +}); + +it("retains native Inspector verification on every documented package host", async () => { + const stepSchema = z.object({ + run: z.string().optional(), + if: z.string().optional(), + }); + const steps = z.array(stepSchema); + const workflow = z + .object({ + jobs: z.object({ + "package-e2e": z.object({ + strategy: z.object({ + matrix: z.object({ + include: z.array( + z.object({ platform: z.string(), architecture: z.string() }), + ), + }), + }), + steps, + }), + "windows-curated": z.object({ steps }), + }), + }) + .parse( + parse( + await readFile( + new URL("../../../.github/workflows/ci.yml", import.meta.url), + "utf8", + ), + ), + ); + const packageLane = workflow.jobs["package-e2e"]; + const packageInspector = stepSchema.parse( + packageLane.steps.find((step) => step.run === "npm run verify:inspector"), + ); + expect(packageInspector.if).toBeUndefined(); + expect( + packageLane.strategy.matrix.include + .map(({ platform, architecture }) => `${platform}/${architecture}`) + .sort(), + ).toEqual(["darwin/arm64", "darwin/x64", "linux/arm64", "linux/x64"]); + const windowsInspector = stepSchema.parse( + workflow.jobs["windows-curated"].steps.find( + (step) => step.run === "npm run verify:inspector", + ), + ); + expect(windowsInspector.if).toBeUndefined(); +}); diff --git a/tests/boundary/filesystem/releaseCheckpoint.test.ts b/tests/boundary/filesystem/releaseCheckpoint.test.ts index e7dc61e41..226fbb37c 100644 --- a/tests/boundary/filesystem/releaseCheckpoint.test.ts +++ b/tests/boundary/filesystem/releaseCheckpoint.test.ts @@ -1,44 +1,19 @@ -import { execFile } from "node:child_process"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; -import { promisify } from "node:util"; import { expect, it } from "vitest"; import { z } from "zod"; import { parse } from "yaml"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { + executeWorkflowFixture as execFileAsync, + workflowGit as git, +} from "../../support/workflowGit.js"; -const execFileAsync = promisify(execFile); const verifier = fileURLToPath( new URL("../../../scripts/verify-release-checkpoint.mjs", import.meta.url), ); -async function git( - directory: string, - args: string[], - date = "2026-01-04T00:00:00Z", -) { - const result = await execFileAsync( - "git", - [ - "-c", - "user.name=REA fixture", - "-c", - "user.email=fixture@example.invalid", - "-c", - "commit.gpgsign=false", - "-c", - `core.hooksPath=${join(directory, "no-hooks")}`, - ...args, - ], - { - cwd: directory, - env: { ...process.env, GIT_AUTHOR_DATE: date, GIT_COMMITTER_DATE: date }, - }, - ); - return result.stdout.trim(); -} - async function metadata( directory: string, version: string, diff --git a/tests/support/workflowGit.ts b/tests/support/workflowGit.ts new file mode 100644 index 000000000..c89d2a4be --- /dev/null +++ b/tests/support/workflowGit.ts @@ -0,0 +1,33 @@ +import { execFile } from "node:child_process"; +import { join } from "node:path"; +import { promisify } from "node:util"; + +/** Execute a fixture command while retaining its output and native exit status. */ +export const executeWorkflowFixture = promisify(execFile); + +/** Run fixture Git commands with deterministic dates and no workstation hooks. */ +export async function workflowGit( + directory: string, + args: readonly string[], + date = "2026-01-04T00:00:00Z", +) { + const result = await executeWorkflowFixture( + "git", + [ + "-c", + "user.name=REA fixture", + "-c", + "user.email=fixture@example.invalid", + "-c", + "commit.gpgsign=false", + "-c", + `core.hooksPath=${join(directory, "no-hooks")}`, + ...args, + ], + { + cwd: directory, + env: { ...process.env, GIT_AUTHOR_DATE: date, GIT_COMMITTER_DATE: date }, + }, + ); + return result.stdout.trim(); +}