From e86d5ea5a015dd719e9c4ac2506be222e6cae914 Mon Sep 17 00:00:00 2001 From: YiJie Date: Wed, 9 Sep 2026 22:21:57 +0800 Subject: [PATCH 1/2] ci: preserve prepared dependencies and classify documentation safely --- .github/workflows/ci.yml | 26 +++++--- .github/workflows/shared-quality.yml | 2 + scripts/ci-scope.test.mjs | 88 ++++++++++++++++++++++++++++ 3 files changed, 107 insertions(+), 9 deletions(-) create mode 100644 scripts/ci-scope.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 532c6f5..ac50ec0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,16 +30,20 @@ jobs: BASE_SHA: ${{ github.event.pull_request.base.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | - git diff --name-only --diff-filter=ACMRT "$BASE_SHA...$HEAD_SHA" > "$RUNNER_TEMP/changed-files" + # Include deletions and both rename paths; preserve unusual filenames. + git diff --no-renames --name-only -z "$BASE_SHA...$HEAD_SHA" > "$RUNNER_TEMP/changed-files" docs_only=true full=false - while IFS= read -r file; do + while IFS= read -r -d '' file; do + case "$file" in + .agents/docs/*.md|.agents/docs/*.markdown) continue ;; + esac case "$file" in *.md|*.markdown) ;; *) docs_only=false ;; esac case "$file" in - AGENTS.md|.agents/*|package.json|package-lock.json|cordisx-package.json|runtime-manifest.json|tsconfig.json|vite.config.mjs|.github/*|scripts/*|entities/*) + AGENTS.md|.agents/*|package.json|package-lock.json|cordisx-package.json|cordisx-agent-tools.json|runtime-manifest.json|tsconfig*.json|vite.config.*|vitest.config.*|eslint.config.*|stylelint.config.*|dprint.json|.lintstagedrc.*|.github/*|scripts/*|entities/*) full=true ;; esac @@ -55,9 +59,11 @@ jobs: echo "- docs-only: \`$docs_only\`" echo "- full gate: \`$full\`" } >> "$GITHUB_STEP_SUMMARY" + - name: Check scope regression cases + run: node --test scripts/ci-scope.test.mjs check: - if: always() && (github.event_name == 'push' || needs.scope.outputs.full == 'true') + if: always() && (github.event_name == 'push' || needs.scope.result != 'success' || needs.scope.outputs.full == 'true') needs: scope runs-on: ubuntu-latest timeout-minutes: 15 @@ -94,7 +100,7 @@ jobs: cache=$([[ "$CACHE_HIT" == 'true' ]] && echo hit || echo miss) evidence="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" jq -n --arg schema 'cordisx/ci-job-result/v1' --arg repo "$GITHUB_REPOSITORY" \ - --arg head "$HEAD_SHA" --arg base "$BASE_SHA" --arg gateVersion 'chatroom-check-v2' \ + --arg head "$HEAD_SHA" --arg base "$BASE_SHA" --arg gateVersion 'chatroom-check-v3' \ --arg classification "$CLASSIFICATION" --arg job "$GITHUB_JOB" --arg startedAt "$STARTED_AT" \ --arg completedAt "$completed_at" --arg cache "$cache" --arg result "$RESULT" \ --arg environment 'github-hosted/ubuntu-latest/node22' \ @@ -128,7 +134,8 @@ jobs: with: node-version: 22 cache: npm - - run: npm ci --ignore-scripts --allow-git=all + # The Git Host dependency needs prepare to produce cordisx/contracts and JSX exports. + - run: npm ci --allow-git=all - name: Typecheck the plugin env: BASE_SHA: ${{ github.event.pull_request.base.sha }} @@ -151,7 +158,7 @@ jobs: cache=$([[ "$CACHE_HIT" == 'true' ]] && echo hit || echo miss) evidence="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" jq -n --arg schema 'cordisx/ci-job-result/v1' --arg repo "$GITHUB_REPOSITORY" \ - --arg head "$HEAD_SHA" --arg base "$BASE_SHA" --arg gateVersion 'chatroom-check-v2' \ + --arg head "$HEAD_SHA" --arg base "$BASE_SHA" --arg gateVersion 'chatroom-check-v3' \ --arg classification 'changed-typecheck' --arg job "$GITHUB_JOB" --arg startedAt "$STARTED_AT" \ --arg completedAt "$completed_at" --arg cache "$cache" --arg result "$RESULT" \ --arg environment 'github-hosted/ubuntu-latest/node22' \ @@ -185,7 +192,8 @@ jobs: with: node-version: 22 cache: npm - - run: npm ci --ignore-scripts --allow-git=all + # The Git Host dependency needs prepare to produce cordisx/contracts and JSX exports. + - run: npm ci --allow-git=all - name: Build, then run tests against the build env: BASE_SHA: ${{ github.event.pull_request.base.sha }} @@ -209,7 +217,7 @@ jobs: cache=$([[ "$CACHE_HIT" == 'true' ]] && echo hit || echo miss) evidence="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" jq -n --arg schema 'cordisx/ci-job-result/v1' --arg repo "$GITHUB_REPOSITORY" \ - --arg head "$HEAD_SHA" --arg base "$BASE_SHA" --arg gateVersion 'chatroom-check-v2' \ + --arg head "$HEAD_SHA" --arg base "$BASE_SHA" --arg gateVersion 'chatroom-check-v3' \ --arg classification 'build-test' --arg job "$GITHUB_JOB" --arg startedAt "$STARTED_AT" \ --arg completedAt "$completed_at" --arg cache "$cache" --arg result "$RESULT" \ --arg environment 'github-hosted/ubuntu-latest/node22' \ diff --git a/.github/workflows/shared-quality.yml b/.github/workflows/shared-quality.yml index d250e9b..6c53a87 100644 --- a/.github/workflows/shared-quality.yml +++ b/.github/workflows/shared-quality.yml @@ -21,11 +21,13 @@ jobs: steps: - uses: actions/checkout@v7 with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} persist-credentials: false fetch-depth: 0 - uses: actions/setup-node@v7 with: node-version: 22 + cache: npm - run: npm ci --ignore-scripts --allow-git=all - name: Install the exact shared audit provider run: | diff --git a/scripts/ci-scope.test.mjs b/scripts/ci-scope.test.mjs new file mode 100644 index 0000000..14c5a1e --- /dev/null +++ b/scripts/ci-scope.test.mjs @@ -0,0 +1,88 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { test } from 'node:test'; + +const workflow = readFileSync(new URL('../.github/workflows/ci.yml', import.meta.url), 'utf8'); +const block = workflow.split(' run: |\n')[1].split(' - name: Check scope regression cases')[0]; +const script = block.split('\n').map(line => line.replace(/^ /, '')).join('\n'); +const sensitive = 'entities/example.ts'; + +function classify(mode, filename = 'README.md') { + const cwd = mkdtempSync(path.join(tmpdir(), 'ci-scope-')); + const git = (...args) => execFileSync('git', args, { cwd, encoding: 'utf8' }).trim(); + function write(file, content) { + mkdirSync(path.dirname(path.join(cwd, file)), { recursive: true }); + writeFileSync(path.join(cwd, file), content); + } + try { + git('init', '--quiet'); + git('config', 'user.name', 'CI scope test'); + git('config', 'user.email', 'ci@example.invalid'); + write(sensitive, 'export {}\n'); + write('README.md', '# Base\n'); + git('add', '.'); + git('commit', '--quiet', '-m', 'base'); + const base = git('rev-parse', 'HEAD'); + if (mode === 'delete') git('rm', '--quiet', '--', sensitive); + if (mode === 'rename') git('mv', '--', sensitive, 'moved.md'); + if (mode !== 'empty') { + write(filename, '# Changed\n'); + git('add', '.'); + git('commit', '--quiet', '-m', 'change'); + } + const output = path.join(cwd, 'output'); + execFileSync('bash', ['-e', '-o', 'pipefail', '-c', script], { + cwd, + env: { + ...process.env, + BASE_SHA: base, + HEAD_SHA: git('rev-parse', 'HEAD'), + RUNNER_TEMP: cwd, + GITHUB_OUTPUT: output, + GITHUB_STEP_SUMMARY: path.join(cwd, 'summary'), + }, + }); + return Object.fromEntries(readFileSync(output, 'utf8').trim().split('\n').map(line => line.split('='))); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +} + +for (const mode of ['delete', 'rename', 'empty']) { + test(`${mode} cannot downgrade a sensitive diff to documentation`, () => { + assert.equal(classify(mode).full, 'true'); + }); +} + +test('ordinary README remains lightweight', () => { + assert.equal(classify('edit').full, 'false'); +}); + +test('guide classification respects owner semantics and unusual filenames', () => { + assert.equal(classify('edit', '.agents/docs/two words\nexample.md').full, 'false'); +}); + +test('maintenance rules retain complete validation', () => { + assert.equal(classify('edit', '.agents/rules/README.md').full, 'true'); +}); + +test('scope failure cannot skip the complete gate', () => { + assert.ok(workflow.includes("needs.scope.result != 'success'")); +}); + +for (const file of ['cordisx-agent-tools.json', 'stylelint.config.mjs', 'tsconfig.extra.json', 'vite.config.ts']) { + test(`shared build/quality and package configuration retains full checks: ${file}`, () => { + assert.equal(classify('edit', file).full, 'true'); + }); +} + +test('affected compilation jobs prepare the Git Host dependency', () => { + for (const name of ['typecheck', 'build-test']) { + const job = workflow.split(`\n ${name}:\n`)[1].split(/\n [a-z-]+:\n/)[0]; + assert.ok(job.includes('run: npm ci --allow-git=all')); + assert.ok(!job.includes('npm ci --ignore-scripts')); + } +}); From 34906ded2fa9323f09ed6c6b7ab1fd011a8d4159 Mon Sep 17 00:00:00 2001 From: YiJie Date: Wed, 9 Sep 2026 22:26:20 +0800 Subject: [PATCH 2/2] ci: allow obsolete full checks to cancel --- .github/workflows/ci.yml | 2 +- scripts/ci-scope.test.mjs | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ac50ec0..1f2e1d1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,7 +63,7 @@ jobs: run: node --test scripts/ci-scope.test.mjs check: - if: always() && (github.event_name == 'push' || needs.scope.result != 'success' || needs.scope.outputs.full == 'true') + if: ${{ !cancelled() && (github.event_name == 'push' || needs.scope.result != 'success' || needs.scope.outputs.full == 'true') }} needs: scope runs-on: ubuntu-latest timeout-minutes: 15 diff --git a/scripts/ci-scope.test.mjs b/scripts/ci-scope.test.mjs index 14c5a1e..1f8c1a0 100644 --- a/scripts/ci-scope.test.mjs +++ b/scripts/ci-scope.test.mjs @@ -86,3 +86,9 @@ test('affected compilation jobs prepare the Git Host dependency', () => { assert.ok(!job.includes('npm ci --ignore-scripts')); } }); + +test('complete jobs honor cancellation so superseded PR runs release their slot', () => { + const source = readFileSync(new URL('../.github/workflows/ci.yml', import.meta.url), 'utf8'); + assert.ok(source.includes(' if: ${{ !cancelled() && ')); + assert.ok(!source.includes(' if: always() && ')); +});