mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 12:15:57 +00:00
* fix(ci): breaking-change-guard handles \${VAR:-default} compose image syntax (REV12)
The sed 's/:.*//' pattern truncated at the first colon in the
\${CCS_IMAGE:-ghcr.io/...} expression, yielding "\${CCS_IMAGE" as the
image name instead of the actual registry path. Any change to the default
image namespace was therefore undetectable.
Introduce extract_image_name() that first strips the \${VAR:-default}
wrapper with a sed -E expression, then strips only the trailing :tag
suffix using a pattern that preserves internal colons (e.g. registry:5000/
owner/repo). Applied to both OLD_RAW and NEW_RAW extraction paths.
* fix(ci): breaking-change-guard runs on ubuntu-latest to cover forked PRs (REV13)
The trusted-author gate (COLLABORATOR|MEMBER|OWNER) caused forked-PR
contributors to bypass the breaking-change check entirely. A forked
contributor could rename services.ccs or change the image namespace
without a feat!/fix! marker and the guard would never run.
This workflow is a documented exception to the self-hosted-first policy:
it performs ONLY pure YAML diff parsing (git show / awk / sed). No build,
install, or arbitrary PR-branch scripts are executed. The checkout uses
persist-credentials: false. There is no untrusted code execution, so
ubuntu-latest is safe and necessary for universal fork coverage.
Update self-hosted-runner-policy.test.ts to:
- Introduce GITHUB_HOSTED_RUNNER_EXCEPTIONS registry with required
justification comments for each entry
- Skip exception workflows in the "keeps active workflows on local runners"
and "gates pull-request workflows" assertions
- Add a new "documented exceptions use github-hosted runners" test that
verifies each exception entry actually uses a GitHub-hosted runner
(prevents stale entries accumulating without cleanup)
135 lines
5.7 KiB
TypeScript
135 lines
5.7 KiB
TypeScript
import { describe, expect, test } from 'bun:test';
|
|
import { spawnSync } from 'node:child_process';
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
|
|
function workflowsDir() {
|
|
return path.resolve(import.meta.dir, '../../../../.github/workflows');
|
|
}
|
|
|
|
// Documented exceptions to the self-hosted-first policy (see CLAUDE.md "Self-Hosted Runner Policy").
|
|
// Each entry must include a justification comment explaining why GitHub-hosted runners
|
|
// are required for correctness (not just convenience).
|
|
const GITHUB_HOSTED_RUNNER_EXCEPTIONS: Record<string, string> = {
|
|
// Pure YAML diff parser — no untrusted code execution. Must cover ALL PRs including
|
|
// forks to prevent forked contributors from bypassing the breaking-change check.
|
|
// Gating on trusted-author association would silently allow contract-breaking changes
|
|
// from forks. No build, install, or arbitrary PR-branch scripts are run here.
|
|
'breaking-change-guard.yml': 'ubuntu-latest — fork-safe YAML diff check; no untrusted code execution',
|
|
};
|
|
|
|
describe('self-hosted runner policy', () => {
|
|
test('keeps active workflows on local runners', () => {
|
|
const hostedRunnerLabels = [
|
|
'ubuntu-latest',
|
|
'ubuntu-24.04',
|
|
'ubuntu-22.04',
|
|
'macos-latest',
|
|
'windows-latest',
|
|
];
|
|
const workflowFiles = fs
|
|
.readdirSync(workflowsDir())
|
|
.filter((file) => file.endsWith('.yml') || file.endsWith('.yaml'));
|
|
|
|
expect(workflowFiles.length).toBeGreaterThan(0);
|
|
|
|
for (const file of workflowFiles) {
|
|
// Skip files with documented, justified exceptions to the self-hosted-first policy
|
|
if (GITHUB_HOSTED_RUNNER_EXCEPTIONS[file]) continue;
|
|
|
|
const workflow = fs.readFileSync(path.join(workflowsDir(), file), 'utf8');
|
|
|
|
for (const label of hostedRunnerLabels) {
|
|
expect(workflow, `${file} must not use GitHub-hosted runner ${label}`).not.toContain(
|
|
`runs-on: ${label}`
|
|
);
|
|
}
|
|
|
|
expect(workflow, `${file} must target a self-hosted runner`).toContain('self-hosted');
|
|
}
|
|
});
|
|
|
|
test('documented exceptions use github-hosted runners for justified safety reasons', () => {
|
|
// Verify each documented exception actually uses a GitHub-hosted runner
|
|
// (prevents stale exception entries that no longer reflect the workflow)
|
|
for (const [file, reason] of Object.entries(GITHUB_HOSTED_RUNNER_EXCEPTIONS)) {
|
|
const workflow = fs.readFileSync(path.join(workflowsDir(), file), 'utf8');
|
|
const hasGitHubHosted = ['ubuntu-latest', 'ubuntu-24.04', 'ubuntu-22.04', 'macos-latest', 'windows-latest']
|
|
.some((label) => workflow.includes(`runs-on: ${label}`));
|
|
expect(hasGitHubHosted, `${file} is in exceptions list (reason: ${reason}) but does not use a GitHub-hosted runner — remove the exception or restore the runner type`).toBe(true);
|
|
}
|
|
});
|
|
|
|
test('gates pull-request self-hosted worker deploys to trusted authors', () => {
|
|
const workflow = fs.readFileSync(path.join(workflowsDir(), 'deploy-ccs-worker.yml'), 'utf8');
|
|
|
|
expect(workflow).toContain("github.event_name != 'pull_request'");
|
|
expect(workflow).toContain(
|
|
'contains(fromJSON(\'["COLLABORATOR","MEMBER","OWNER"]\'), github.event.pull_request.author_association)'
|
|
);
|
|
});
|
|
|
|
test('gates pull-request workflows that check out code on self-hosted runners', () => {
|
|
const trustedAuthorGate =
|
|
'contains(fromJSON(\'["COLLABORATOR","MEMBER","OWNER"]\'), github.event.pull_request.author_association)';
|
|
const workflowFiles = fs
|
|
.readdirSync(workflowsDir())
|
|
.filter((file) => file.endsWith('.yml') || file.endsWith('.yaml'));
|
|
|
|
for (const file of workflowFiles) {
|
|
const workflow = fs.readFileSync(path.join(workflowsDir(), file), 'utf8');
|
|
|
|
if (workflow.includes('pull_request_target:')) {
|
|
expect(workflow, `${file} must not check out code from pull_request_target`).not.toContain(
|
|
'uses: actions/checkout'
|
|
);
|
|
}
|
|
|
|
// Documented exceptions run on GitHub-hosted runners for justified safety reasons
|
|
// (e.g. must cover forked PRs, no untrusted code execution). These workflows do not
|
|
// use self-hosted runners for their PR jobs, so the trusted-author gate does not apply.
|
|
if (GITHUB_HOSTED_RUNNER_EXCEPTIONS[file]) continue;
|
|
|
|
if (
|
|
workflow.includes('pull_request:') &&
|
|
workflow.includes('self-hosted') &&
|
|
workflow.includes('uses: actions/checkout')
|
|
) {
|
|
expect(workflow, `${file} must gate self-hosted PR checkout to trusted authors`).toContain(
|
|
trustedAuthorGate
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('scoped PAT headers match repository URL forms used by git', () => {
|
|
const header = 'AUTHORIZATION: basic test-token';
|
|
const env = {
|
|
...process.env,
|
|
GIT_CONFIG_COUNT: '2',
|
|
GIT_CONFIG_KEY_0: 'http.https://github.com/kaitranntt/ccs.extraheader',
|
|
GIT_CONFIG_VALUE_0: header,
|
|
GIT_CONFIG_KEY_1: 'http.https://github.com/kaitranntt/ccs.git.extraheader',
|
|
GIT_CONFIG_VALUE_1: header,
|
|
};
|
|
|
|
for (const url of ['https://github.com/kaitranntt/ccs', 'https://github.com/kaitranntt/ccs.git']) {
|
|
const result = spawnSync('git', ['config', '--get-urlmatch', 'http.extraheader', url], {
|
|
env,
|
|
encoding: 'utf8',
|
|
});
|
|
|
|
expect(result.status, result.stderr).toBe(0);
|
|
expect(result.stdout.trim()).toBe(header);
|
|
}
|
|
|
|
for (const file of ['release.yml', 'dev-release.yml', 'sync-dev-after-release.yml']) {
|
|
const workflow = fs.readFileSync(path.join(workflowsDir(), file), 'utf8');
|
|
|
|
expect(workflow).toContain('echo "::add-mask::${auth_header}"');
|
|
expect(workflow).toContain('http.https://github.com/kaitranntt/ccs.extraheader');
|
|
expect(workflow).toContain('http.https://github.com/kaitranntt/ccs.git.extraheader');
|
|
}
|
|
});
|
|
});
|