From 2273757c837254edd2277c785530835d6db2a977 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Tue, 12 May 2026 12:34:15 -0400 Subject: [PATCH] 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. --- .github/workflows/ai-review.yml | 6 +- .github/workflows/ci.yml | 6 + .github/workflows/deploy-ccs-worker.yml | 5 +- .github/workflows/dev-release.yml | 16 ++- .github/workflows/docker-release.yml | 2 +- .github/workflows/label-pending-release.yml | 2 +- .github/workflows/publish-npm.yml.deprecated | 4 +- .github/workflows/release.yml | 14 ++- .../workflows/sync-ccs-backlog-project.yml | 2 +- .github/workflows/sync-dev-after-release.yml | 24 +++- CLAUDE.md | 7 ++ .../scripts/github/ai-review-workflow.test.ts | 10 +- tests/unit/scripts/github/ci-workflow.test.ts | 3 + .../github/dev-release-workflow.test.ts | 9 +- .../scripts/github/release-workflow.test.ts | 11 +- .../github/self-hosted-runner-policy.test.ts | 104 ++++++++++++++++++ .../sync-dev-after-release-workflow.test.ts | 14 ++- 17 files changed, 210 insertions(+), 29 deletions(-) create mode 100644 tests/unit/scripts/github/self-hosted-runner-policy.test.ts diff --git a/.github/workflows/ai-review.yml b/.github/workflows/ai-review.yml index 40e84de8..28b41d1f 100644 --- a/.github/workflows/ai-review.yml +++ b/.github/workflows/ai-review.yml @@ -74,10 +74,14 @@ jobs: if: >- 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.issue.pull_request && + contains(fromJSON('["COLLABORATOR","MEMBER","OWNER"]'), github.event.issue.author_association) && github.event.comment.body == '/review' && ( contains(fromJSON('["COLLABORATOR","MEMBER","OWNER"]'), github.event.comment.author_association) || diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3dba111b..598e2db3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,8 @@ concurrency: jobs: validate: + if: >- + contains(fromJSON('["COLLABORATOR","MEMBER","OWNER"]'), github.event.pull_request.author_association) runs-on: [self-hosted, linux, x64] env: # Keep Bun cache isolated per job workspace so parallel self-hosted runs @@ -58,6 +60,8 @@ jobs: run: ${{ matrix.check.cmd }} build: + if: >- + contains(fromJSON('["COLLABORATOR","MEMBER","OWNER"]'), github.event.pull_request.author_association) runs-on: [self-hosted, linux, x64] name: build env: @@ -98,6 +102,8 @@ jobs: if-no-files-found: error test: + if: >- + contains(fromJSON('["COLLABORATOR","MEMBER","OWNER"]'), github.event.pull_request.author_association) runs-on: [self-hosted, linux, x64] name: test needs: [build] diff --git a/.github/workflows/deploy-ccs-worker.yml b/.github/workflows/deploy-ccs-worker.yml index d08900c4..91962b26 100644 --- a/.github/workflows/deploy-ccs-worker.yml +++ b/.github/workflows/deploy-ccs-worker.yml @@ -21,7 +21,10 @@ on: jobs: 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 steps: diff --git a/.github/workflows/dev-release.yml b/.github/workflows/dev-release.yml index 56dc29c6..875a9cb6 100644 --- a/.github/workflows/dev-release.yml +++ b/.github/workflows/dev-release.yml @@ -50,9 +50,7 @@ jobs: with: fetch-depth: 0 ref: dev - # PAT_TOKEN required: dev-release pushes version bump commits to dev, - # and protected branches reject github.token writes once status checks are required. - token: ${{ secrets.PAT_TOKEN }} + persist-credentials: false - name: Setup Bun uses: oven-sh/setup-bun@v2 @@ -93,8 +91,16 @@ jobs: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | - # Use custom dev release script instead of semantic-release - # This ensures dev versions follow {stable}-dev.{N} pattern + 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}" + + # Use custom dev release script instead of semantic-release. + # This ensures dev versions follow {stable}-dev.{N} pattern. chmod +x scripts/dev-release.sh ./scripts/dev-release.sh diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index e665bbd8..f84abee9 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -18,7 +18,7 @@ concurrency: jobs: publish: if: ${{ github.event_name != 'release' || !github.event.release.prerelease }} - runs-on: ubuntu-latest + runs-on: [self-hosted, linux, x64] permissions: contents: read diff --git a/.github/workflows/label-pending-release.yml b/.github/workflows/label-pending-release.yml index dffe8a2d..7452f85d 100644 --- a/.github/workflows/label-pending-release.yml +++ b/.github/workflows/label-pending-release.yml @@ -7,7 +7,7 @@ on: jobs: label: - runs-on: ubuntu-latest + runs-on: [self-hosted, linux, x64] permissions: issues: write pull-requests: read diff --git a/.github/workflows/publish-npm.yml.deprecated b/.github/workflows/publish-npm.yml.deprecated index 20578673..61d04ae9 100644 --- a/.github/workflows/publish-npm.yml.deprecated +++ b/.github/workflows/publish-npm.yml.deprecated @@ -7,7 +7,7 @@ on: jobs: publish: - runs-on: ubuntu-latest + runs-on: [self-hosted, linux, x64] permissions: contents: write # For creating GitHub releases @@ -66,4 +66,4 @@ jobs: ## What's Changed See [CHANGELOG.md](https://github.com/kaitranntt/ccs/blob/main/CHANGELOG.md) env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bc4239be..57e60922 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,9 +21,7 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 - # PAT_TOKEN required: semantic-release pushes version bump commits to main, - # which needs branch protection bypass. github.token cannot push to protected branches. - token: ${{ secrets.PAT_TOKEN }} + persist-credentials: false - name: Setup Node.js uses: actions/setup-node@v4 @@ -58,12 +56,20 @@ jobs: env: HUSKY: 0 # 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 }} GH_TOKEN: ${{ secrets.PAT_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 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 OUTPUT=$(bun x semantic-release 2>&1) STATUS=$? diff --git a/.github/workflows/sync-ccs-backlog-project.yml b/.github/workflows/sync-ccs-backlog-project.yml index cd48a014..6328b72f 100644 --- a/.github/workflows/sync-ccs-backlog-project.yml +++ b/.github/workflows/sync-ccs-backlog-project.yml @@ -22,7 +22,7 @@ permissions: jobs: sync-project: - runs-on: ubuntu-latest + runs-on: [self-hosted, linux, x64] steps: - name: Checkout uses: actions/checkout@v4 diff --git a/.github/workflows/sync-dev-after-release.yml b/.github/workflows/sync-dev-after-release.yml index 0c0ced7d..793401f2 100644 --- a/.github/workflows/sync-dev-after-release.yml +++ b/.github/workflows/sync-dev-after-release.yml @@ -10,16 +10,20 @@ jobs: runs-on: [self-hosted, linux, x64] permissions: - contents: write + contents: read steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - # PAT_TOKEN is required because this pushes to protected dev and must - # trigger follow-up Push CI / Dev Release workflows. - token: ${{ secrets.PAT_TOKEN }} + persist-credentials: false + + - name: Harden Git workspace + run: | + rm -rf .git/hooks + mkdir -p .git/hooks + git config --local core.hooksPath /dev/null - name: Configure Git run: | @@ -47,4 +51,14 @@ jobs: git commit --no-edit 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 }} diff --git a/CLAUDE.md b/CLAUDE.md index 2ffd7892..cbdc918a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. - `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 Multi-provider profile and runtime manager for Claude Code, Factory Droid, diff --git a/tests/unit/scripts/github/ai-review-workflow.test.ts b/tests/unit/scripts/github/ai-review-workflow.test.ts index 55d3efb3..017112db 100644 --- a/tests/unit/scripts/github/ai-review-workflow.test.ts +++ b/tests/unit/scripts/github/ai-review-workflow.test.ts @@ -7,7 +7,7 @@ function resolvePath(relativePath: string) { } 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 prAgentConfigPath = resolvePath('../../../../.pr_agent.toml'); @@ -23,6 +23,14 @@ describe('PR-Agent review lane migration', () => { expect(workflow).toContain('uses: qodo-ai/pr-agent'); expect(appTokenUsages).toHaveLength(2); 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('vars.AI_REVIEW_BASE_URL'); expect(workflow).toContain('vars.AI_REVIEW_MODEL'); diff --git a/tests/unit/scripts/github/ci-workflow.test.ts b/tests/unit/scripts/github/ci-workflow.test.ts index a5319626..ae546e35 100644 --- a/tests/unit/scripts/github/ci-workflow.test.ts +++ b/tests/unit/scripts/github/ci-workflow.test.ts @@ -9,6 +9,8 @@ function resolvePath(relativePath: string) { describe('pr ci workflow', () => { test('keeps full coverage on pull requests', () => { 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); @@ -17,6 +19,7 @@ describe('pr ci workflow', () => { expect(workflow).toContain('name: CI'); expect(workflow).toContain('pull_request:'); expect(workflow).toContain('branches: [main, dev]'); + expect(workflow.split(trustedAuthorGate).length - 1).toBe(3); expect(workflow).toContain('group: ci-${{ github.ref }}'); expect(workflow).toContain('cancel-in-progress: true'); expect(workflow).toContain('fail-fast: false'); diff --git a/tests/unit/scripts/github/dev-release-workflow.test.ts b/tests/unit/scripts/github/dev-release-workflow.test.ts index e0f4b2cc..b61d79ff 100644 --- a/tests/unit/scripts/github/dev-release-workflow.test.ts +++ b/tests/unit/scripts/github/dev-release-workflow.test.ts @@ -26,8 +26,15 @@ describe('dev release workflow', () => { expect(workflow).toContain('branches: [dev]'); expect(workflow.match(/runs-on: \[self-hosted, linux, x64\]/g)).toHaveLength(2); 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(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('GH_TOKEN: ${{ secrets.PAT_TOKEN }}'); expect(releaseSection).not.toContain('GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}'); diff --git a/tests/unit/scripts/github/release-workflow.test.ts b/tests/unit/scripts/github/release-workflow.test.ts index fb3f592a..60d0d467 100644 --- a/tests/unit/scripts/github/release-workflow.test.ts +++ b/tests/unit/scripts/github/release-workflow.test.ts @@ -7,7 +7,7 @@ function resolvePath(relativePath: string) { } 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'); expect(fs.existsSync(workflowPath)).toBe(true); @@ -26,7 +26,14 @@ describe('stable release workflow', () => { expect(workflow).toContain('branches: [main]'); expect(workflow).toContain('runs-on: [self-hosted, linux, x64]'); 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('GH_TOKEN: ${{ secrets.PAT_TOKEN }}'); expect(releaseSection).toContain('NPM_TOKEN: ${{ secrets.NPM_TOKEN }}'); diff --git a/tests/unit/scripts/github/self-hosted-runner-policy.test.ts b/tests/unit/scripts/github/self-hosted-runner-policy.test.ts new file mode 100644 index 00000000..6ca9313b --- /dev/null +++ b/tests/unit/scripts/github/self-hosted-runner-policy.test.ts @@ -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'); + } + }); +}); diff --git a/tests/unit/scripts/github/sync-dev-after-release-workflow.test.ts b/tests/unit/scripts/github/sync-dev-after-release-workflow.test.ts index 004a6120..2245fab5 100644 --- a/tests/unit/scripts/github/sync-dev-after-release-workflow.test.ts +++ b/tests/unit/scripts/github/sync-dev-after-release-workflow.test.ts @@ -7,7 +7,7 @@ function resolvePath(relativePath: string) { } 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'); 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('runs-on: [self-hosted, linux, x64]'); expect(workflow).not.toContain('runs-on: ubuntu-latest'); - expect(checkoutSection).toContain('token: ${{ secrets.PAT_TOKEN }}'); - expect(checkoutSection).not.toContain('token: ${{ github.token }}'); + expect(checkoutSection).toContain('persist-credentials: false'); + expect(checkoutSection).not.toContain('token: ${{ secrets.PAT_TOKEN }}'); expect(syncSection).toContain( 'git merge origin/main --no-edit -m "chore(sync): merge main into dev after release"' ); 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'); }); });