fix(ci): harden self-hosted workflow trust boundaries

Keep active CCS workflows on self-hosted runners, gate self-hosted PR execution to trusted authors, and scope privileged release credentials to the exact git operations that need them.
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-05-12 12:34:15 -04:00
1 parent d86032ed0a
commit 2273757c83
17 files changed
+210 -29

No files matched your search

+5 -1
View File
@@ -74,10 +74,14 @@ jobs:
if: >- if: >-
github.event_name != 'workflow_dispatch' && github.event_name != 'workflow_dispatch' &&
( (
github.event_name == 'pull_request_target' || (
github.event_name == 'pull_request_target' &&
contains(fromJSON('["COLLABORATOR","MEMBER","OWNER"]'), github.event.pull_request.author_association)
) ||
( (
github.event_name == 'issue_comment' && github.event_name == 'issue_comment' &&
github.event.issue.pull_request && github.event.issue.pull_request &&
contains(fromJSON('["COLLABORATOR","MEMBER","OWNER"]'), github.event.issue.author_association) &&
github.event.comment.body == '/review' && github.event.comment.body == '/review' &&
( (
contains(fromJSON('["COLLABORATOR","MEMBER","OWNER"]'), github.event.comment.author_association) || contains(fromJSON('["COLLABORATOR","MEMBER","OWNER"]'), github.event.comment.author_association) ||
+6
View File
@@ -16,6 +16,8 @@ concurrency:
jobs: jobs:
validate: validate:
if: >-
contains(fromJSON('["COLLABORATOR","MEMBER","OWNER"]'), github.event.pull_request.author_association)
runs-on: [self-hosted, linux, x64] runs-on: [self-hosted, linux, x64]
env: env:
# Keep Bun cache isolated per job workspace so parallel self-hosted runs # Keep Bun cache isolated per job workspace so parallel self-hosted runs
@@ -58,6 +60,8 @@ jobs:
run: ${{ matrix.check.cmd }} run: ${{ matrix.check.cmd }}
build: build:
if: >-
contains(fromJSON('["COLLABORATOR","MEMBER","OWNER"]'), github.event.pull_request.author_association)
runs-on: [self-hosted, linux, x64] runs-on: [self-hosted, linux, x64]
name: build name: build
env: env:
@@ -98,6 +102,8 @@ jobs:
if-no-files-found: error if-no-files-found: error
test: test:
if: >-
contains(fromJSON('["COLLABORATOR","MEMBER","OWNER"]'), github.event.pull_request.author_association)
runs-on: [self-hosted, linux, x64] runs-on: [self-hosted, linux, x64]
name: test name: test
needs: [build] needs: [build]
+4 -1
View File
@@ -21,7 +21,10 @@ on:
jobs: jobs:
deploy: deploy:
runs-on: ubuntu-latest if: >-
github.event_name != 'pull_request' ||
contains(fromJSON('["COLLABORATOR","MEMBER","OWNER"]'), github.event.pull_request.author_association)
runs-on: [self-hosted, linux, x64]
name: Deploy ccs-installer to CloudFlare name: Deploy ccs-installer to CloudFlare
steps: steps:
+11 -5
View File
@@ -50,9 +50,7 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
ref: dev ref: dev
# PAT_TOKEN required: dev-release pushes version bump commits to dev, persist-credentials: false
# and protected branches reject github.token writes once status checks are required.
token: ${{ secrets.PAT_TOKEN }}
- name: Setup Bun - name: Setup Bun
uses: oven-sh/setup-bun@v2 uses: oven-sh/setup-bun@v2
@@ -93,8 +91,16 @@ jobs:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: | run: |
# Use custom dev release script instead of semantic-release auth_header=$(printf 'x-access-token:%s' "$GITHUB_TOKEN" | base64 | tr -d '\n')
# This ensures dev versions follow {stable}-dev.{N} pattern echo "::add-mask::${auth_header}"
export GIT_CONFIG_COUNT=2
export GIT_CONFIG_KEY_0=http.https://github.com/kaitranntt/ccs.extraheader
export GIT_CONFIG_VALUE_0="AUTHORIZATION: basic ${auth_header}"
export GIT_CONFIG_KEY_1=http.https://github.com/kaitranntt/ccs.git.extraheader
export GIT_CONFIG_VALUE_1="AUTHORIZATION: basic ${auth_header}"
# Use custom dev release script instead of semantic-release.
# This ensures dev versions follow {stable}-dev.{N} pattern.
chmod +x scripts/dev-release.sh chmod +x scripts/dev-release.sh
./scripts/dev-release.sh ./scripts/dev-release.sh
+1 -1
View File
@@ -18,7 +18,7 @@ concurrency:
jobs: jobs:
publish: publish:
if: ${{ github.event_name != 'release' || !github.event.release.prerelease }} if: ${{ github.event_name != 'release' || !github.event.release.prerelease }}
runs-on: ubuntu-latest runs-on: [self-hosted, linux, x64]
permissions: permissions:
contents: read contents: read
+1 -1
View File
@@ -7,7 +7,7 @@ on:
jobs: jobs:
label: label:
runs-on: ubuntu-latest runs-on: [self-hosted, linux, x64]
permissions: permissions:
issues: write issues: write
pull-requests: read pull-requests: read
+2 -2
View File
@@ -7,7 +7,7 @@ on:
jobs: jobs:
publish: publish:
runs-on: ubuntu-latest runs-on: [self-hosted, linux, x64]
permissions: permissions:
contents: write # For creating GitHub releases contents: write # For creating GitHub releases
@@ -66,4 +66,4 @@ jobs:
## What's Changed ## What's Changed
See [CHANGELOG.md](https://github.com/kaitranntt/ccs/blob/main/CHANGELOG.md) See [CHANGELOG.md](https://github.com/kaitranntt/ccs/blob/main/CHANGELOG.md)
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+10 -4
View File
@@ -21,9 +21,7 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
# PAT_TOKEN required: semantic-release pushes version bump commits to main, persist-credentials: false
# which needs branch protection bypass. github.token cannot push to protected branches.
token: ${{ secrets.PAT_TOKEN }}
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -58,12 +56,20 @@ jobs:
env: env:
HUSKY: 0 HUSKY: 0
# PAT_TOKEN bypasses branch protection so semantic-release can push # PAT_TOKEN bypasses branch protection so semantic-release can push
# version bump + CHANGELOG commits directly to main # version bump + CHANGELOG commits directly to main.
GITHUB_TOKEN: ${{ secrets.PAT_TOKEN }} GITHUB_TOKEN: ${{ secrets.PAT_TOKEN }}
GH_TOKEN: ${{ secrets.PAT_TOKEN }} GH_TOKEN: ${{ secrets.PAT_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: | run: |
auth_header=$(printf 'x-access-token:%s' "$GITHUB_TOKEN" | base64 | tr -d '\n')
echo "::add-mask::${auth_header}"
export GIT_CONFIG_COUNT=2
export GIT_CONFIG_KEY_0=http.https://github.com/kaitranntt/ccs.extraheader
export GIT_CONFIG_VALUE_0="AUTHORIZATION: basic ${auth_header}"
export GIT_CONFIG_KEY_1=http.https://github.com/kaitranntt/ccs.git.extraheader
export GIT_CONFIG_VALUE_1="AUTHORIZATION: basic ${auth_header}"
set +e set +e
OUTPUT=$(bun x semantic-release 2>&1) OUTPUT=$(bun x semantic-release 2>&1)
STATUS=$? STATUS=$?
@@ -22,7 +22,7 @@ permissions:
jobs: jobs:
sync-project: sync-project:
runs-on: ubuntu-latest runs-on: [self-hosted, linux, x64]
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
+19 -5
View File
@@ -10,16 +10,20 @@ jobs:
runs-on: [self-hosted, linux, x64] runs-on: [self-hosted, linux, x64]
permissions: permissions:
contents: write contents: read
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
# PAT_TOKEN is required because this pushes to protected dev and must persist-credentials: false
# trigger follow-up Push CI / Dev Release workflows.
token: ${{ secrets.PAT_TOKEN }} - name: Harden Git workspace
run: |
rm -rf .git/hooks
mkdir -p .git/hooks
git config --local core.hooksPath /dev/null
- name: Configure Git - name: Configure Git
run: | run: |
@@ -47,4 +51,14 @@ jobs:
git commit --no-edit git commit --no-edit
fi fi
git push origin dev # PAT_TOKEN is required because this pushes to protected dev and must
# trigger follow-up Push CI / Dev Release workflows. Keep it scoped to
# the final push rather than persisting it in the checkout config.
auth_header=$(printf 'x-access-token:%s' "$PAT_TOKEN" | base64 | tr -d '\n')
echo "::add-mask::${auth_header}"
git -c core.hooksPath=/dev/null \
-c "http.https://github.com/kaitranntt/ccs.extraheader=AUTHORIZATION: basic ${auth_header}" \
-c "http.https://github.com/kaitranntt/ccs.git.extraheader=AUTHORIZATION: basic ${auth_header}" \
push origin dev
env:
PAT_TOKEN: ${{ secrets.PAT_TOKEN }}
+7
View File
@@ -50,6 +50,13 @@ AI MUST NOT declare a task done, close a session, or move to the next task while
- Verified on `2026-04-22` via `gh api repos/kaitranntt/ccs/branches/dev/protection`: `dev` currently requires `typecheck`, `lint`, `format`, `build`, and `test`, has no branch restrictions, and has no required PR-review gate. - Verified on `2026-04-22` via `gh api repos/kaitranntt/ccs/branches/dev/protection`: `dev` currently requires `typecheck`, `lint`, `format`, `build`, and `test`, has no branch restrictions, and has no required PR-review gate.
- `dev-release.yml` currently pushes with `PAT_TOKEN` because `dev` is protected by those required status checks. Do not switch it back to `github.token` unless branch protection changes with it. - `dev-release.yml` currently pushes with `PAT_TOKEN` because `dev` is protected by those required status checks. Do not switch it back to `github.token` unless branch protection changes with it.
### Self-Hosted Runner Policy
- Keep active CCS workflows on local self-hosted runners by default. Do **not** move jobs to `ubuntu-latest`, `macos-latest`, or other GitHub-hosted runners as the first security fix.
- For this public repo, standard GitHub-hosted runners may not consume billable minutes, but the project policy is still local-runner-first to keep compute predictable and avoid future quota surprises across repos.
- Make self-hosted use secure by gating untrusted PR triggers, avoiding checkout of untrusted code in privileged `pull_request_target` jobs, setting `persist-credentials: false`, and scoping PAT-backed credentials to the exact release/sync push step.
- If a workflow truly needs GitHub-hosted runners, document the exception in this file and add a regression test for the exception.
## Core Function ## Core Function
Multi-provider profile and runtime manager for Claude Code, Factory Droid, Multi-provider profile and runtime manager for Claude Code, Factory Droid,
@@ -7,7 +7,7 @@ function resolvePath(relativePath: string) {
} }
describe('PR-Agent review lane migration', () => { describe('PR-Agent review lane migration', () => {
test('keeps ai-review.yml as the PR-Agent workflow on the self-hosted cliproxy runner', () => { test('keeps ai-review.yml as the PR-Agent workflow on the self-hosted cliproxy runner with trusted PR gating', () => {
const workflowPath = resolvePath('../../../../.github/workflows/ai-review.yml'); const workflowPath = resolvePath('../../../../.github/workflows/ai-review.yml');
const prAgentConfigPath = resolvePath('../../../../.pr_agent.toml'); const prAgentConfigPath = resolvePath('../../../../.pr_agent.toml');
@@ -23,6 +23,14 @@ describe('PR-Agent review lane migration', () => {
expect(workflow).toContain('uses: qodo-ai/pr-agent'); expect(workflow).toContain('uses: qodo-ai/pr-agent');
expect(appTokenUsages).toHaveLength(2); expect(appTokenUsages).toHaveLength(2);
expect(workflow).toContain('OPENAI.API_BASE'); expect(workflow).toContain('OPENAI.API_BASE');
expect(workflow).toContain("github.event_name == 'pull_request_target' &&");
expect(workflow).toContain(
'contains(fromJSON(\'["COLLABORATOR","MEMBER","OWNER"]\'), github.event.pull_request.author_association)'
);
expect(workflow).toContain(
'contains(fromJSON(\'["COLLABORATOR","MEMBER","OWNER"]\'), github.event.issue.author_association)'
);
expect(workflow).toContain('github.event.issue.author_association');
expect(workflow).toContain('OPENAI_KEY'); expect(workflow).toContain('OPENAI_KEY');
expect(workflow).toContain('vars.AI_REVIEW_BASE_URL'); expect(workflow).toContain('vars.AI_REVIEW_BASE_URL');
expect(workflow).toContain('vars.AI_REVIEW_MODEL'); expect(workflow).toContain('vars.AI_REVIEW_MODEL');
@@ -9,6 +9,8 @@ function resolvePath(relativePath: string) {
describe('pr ci workflow', () => { describe('pr ci workflow', () => {
test('keeps full coverage on pull requests', () => { test('keeps full coverage on pull requests', () => {
const workflowPath = resolvePath('../../../../.github/workflows/ci.yml'); const workflowPath = resolvePath('../../../../.github/workflows/ci.yml');
const trustedAuthorGate =
'contains(fromJSON(\'["COLLABORATOR","MEMBER","OWNER"]\'), github.event.pull_request.author_association)';
expect(fs.existsSync(workflowPath)).toBe(true); expect(fs.existsSync(workflowPath)).toBe(true);
@@ -17,6 +19,7 @@ describe('pr ci workflow', () => {
expect(workflow).toContain('name: CI'); expect(workflow).toContain('name: CI');
expect(workflow).toContain('pull_request:'); expect(workflow).toContain('pull_request:');
expect(workflow).toContain('branches: [main, dev]'); expect(workflow).toContain('branches: [main, dev]');
expect(workflow.split(trustedAuthorGate).length - 1).toBe(3);
expect(workflow).toContain('group: ci-${{ github.ref }}'); expect(workflow).toContain('group: ci-${{ github.ref }}');
expect(workflow).toContain('cancel-in-progress: true'); expect(workflow).toContain('cancel-in-progress: true');
expect(workflow).toContain('fail-fast: false'); expect(workflow).toContain('fail-fast: false');
@@ -26,8 +26,15 @@ describe('dev release workflow', () => {
expect(workflow).toContain('branches: [dev]'); expect(workflow).toContain('branches: [dev]');
expect(workflow.match(/runs-on: \[self-hosted, linux, x64\]/g)).toHaveLength(2); expect(workflow.match(/runs-on: \[self-hosted, linux, x64\]/g)).toHaveLength(2);
expect(workflow).not.toContain('runs-on: ubuntu-latest'); expect(workflow).not.toContain('runs-on: ubuntu-latest');
expect(checkoutSection).toContain("token: ${{ secrets.PAT_TOKEN }}"); expect(checkoutSection).toContain('persist-credentials: false');
expect(checkoutSection).not.toContain('token: ${{ secrets.PAT_TOKEN }}');
expect(checkoutSection).not.toContain('token: ${{ github.token }}'); expect(checkoutSection).not.toContain('token: ${{ github.token }}');
expect(releaseSection).toContain('echo "::add-mask::${auth_header}"');
expect(releaseSection).toContain('GIT_CONFIG_COUNT=2');
expect(releaseSection).toContain('GIT_CONFIG_KEY_0=http.https://github.com/kaitranntt/ccs.extraheader');
expect(releaseSection).toContain('GIT_CONFIG_VALUE_0="AUTHORIZATION: basic ${auth_header}"');
expect(releaseSection).toContain('GIT_CONFIG_KEY_1=http.https://github.com/kaitranntt/ccs.git.extraheader');
expect(releaseSection).toContain('GIT_CONFIG_VALUE_1="AUTHORIZATION: basic ${auth_header}"');
expect(releaseSection).toContain('GITHUB_TOKEN: ${{ secrets.PAT_TOKEN }}'); expect(releaseSection).toContain('GITHUB_TOKEN: ${{ secrets.PAT_TOKEN }}');
expect(releaseSection).toContain('GH_TOKEN: ${{ secrets.PAT_TOKEN }}'); expect(releaseSection).toContain('GH_TOKEN: ${{ secrets.PAT_TOKEN }}');
expect(releaseSection).not.toContain('GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}'); expect(releaseSection).not.toContain('GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}');
@@ -7,7 +7,7 @@ function resolvePath(relativePath: string) {
} }
describe('stable release workflow', () => { describe('stable release workflow', () => {
test('uses the protected-branch runner and release token path', () => { test('uses the self-hosted runner and scoped release token path', () => {
const workflowPath = resolvePath('../../../../.github/workflows/release.yml'); const workflowPath = resolvePath('../../../../.github/workflows/release.yml');
expect(fs.existsSync(workflowPath)).toBe(true); expect(fs.existsSync(workflowPath)).toBe(true);
@@ -26,7 +26,14 @@ describe('stable release workflow', () => {
expect(workflow).toContain('branches: [main]'); expect(workflow).toContain('branches: [main]');
expect(workflow).toContain('runs-on: [self-hosted, linux, x64]'); expect(workflow).toContain('runs-on: [self-hosted, linux, x64]');
expect(workflow).not.toContain('runs-on: ubuntu-latest'); expect(workflow).not.toContain('runs-on: ubuntu-latest');
expect(checkoutSection).toContain('token: ${{ secrets.PAT_TOKEN }}'); expect(checkoutSection).toContain('persist-credentials: false');
expect(checkoutSection).not.toContain('token: ${{ secrets.PAT_TOKEN }}');
expect(releaseSection).toContain('echo "::add-mask::${auth_header}"');
expect(releaseSection).toContain('GIT_CONFIG_COUNT=2');
expect(releaseSection).toContain('GIT_CONFIG_KEY_0=http.https://github.com/kaitranntt/ccs.extraheader');
expect(releaseSection).toContain('GIT_CONFIG_VALUE_0="AUTHORIZATION: basic ${auth_header}"');
expect(releaseSection).toContain('GIT_CONFIG_KEY_1=http.https://github.com/kaitranntt/ccs.git.extraheader');
expect(releaseSection).toContain('GIT_CONFIG_VALUE_1="AUTHORIZATION: basic ${auth_header}"');
expect(releaseSection).toContain('GITHUB_TOKEN: ${{ secrets.PAT_TOKEN }}'); expect(releaseSection).toContain('GITHUB_TOKEN: ${{ secrets.PAT_TOKEN }}');
expect(releaseSection).toContain('GH_TOKEN: ${{ secrets.PAT_TOKEN }}'); expect(releaseSection).toContain('GH_TOKEN: ${{ secrets.PAT_TOKEN }}');
expect(releaseSection).toContain('NPM_TOKEN: ${{ secrets.NPM_TOKEN }}'); expect(releaseSection).toContain('NPM_TOKEN: ${{ secrets.NPM_TOKEN }}');
@@ -0,0 +1,104 @@
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');
}
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) {
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('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'
);
}
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');
}
});
});
@@ -7,7 +7,7 @@ function resolvePath(relativePath: string) {
} }
describe('sync dev after release workflow', () => { describe('sync dev after release workflow', () => {
test('uses protected-branch runner and token settings', () => { test('keeps protected-branch sync on self-hosted runners with scoped push credentials', () => {
const workflowPath = resolvePath('../../../../.github/workflows/sync-dev-after-release.yml'); const workflowPath = resolvePath('../../../../.github/workflows/sync-dev-after-release.yml');
expect(fs.existsSync(workflowPath)).toBe(true); expect(fs.existsSync(workflowPath)).toBe(true);
@@ -22,12 +22,18 @@ describe('sync dev after release workflow', () => {
expect(workflow).toContain('name: Sync Dev After Main Release'); expect(workflow).toContain('name: Sync Dev After Main Release');
expect(workflow).toContain('runs-on: [self-hosted, linux, x64]'); expect(workflow).toContain('runs-on: [self-hosted, linux, x64]');
expect(workflow).not.toContain('runs-on: ubuntu-latest'); expect(workflow).not.toContain('runs-on: ubuntu-latest');
expect(checkoutSection).toContain('token: ${{ secrets.PAT_TOKEN }}'); expect(checkoutSection).toContain('persist-credentials: false');
expect(checkoutSection).not.toContain('token: ${{ github.token }}'); expect(checkoutSection).not.toContain('token: ${{ secrets.PAT_TOKEN }}');
expect(syncSection).toContain( expect(syncSection).toContain(
'git merge origin/main --no-edit -m "chore(sync): merge main into dev after release"' 'git merge origin/main --no-edit -m "chore(sync): merge main into dev after release"'
); );
expect(syncSection).not.toContain('[skip ci]'); expect(syncSection).not.toContain('[skip ci]');
expect(syncSection).toContain('git push origin dev'); expect(workflow).toContain('git config --local core.hooksPath /dev/null');
expect(syncSection).toContain('PAT_TOKEN: ${{ secrets.PAT_TOKEN }}');
expect(syncSection).toContain('echo "::add-mask::${auth_header}"');
expect(syncSection).toContain('http.https://github.com/kaitranntt/ccs.extraheader');
expect(syncSection).toContain('http.https://github.com/kaitranntt/ccs.git.extraheader');
expect(syncSection).toContain('git -c core.hooksPath=/dev/null');
expect(syncSection).toContain('push origin dev');
}); });
}); });