Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .github/workflows/ccm-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,19 @@ on:
# 因这个 check 从不运行而永久 BLOCKED(required check 永不满足)。ccm 未变时
# turbo/pnpm 缓存让 build/typecheck/lint/test 四步快速过,代价可接受。
pull_request:
# push 仍按 paths 收窄:main 上非 ccm 改动无需重复跑 ccm CI(push 不是 PR merge 门)。
# push 只保留 main 的落地验证:feature branch 已由 pull_request 覆盖,release tag
# 指向的 commit 也已经过 PR/main 验证;不再为同一 SHA 重复分配 runner。
push:
branches:
- 'main'
paths:
- 'ccm/**'
- '.github/workflows/ccm-ci.yml'

concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
ccm:
name: ccm
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/ccm-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -108,13 +108,15 @@ jobs:
name: ${{ matrix.asset }}
path: ccm/apps/cli/dist/${{ matrix.asset }}
if-no-files-found: error
retention-days: 14

- name: Upload checksum part
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.asset }}-sha256
path: ccm/apps/cli/dist/${{ matrix.asset }}.sha256
if-no-files-found: error
retention-days: 14

- name: Plan GitHub release metadata
id: release_meta
Expand Down Expand Up @@ -160,6 +162,7 @@ jobs:
name: ccm-SHA256SUMS
path: dist/SHA256SUMS
if-no-files-found: error
retention-days: 14

- name: Upload license documents artifact
uses: actions/upload-artifact@v4
Expand All @@ -170,6 +173,7 @@ jobs:
LICENSING.md
TRADEMARKS.md
if-no-files-found: error
retention-days: 14

- name: Plan GitHub release metadata
id: release_meta
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/installer-portability.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,17 @@ on:
- 'tests/scripts/test_install_integrity.sh'
- '.github/workflows/installer-portability.yml'
push:
branches:
- 'main'
paths:
- 'install.sh'
- 'tests/scripts/test_install_integrity.sh'
- '.github/workflows/installer-portability.yml'

concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
transactional-publish:
strategy:
Expand Down
17 changes: 13 additions & 4 deletions .github/workflows/macos-live-qualification.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ on:
- "tests/scripts/test_install_integrity.sh"
- "tests/scripts/test_macos_evidence_artifact.sh"
- "tests/content/macos-qualification-workflow.test.mjs"
# Live Darwin qualification is intentionally opt-in: add `ci:macos-live` to the PR at the
# revision that needs evidence. Remove/re-add the label after later commits to re-qualify.
pull_request:
types: [labeled]
paths:
- ".github/workflows/macos-live-qualification.yml"
- "scripts/qualify-macos-live.sh"
Expand All @@ -30,9 +33,15 @@ on:
permissions:
contents: read

concurrency:
# Unrelated label events get a unique run-id group, so they cannot cancel a real qualification.
group: ${{ github.workflow }}-${{ github.event.label.name == 'ci:macos-live' && github.event.pull_request.number || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' && github.event.label.name == 'ci:macos-live' }}

jobs:
build-sea:
name: build / ${{ matrix.contract }}
if: ${{ github.event_name != 'pull_request' || github.event.label.name == 'ci:macos-live' }}
strategy:
fail-fast: false
matrix:
Expand Down Expand Up @@ -161,7 +170,7 @@ jobs:
${{ runner.temp }}/macos-sea/${{ matrix.asset }}.sha256
${{ runner.temp }}/macos-sea/build-evidence
if-no-files-found: error
retention-days: 14
retention-days: 7
- name: Enforce build verdict
if: always()
shell: bash
Expand Down Expand Up @@ -258,7 +267,7 @@ jobs:
include-hidden-files: true
if-no-files-found: error
overwrite: true
retention-days: 14
retention-days: 7
- name: Enforce qualification verdict
if: steps.live.outcome != 'success'
shell: bash
Expand All @@ -268,7 +277,7 @@ jobs:

evidence-index:
name: evidence index
if: always()
if: ${{ always() && (github.event_name != 'pull_request' || github.event.label.name == 'ci:macos-live') }}
needs: qualify
runs-on: ubuntu-latest
steps:
Expand Down Expand Up @@ -366,4 +375,4 @@ jobs:
path: EVIDENCE_SHA256SUMS
if-no-files-found: error
overwrite: true
retention-days: 14
retention-days: 7
8 changes: 8 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,14 @@ claude plugin validate plugin/dist/claude-code
must leave no `plugin/dist` diff, and `claude plugin validate plugin/dist/claude-code`
must report no errors.

### PR 中按需请求真实 macOS 证据

普通 PR 不再自动分配昂贵的 macOS runner。只有改动 Darwin SEA 构建、安装/卸载、
launchd 行为、证据校验或 macOS qualification workflow 时,才给 PR 添加
`ci:macos-live` 标签。请在需要验收的最新 commit 推送完成后再添加标签;workflow 会把
arm64 与 x64 证据绑定到该 commit。标签添加后又有新 commit 时,先移除后重新添加该标签,
以重新验收最新 revision。日常 Linux-only、文档和无关代码变更无需此标签。

### ccm 改动:本地验收必须对齐 CI `build-and-check`

上面三道门覆盖 hook 行为 + content contract + plugin 结构,**但不含 ccm 的严格类型检查与
Expand Down
91 changes: 91 additions & 0 deletions tests/content/github-actions-cost-contract.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { test } from 'node:test';

function readWorkflow(name) {
return readFileSync(`.github/workflows/${name}`, 'utf8');
}

function triggerBlock(workflow, id) {
const marker = ` ${id}:\n`;
const start = workflow.indexOf(marker);
assert.notEqual(start, -1, `workflow trigger ${id} must exist`);
const tail = workflow.slice(start + marker.length);
const nextTrigger = tail.search(/^ [a-z_][a-z0-9_-]*:\n/m);
return nextTrigger === -1 ? tail : tail.slice(0, nextTrigger);
}

function uploadBlocks(workflow) {
const marker = 'uses: actions/upload-artifact@v4';
const blocks = [];
let cursor = 0;
while (true) {
const start = workflow.indexOf(marker, cursor);
if (start === -1) break;
const tail = workflow.slice(start + marker.length);
const nextStep = tail.search(/^ - (?:name|uses):/m);
blocks.push(nextStep === -1 ? tail : tail.slice(0, nextStep));
cursor = start + marker.length;
}
return blocks;
}

function assertMainOnlyPush(workflow, name) {
const push = triggerBlock(workflow, 'push');
assert.match(push, /^ branches:\n - 'main'$/m, `${name} push must target main only`);
assert.match(push, /^ paths:\n/m, `${name} must preserve its path filter`);
assert.doesNotMatch(push, /^ tags:/m, `${name} must not run on release tags`);
}

function assertPrOnlyCancellation(workflow, name) {
assert.match(
workflow,
/^concurrency:\n group: \$\{\{ github\.workflow \}\}-\$\{\{ github\.event\.pull_request\.number \|\| github\.run_id \}\}\n cancel-in-progress: \$\{\{ github\.event_name == 'pull_request' \}\}$/m,
`${name} must cancel only superseded pull-request revisions`,
);
}

test('ordinary CI push triggers are limited to main and never release tags', () => {
const ccm = readWorkflow('ccm-ci.yml');
const installer = readWorkflow('installer-portability.yml');
assertMainOnlyPush(ccm, 'ccm-ci');
assertMainOnlyPush(installer, 'installer-portability');
});

test('ordinary pull-request workflows cancel only superseded revisions', () => {
assertPrOnlyCancellation(readWorkflow('ccm-ci.yml'), 'ccm-ci');
assertPrOnlyCancellation(
readWorkflow('installer-portability.yml'),
'installer-portability',
);
});

test('non-release macOS evidence expires after seven days', () => {
const uploads = uploadBlocks(readWorkflow('macos-live-qualification.yml'));
assert.equal(uploads.length, 3, 'macOS qualification must keep its three evidence uploads');
for (const upload of uploads) {
assert.match(upload, /^ {10}retention-days: 7$/m);
}
});

test('release workflow artifacts are short-lived copies of attached release assets', () => {
const workflow = readWorkflow('ccm-release.yml');
const uploads = uploadBlocks(workflow);
assert.equal(uploads.length, 4, 'ccm release must keep its four artifact transfer points');
for (const upload of uploads) {
assert.match(upload, /^ {10}retention-days: 14$/m);
}
assert.equal(
(workflow.match(/uses: softprops\/action-gh-release@v2/g) ?? []).length,
2,
'formal GitHub Release attachments must remain intact',
);
});

test('maintainer guidance explains when and how to request live macOS evidence', () => {
const contributing = readFileSync('CONTRIBUTING.md', 'utf8');
assert.match(contributing, /`ci:macos-live`/);
assert.match(contributing, /真实 macOS|live macOS/i);
assert.match(contributing, /移除后重新添加|remove.*re-add/i);
assert.match(contributing, /最新.*commit|latest.*commit/i);
});
29 changes: 25 additions & 4 deletions tests/content/macos-qualification-workflow.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -218,8 +218,8 @@ const expectedStrategy = ` strategy:
asset: ccm-darwin-x64
`;

test('validator-only changes require both real macOS architecture qualification jobs', () => {
assert.match(workflow, /^ pull_request:\n paths:\n/m);
test('label-gated validator changes retain both real macOS architecture qualification jobs', () => {
assert.match(workflow, /^ pull_request:\n types: \[labeled\]\n paths:\n/m);
assert.match(workflow, /^ - "ccm\/\*\*"$/m);
assert.match(workflow, /^ - "scripts\/qualify-macos-live\.sh"$/m);
for (const trigger of ['push', 'pull_request']) {
Expand All @@ -234,6 +234,27 @@ test('validator-only changes require both real macOS architecture qualification
assert.match(workflow, /runner: macos-15-intel\n contract: darwin-x64/);
});

test('only the explicit macOS qualification label allocates runners for pull requests', () => {
const build = jobBlock('build-sea');
const evidenceIndex = jobBlock('evidence-index');
assert.match(
build,
/^ if: \$\{\{ github\.event_name != 'pull_request' \|\| github\.event\.label\.name == 'ci:macos-live' \}\}$/m,
);
assert.match(
evidenceIndex,
/^ if: \$\{\{ always\(\) && \(github\.event_name != 'pull_request' \|\| github\.event\.label\.name == 'ci:macos-live'\) \}\}$/m,
);
assert.match(
workflow,
/^ group: \$\{\{ github\.workflow \}\}-\$\{\{ github\.event\.label\.name == 'ci:macos-live' && github\.event\.pull_request\.number \|\| github\.run_id \}\}$/m,
);
assert.match(
workflow,
/^ cancel-in-progress: \$\{\{ github\.event_name == 'pull_request' && github\.event\.label\.name == 'ci:macos-live' \}\}$/m,
);
});

test('every Darwin job checks out and attests the exact pull-request head tree', () => {
const exactRef = 'ref: ${{ github.event.pull_request.head.sha || github.sha }}';
assert.equal(
Expand Down Expand Up @@ -545,7 +566,7 @@ test('raw evidence upload intentionally includes hidden members only within the
assert.match(upload, /^ {10}include-hidden-files: true$/m);
assert.match(upload, /^ {10}if-no-files-found: error$/m);
assert.match(upload, /^ {10}overwrite: true$/m);
assert.match(upload, /^ {10}retention-days: 14$/m);
assert.match(upload, /^ {10}retention-days: 7$/m);
assert.equal(
(workflow.match(/^[ \t]+include-hidden-files: true$/gm) ?? []).length,
1,
Expand Down Expand Up @@ -581,7 +602,7 @@ test('downloaded inner artifacts and the outer index are verified before index u
assert.ok(indexUpload > outerVerify, 'index artifact must upload only after outer verification');
assert.match(index.slice(outerVerify), /uses: actions\/upload-artifact@v4/);
assert.match(index.slice(indexUpload), /^ {10}overwrite: true$/m);
assert.match(index.slice(indexUpload), /^ {10}retention-days: 14$/m);
assert.match(index.slice(indexUpload), /^ {10}retention-days: 7$/m);
assert.match(
verification,
/^ {8}env:\n {10}EXPECTED_COMMIT: \$\{\{ github\.event\.pull_request\.head\.sha \|\| github\.sha \}\}$/m,
Expand Down
Loading