From 9e875fbfa02cf447e0fec15f2259b3d3c73afe01 Mon Sep 17 00:00:00 2001 From: Adam Poit Date: Fri, 17 Jul 2026 23:18:43 -0700 Subject: [PATCH 1/5] Enforce workflow allowlist during composition --- docs/configuration.md | 29 ++++--- src/bootstrap.ts | 2 + src/cli.ts | 2 + src/config.ts | 28 +++++++ src/doctor.ts | 8 +- src/integration-sync.ts | 31 +++++++- src/promote-sync.ts | 27 +++++++ src/workflow-policy.ts | 115 ++++++++++++++++++++++++++++ tests/config.test.ts | 23 ++++++ tests/doctor.test.ts | 22 ++++++ tests/integration/sync.test.ts | 135 ++++++++++++++++++++++++++++++++- tests/workflow-policy.test.ts | 56 ++++++++++++++ 12 files changed, 463 insertions(+), 15 deletions(-) create mode 100644 src/workflow-policy.ts create mode 100644 tests/workflow-policy.test.ts diff --git a/docs/configuration.md b/docs/configuration.md index 062d1fe..d1dd9ab 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -15,17 +15,24 @@ patchRefs: - patch/ci - patch/product ciWorkflow: CI +allowedWorkflows: + - ci.yml + - promote-tested-sync.yml + - sync-upstream.yml ``` -| Field | Required | Description | -| ------------ | ----------- | ----------------------------------------------------------------- | -| `version` | yes | Configuration schema version; currently `1` | -| `upstream` | yes | GitHub repository in `owner/repo` form | -| `source` | yes | Explicit release or branch source | -| `baseBranch` | no | Generated branch promoted after successful CI; defaults to `main` | -| `syncBranch` | no | Generated branch published for CI; defaults to `sync/integration` | -| `patchRefs` | yes | Independent patch branches applied in order | -| `ciWorkflow` | recommended | Exact existing CI workflow name used by `workflow_run` | +| Field | Required | Description | +| ------------------ | ----------- | ----------------------------------------------------------------- | +| `version` | yes | Configuration schema version; currently `1` | +| `upstream` | yes | GitHub repository in `owner/repo` form | +| `source` | yes | Explicit release or branch source | +| `baseBranch` | no | Generated branch promoted after successful CI; defaults to `main` | +| `syncBranch` | no | Generated branch published for CI; defaults to `sync/integration` | +| `patchRefs` | yes | Independent patch branches applied in order | +| `ciWorkflow` | recommended | Exact existing CI workflow name used by `workflow_run` | +| `allowedWorkflows` | no | Exact workflow filenames permitted in the composed tree | + +When `allowedWorkflows` is configured, doctor, every sync mode, and promotion reject unexpected or missing workflow files and dangling local reusable-workflow references. Sync validates after all patches are composed and before publishing `syncBranch`; promotion validates the exact `EXPECTED_SYNC_SHA`. Omitting the field preserves the existing behavior. Supported sources: @@ -57,7 +64,7 @@ npx patchlane doctor npx patchlane doctor --json ``` -Doctor checks source resolution, remote patch refs, patch bases, composed workflow configuration, CI triggers, permissions, and bootstrap state without changing repository state. +Doctor checks source resolution, remote patch refs, patch bases, composed workflow configuration and policy, CI triggers, permissions, and bootstrap state without changing repository state. ### Validate or publish a sync @@ -134,4 +141,4 @@ Patchlane writes these outputs when `GITHUB_OUTPUT` is available: - `failed_commit` - `conflicted_paths` -Sync status can be `dry_run`, `no_push`, `published`, `unchanged`, `missing_patch`, `conflicted`, `invalid_patch`, or `invalid_patch_base`. +Sync status can be `dry_run`, `no_push`, `published`, `unchanged`, `missing_patch`, `conflicted`, `invalid_patch`, `invalid_patch_base`, or `workflow_policy`. diff --git a/src/bootstrap.ts b/src/bootstrap.ts index ffc694a..fd3ac77 100644 --- a/src/bootstrap.ts +++ b/src/bootstrap.ts @@ -141,6 +141,7 @@ export async function bootstrapPatchlane(options: BootstrapOptions = {}) { upstreamOwner: config.upstreamOwner, upstreamRepo: config.upstreamRepo, patchRefs: config.patchRefs.join(','), + allowedWorkflows: config.allowedWorkflows, baseBranch: config.baseBranch, source: config.source, syncBranch: config.syncBranch, @@ -203,6 +204,7 @@ export async function bootstrapPatchlane(options: BootstrapOptions = {}) { runPromoteSync({ expectedSyncSha: syncSha, + allowedWorkflows: config.allowedWorkflows, baseBranch: config.baseBranch, syncBranch: config.syncBranch, originRemoteName, diff --git a/src/cli.ts b/src/cli.ts index 34968e2..2e972fb 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -172,6 +172,7 @@ cli.command('sync', 'Rebuild integration branch from upstream and patches') upstreamOwner: args.upstreamOwner, upstreamRepo: args.upstreamRepo, patchRefs: args.patchRefs, + allowedWorkflows: config?.allowedWorkflows, baseBranch: args.baseBranch, source: args.source, upstreamRef: args.upstreamRef, @@ -209,6 +210,7 @@ cli.command('promote', 'Promote tested sync branch onto base branch') runPromoteSync({ expectedSyncSha: args.expectedSyncSha, + allowedWorkflows: config?.allowedWorkflows, baseBranch: args.baseBranch, syncBranch: args.syncBranch, originRemoteName: args.originRemoteName, diff --git a/src/config.ts b/src/config.ts index ef6654e..297545b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -13,6 +13,7 @@ export type PatchlaneConfig = { syncBranch: string; patchRefs: string[]; ciWorkflow?: string; + allowedWorkflows?: string[]; }; function isPlainObject(value: unknown): value is Record { @@ -58,6 +59,31 @@ export function parsePatchlaneConfig(value: unknown): PatchlaneConfig { throw new Error("Patchlane config field 'ciWorkflow' must be a non-empty string when provided."); } + const rawAllowedWorkflows = value.allowedWorkflows; + let allowedWorkflows: string[] | undefined; + if (rawAllowedWorkflows !== undefined) { + if (!Array.isArray(rawAllowedWorkflows)) { + throw new Error("Patchlane config field 'allowedWorkflows' must be an array when provided."); + } + allowedWorkflows = rawAllowedWorkflows.map((workflow) => { + if (typeof workflow !== 'string' || !workflow.trim()) { + throw new Error( + "Patchlane config field 'allowedWorkflows' must contain only non-empty workflow filenames.", + ); + } + const filename = workflow.trim(); + if (path.basename(filename) !== filename || filename.includes('\\') || !/\.ya?ml$/.test(filename)) { + throw new Error( + "Patchlane config field 'allowedWorkflows' must contain filenames ending in .yml or .yaml.", + ); + } + return filename; + }); + if (new Set(allowedWorkflows).size !== allowedWorkflows.length) { + throw new Error("Patchlane config field 'allowedWorkflows' must not contain duplicate filenames."); + } + } + return { upstreamOwner, upstreamRepo, @@ -69,6 +95,7 @@ export function parsePatchlaneConfig(value: unknown): PatchlaneConfig { : 'sync/integration', patchRefs, ciWorkflow: typeof ciWorkflow === 'string' ? ciWorkflow.trim() : undefined, + allowedWorkflows, }; } @@ -81,6 +108,7 @@ export function serializePatchlaneConfig(config: PatchlaneConfig) { syncBranch: config.syncBranch, patchRefs: config.patchRefs, ...(config.ciWorkflow ? { ciWorkflow: config.ciWorkflow } : {}), + ...(config.allowedWorkflows !== undefined ? { allowedWorkflows: config.allowedWorkflows } : {}), }); } diff --git a/src/doctor.ts b/src/doctor.ts index 01570f5..d953c41 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -5,6 +5,7 @@ import { spawnSync } from 'node:child_process'; import { parse } from 'yaml'; import { loadPatchlaneConfig, type PatchlaneConfig } from './config.js'; import { parseUpstreamSource } from './upstream-source.js'; +import { validateWorkflowPolicy } from './workflow-policy.js'; export type DoctorCheck = { severity: 'error' | 'warning' | 'info'; @@ -187,7 +188,9 @@ function workflowFiles(config: PatchlaneConfig, sourceSha: string | undefined, c .filter((file) => /\.ya?ml$/.test(file)); return files.flatMap((relativePath) => { const content = git(['show', `:${relativePath}`], cwd, { env: indexEnv, trimOutput: false }); - return content.status === 0 ? [{ file: path.basename(relativePath), content: content.stdout }] : []; + return content.status === 0 + ? [{ file: relativePath.slice('.github/workflows/'.length), content: content.stdout }] + : []; }); } finally { rmSync(tempDir, { force: true, recursive: true }); @@ -233,6 +236,9 @@ function hasWriteContents(workflow: Record) { function inspectWorkflows(config: PatchlaneConfig, sourceSha: string | undefined, cwd: string, checks: DoctorCheck[]) { const files = workflowFiles(config, sourceSha, cwd); + for (const violation of validateWorkflowPolicy(config.allowedWorkflows, files)) { + checks.push({ severity: 'error', message: violation.message }); + } const parsed = files.map((file) => ({ ...file, workflow: readWorkflow(file.content) })); const sync = parsed.find((file) => file.file === 'sync-upstream.yml'); const promotion = parsed.find((file) => file.file === 'promote-tested-sync.yml'); diff --git a/src/integration-sync.ts b/src/integration-sync.ts index 84c9b6c..839c491 100644 --- a/src/integration-sync.ts +++ b/src/integration-sync.ts @@ -1,9 +1,10 @@ -import { appendFileSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { appendFileSync, mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { resolveUpstreamSource } from './upstream-source.js'; +import { validateWorkflowPolicy, workflowFilesAtRef } from './workflow-policy.js'; type RunOptions = { cwd?: string; @@ -206,6 +207,7 @@ export type IntegrationSyncOptions = { upstreamOwner: string; upstreamRepo: string; patchRefs: string; + allowedWorkflows?: string[]; baseBranch?: string; source?: string; upstreamRef?: string; @@ -262,6 +264,27 @@ export function runIntegrationSync(options: IntegrationSyncOptions) { const upstreamRemoteUrl = options.upstreamRemoteUrl ?? `https://github.com/${upstreamOwner}/${upstreamRepo}.git`; const remoteSyncRef = `refs/remotes/${originRemoteName}/${syncBranch}`; + function enforceWorkflowPolicy(targetCwd: string, rebuiltSyncSha: string) { + if (options.allowedWorkflows === undefined) return; + const violations = validateWorkflowPolicy( + options.allowedWorkflows, + workflowFilesAtRef(targetCwd, rebuiltSyncSha), + ); + if (!violations.length) return; + + writeOutput('sync_sha', ''); + writeOutput('status', 'workflow_policy'); + writeSummary( + '## Integration rebuild blocked', + [ + `- Composed SHA: \`${rebuiltSyncSha}\``, + '- Reason: the composed workflow tree violates allowedWorkflows.', + ].join('\n'), + `### Workflow policy violations\n\n${violations.map(({ message }) => `- ${message}`).join('\n')}`, + ); + fail(`Workflow policy violation: ${violations[0]!.message}`); + } + const existingUpstream = git(['remote', 'get-url', upstreamRemoteName], { allowFailure: true, }); @@ -609,7 +632,8 @@ export function runIntegrationSync(options: IntegrationSyncOptions) { const worktreeDir = mkdtempSync(path.join(tmpdir(), 'patchlane-dry-run-')); try { git(['worktree', 'add', '--detach', worktreeDir, upstreamBase]); - const { appliedRefs, patchDiagnostics } = applyAllPatches(worktreeDir, true); + const { appliedRefs, patchDiagnostics, rebuiltSyncSha } = applyAllPatches(worktreeDir, true); + enforceWorkflowPolicy(worktreeDir, rebuiltSyncSha); writeOutput('failed_bookmark', ''); writeOutput('failed_commit', ''); @@ -640,6 +664,7 @@ export function runIntegrationSync(options: IntegrationSyncOptions) { log(`Building ${syncBranch} from ${sourceLabel}`); git(['checkout', '-B', syncBranch, upstreamBase]); const { appliedRefs, patchDiagnostics, rebuiltSyncSha } = applyAllPatches(process.cwd(), false); + enforceWorkflowPolicy(process.cwd(), rebuiltSyncSha); writeOutput('failed_bookmark', ''); writeOutput('failed_commit', ''); @@ -727,6 +752,8 @@ function main() { upstreamOwner: requireEnv('UPSTREAM_OWNER'), upstreamRepo: requireEnv('UPSTREAM_REPO'), patchRefs: requireEnv('PATCH_REFS'), + allowedWorkflows: + process.env.ALLOWED_WORKFLOWS === undefined ? undefined : parsePatchRefs(process.env.ALLOWED_WORKFLOWS), baseBranch: getEnv('BASE_BRANCH', 'main'), upstreamRef: getEnv('UPSTREAM_REF'), source: getEnv('UPSTREAM_SOURCE'), diff --git a/src/promote-sync.ts b/src/promote-sync.ts index c530567..0335198 100644 --- a/src/promote-sync.ts +++ b/src/promote-sync.ts @@ -1,6 +1,7 @@ import { appendFileSync } from 'node:fs'; import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; +import { validateWorkflowPolicy, workflowFilesAtRef } from './workflow-policy.js'; type RunOptions = { allowFailure?: boolean; @@ -82,6 +83,7 @@ export type PromoteSyncOptions = { baseBranch?: string; syncBranch?: string; expectedSyncSha: string; + allowedWorkflows?: string[]; originRemoteName?: string; }; @@ -120,6 +122,25 @@ export function runPromoteSync(options: PromoteSyncOptions) { fail(`Refusing to promote ${syncBranch}; expected tested SHA ${expectedSyncSha}.`); } + const workflowViolations = + options.allowedWorkflows === undefined + ? [] + : validateWorkflowPolicy(options.allowedWorkflows, workflowFilesAtRef(process.cwd(), expectedSyncSha)); + if (workflowViolations.length) { + writeOutput('promoted_sha', ''); + writeOutput('status', 'workflow_policy'); + writeSummary( + '## Integration promotion blocked', + [ + `- Sync branch: \`${syncBranch}\``, + `- Tested SHA: \`${expectedSyncSha}\``, + '- Reason: the tested workflow tree violates allowedWorkflows.', + ...workflowViolations.map(({ message }) => `- ${message}`), + ].join('\n'), + ); + fail(`Workflow policy violation at tested SHA ${expectedSyncSha}: ${workflowViolations[0]!.message}`); + } + const baseLease = git(['rev-parse', `refs/remotes/${originRemoteName}/${baseBranch}`]).stdout.trim(); log(`Promoting ${syncBranch}@${expectedSyncSha} onto ${baseBranch}`); const promote = git( @@ -168,6 +189,12 @@ function main() { baseBranch: getEnv('BASE_BRANCH', 'main'), syncBranch: getEnv('SYNC_BRANCH', 'sync/integration'), expectedSyncSha: requireEnv('EXPECTED_SYNC_SHA'), + allowedWorkflows: + process.env.ALLOWED_WORKFLOWS === undefined + ? undefined + : process.env.ALLOWED_WORKFLOWS.split(/\r?\n|,/) + .map((workflow) => workflow.trim()) + .filter(Boolean), originRemoteName: getEnv('ORIGIN_REMOTE_NAME', 'origin'), }); } diff --git a/src/workflow-policy.ts b/src/workflow-policy.ts new file mode 100644 index 0000000..9f19696 --- /dev/null +++ b/src/workflow-policy.ts @@ -0,0 +1,115 @@ +import { spawnSync } from 'node:child_process'; +import { parse } from 'yaml'; + +const WORKFLOW_DIRECTORY = '.github/workflows'; +const LOCAL_WORKFLOW_PREFIX = `./${WORKFLOW_DIRECTORY}/`; + +export type WorkflowFile = { + file: string; + content: string; +}; + +export type WorkflowPolicyViolation = { + message: string; +}; + +function localWorkflowReferences(contents: string) { + let workflow: unknown; + try { + workflow = parse(contents) as unknown; + } catch { + return []; + } + + const references = new Set(); + const visited = new WeakSet(); + + function visit(value: unknown) { + if (typeof value !== 'object' || value === null || visited.has(value)) return; + visited.add(value); + + if (Array.isArray(value)) { + for (const item of value) visit(item); + return; + } + + for (const [key, child] of Object.entries(value)) { + if (key === 'uses' && typeof child === 'string' && child.startsWith(LOCAL_WORKFLOW_PREFIX)) { + const target = child.slice(LOCAL_WORKFLOW_PREFIX.length); + if (/\.ya?ml$/.test(target)) references.add(target); + } + visit(child); + } + } + + visit(workflow); + return [...references].sort(); +} + +export function validateWorkflowPolicy( + allowedWorkflows: string[] | undefined, + workflowFiles: WorkflowFile[], +): WorkflowPolicyViolation[] { + if (allowedWorkflows === undefined) return []; + + const allowed = new Set(allowedWorkflows); + const actual = new Set(workflowFiles.map(({ file }) => file)); + const violations: WorkflowPolicyViolation[] = []; + + for (const file of [...actual].sort()) { + if (!allowed.has(file)) { + violations.push({ + message: `Unexpected workflow '${WORKFLOW_DIRECTORY}/${file}' is not in allowedWorkflows.`, + }); + } + } + + for (const file of [...allowed].sort()) { + if (!actual.has(file)) { + violations.push({ + message: `Allowed workflow '${WORKFLOW_DIRECTORY}/${file}' is missing from the composed tree.`, + }); + } + } + + for (const { file, content } of [...workflowFiles].sort((left, right) => left.file.localeCompare(right.file))) { + for (const target of localWorkflowReferences(content)) { + if (!actual.has(target)) { + violations.push({ + message: `Workflow '${WORKFLOW_DIRECTORY}/${file}' references missing local reusable workflow '${WORKFLOW_DIRECTORY}/${target}'.`, + }); + } else if (!allowed.has(target)) { + violations.push({ + message: `Workflow '${WORKFLOW_DIRECTORY}/${file}' references disallowed local reusable workflow '${WORKFLOW_DIRECTORY}/${target}'.`, + }); + } + } + } + + return violations; +} + +export function workflowFilesAtRef(cwd: string, ref: string): WorkflowFile[] { + const listed = spawnSync('git', ['ls-tree', '-r', '--name-only', ref, '--', WORKFLOW_DIRECTORY], { + cwd, + encoding: 'utf8', + }); + if (listed.error || listed.status !== 0) { + throw new Error( + `Could not inspect workflows at ${ref}: ${(listed.error?.message ?? listed.stderr.trim()) || 'git ls-tree failed'}`, + ); + } + + return listed.stdout + .split(/\r?\n/) + .filter((file) => file.startsWith(`${WORKFLOW_DIRECTORY}/`) && /\.ya?ml$/.test(file)) + .map((file) => { + const shown = spawnSync('git', ['show', `${ref}:${file}`], { cwd, encoding: 'utf8' }); + if (shown.error || shown.status !== 0) { + throw new Error( + `Could not read workflow '${file}' at ${ref}: ${(shown.error?.message ?? shown.stderr.trim()) || 'git show failed'}`, + ); + } + return { file: file.slice(WORKFLOW_DIRECTORY.length + 1), content: shown.stdout }; + }); +} diff --git a/tests/config.test.ts b/tests/config.test.ts index ad6875b..3c290ca 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -18,9 +18,32 @@ test('parses Patchlane configuration', () => { syncBranch: 'sync/integration', patchRefs: ['patch/sync', 'patch/ci'], ciWorkflow: 'CI', + allowedWorkflows: undefined, }); }); +test('parses and validates allowed workflow filenames', () => { + expect( + parsePatchlaneConfig({ + version: 1, + upstream: 'example/upstream', + source: 'branch:main', + patchRefs: ['patch/sync'], + allowedWorkflows: ['ci.yml', 'sync-upstream.yaml'], + }).allowedWorkflows, + ).toEqual(['ci.yml', 'sync-upstream.yaml']); + + expect(() => + parsePatchlaneConfig({ + version: 1, + upstream: 'example/upstream', + source: 'branch:main', + patchRefs: ['patch/sync'], + allowedWorkflows: ['../ci.yml'], + }), + ).toThrow(/filenames/); +}); + test('rejects incomplete Patchlane configuration', () => { expect(() => parsePatchlaneConfig({ version: 1 })).toThrow(/upstream/); expect(() => diff --git a/tests/doctor.test.ts b/tests/doctor.test.ts index 0895a4a..bcd56f4 100644 --- a/tests/doctor.test.ts +++ b/tests/doctor.test.ts @@ -88,6 +88,7 @@ test('composes non-overlapping changes to the same workflow from independent pat 'source: branch:main', 'patchRefs: [patch/sync, patch/ci, patch/product, patch/product-workflow]', 'ciWorkflow: Product CI', + 'allowedWorkflows: [ci.yml, promote-tested-sync.yml, sync-upstream.yml]', '', ].join('\n'), ); @@ -97,6 +98,27 @@ test('composes non-overlapping changes to the same workflow from independent pat expect(report.checks).not.toContainEqual( expect.objectContaining({ message: expect.stringContaining('must run on pushes') }), ); + + writeFileSync( + path.join(forkWork, '.patchlane.yml'), + [ + 'version: 1', + 'upstream: example/upstream', + 'source: branch:main', + 'patchRefs: [patch/sync, patch/ci, patch/product, patch/product-workflow]', + 'ciWorkflow: Product CI', + 'allowedWorkflows: [missing.yml, promote-tested-sync.yml, sync-upstream.yml]', + '', + ].join('\n'), + ); + const deniedReport = runDoctor({ cwd: forkWork, json: true }); + expect(deniedReport.ok).toBe(false); + expect(deniedReport.checks).toContainEqual( + expect.objectContaining({ severity: 'error', message: expect.stringContaining('ci.yml') }), + ); + expect(deniedReport.checks).toContainEqual( + expect.objectContaining({ severity: 'error', message: expect.stringContaining('missing.yml') }), + ); } finally { rmSync(tempRoot, { force: true, recursive: true }); } diff --git a/tests/integration/sync.test.ts b/tests/integration/sync.test.ts index 556d797..5b6383f 100644 --- a/tests/integration/sync.test.ts +++ b/tests/integration/sync.test.ts @@ -128,6 +128,7 @@ function runSync( allowDependentPatches = false, dryRun = false, forcePush = false, + allowedWorkflows?: string, ) { const launcherDir = mkdtempSync(path.join(tmpdir(), 'patchlane-gh-')); createLauncher(launcherDir); @@ -150,6 +151,7 @@ function runSync( FORCE_PUSH: forcePush ? 'true' : 'false', UPSTREAM_REMOTE_URL: upstreamRemoteUrl, ALLOW_DEPENDENT_PATCHES: allowDependentPatches ? 'true' : 'false', + ...(allowedWorkflows === undefined ? {} : { ALLOWED_WORKFLOWS: allowedWorkflows }), }; const result = run('node', [cliPath], worktree, env); @@ -157,7 +159,13 @@ function runSync( return result; } -function runPromote(worktree: string, outputFile: string, summaryFile: string, expectedSha: string) { +function runPromote( + worktree: string, + outputFile: string, + summaryFile: string, + expectedSha: string, + allowedWorkflows?: string, +) { const env = { ...process.env, GITHUB_OUTPUT: outputFile, @@ -165,6 +173,7 @@ function runPromote(worktree: string, outputFile: string, summaryFile: string, e BASE_BRANCH: 'main', SYNC_BRANCH: 'sync/integration', EXPECTED_SYNC_SHA: expectedSha, + ...(allowedWorkflows === undefined ? {} : { ALLOWED_WORKFLOWS: allowedWorkflows }), }; return run('node', [promoteCliPath], worktree, env); @@ -1634,3 +1643,127 @@ test('integration sync CLI handles workflow file deletions and additions cleanly rmSync(tempRoot, { force: true, recursive: true }); } }); + +test('workflow policy blocks dry-run, publishing, and promotion without changing generated refs', () => { + const tempRoot = mkdtempSync(path.join(tmpdir(), 'patchlane-workflow-policy-')); + try { + const stateDir = path.join(tempRoot, 'gh-state'); + mkdirSync(stateDir, { recursive: true }); + writeFileSync(path.join(stateDir, 'prs.json'), '[]\n'); + + const upstreamBare = path.join(tempRoot, 'upstream.git'); + const forkBare = path.join(tempRoot, 'fork.git'); + const upstreamWork = path.join(tempRoot, 'upstream-work'); + const forkSeed = path.join(tempRoot, 'fork-seed'); + const firstWork = path.join(tempRoot, 'first-work'); + const blockedWork = path.join(tempRoot, 'blocked-work'); + const promoteWork = path.join(tempRoot, 'promote-work'); + + git(['init', '--bare', '--initial-branch=main', upstreamBare], tempRoot); + git(['clone', upstreamBare, upstreamWork], tempRoot); + configureUser(upstreamWork); + mkdirSync(path.join(upstreamWork, '.github', 'workflows'), { recursive: true }); + writeFileSync(path.join(upstreamWork, '.github', 'workflows', 'ci.yml'), 'name: CI\n'); + writeFileSync(path.join(upstreamWork, 'README.md'), '# Upstream\n'); + git(['add', '.'], upstreamWork); + git(['commit', '-m', 'Initial upstream'], upstreamWork); + git(['push', 'origin', 'main'], upstreamWork); + + git(['init', '--bare', '--initial-branch=main', forkBare], tempRoot); + git(['clone', upstreamBare, forkSeed], tempRoot); + configureUser(forkSeed); + git(['remote', 'rename', 'origin', 'upstream'], forkSeed); + git(['remote', 'add', 'origin', forkBare], forkSeed); + git(['push', 'origin', 'main'], forkSeed); + createPatchBranch(forkSeed, 'patch/product', 'upstream/main', 'PRODUCT.txt', 'product patch'); + + git(['clone', forkBare, firstWork], tempRoot); + configureUser(firstWork); + const firstOut = path.join(tempRoot, 'first.out'); + const firstRun = runSync( + firstWork, + stateDir, + firstOut, + path.join(tempRoot, 'first.summary'), + 'patch/product', + 'main', + '', + false, + upstreamBare, + false, + false, + false, + 'ci.yml', + ); + expectSuccess(firstRun); + const publishedSha = readOutput(firstOut, 'sync_sha'); + + writeFileSync(path.join(upstreamWork, '.github', 'workflows', 'release.yml'), 'name: Release\n'); + git(['add', '.github/workflows/release.yml'], upstreamWork); + git(['commit', '-m', 'Add upstream release workflow'], upstreamWork); + git(['push', 'origin', 'main'], upstreamWork); + + git(['clone', forkBare, blockedWork], tempRoot); + configureUser(blockedWork); + const dryRunOut = path.join(tempRoot, 'dry-run.out'); + const dryRun = runSync( + blockedWork, + stateDir, + dryRunOut, + path.join(tempRoot, 'dry-run.summary'), + 'patch/product', + 'main', + '', + false, + upstreamBare, + false, + true, + false, + 'ci.yml', + ); + expect(dryRun.status).not.toBe(0); + expect(readOutput(dryRunOut, 'status')).toBe('workflow_policy'); + expect(dryRun.stderr).toContain('release.yml'); + + const blockedOut = path.join(tempRoot, 'blocked.out'); + const blocked = runSync( + blockedWork, + stateDir, + blockedOut, + path.join(tempRoot, 'blocked.summary'), + 'patch/product', + 'main', + '', + false, + upstreamBare, + false, + false, + false, + 'ci.yml', + ); + expect(blocked.status).not.toBe(0); + expect(readOutput(blockedOut, 'status')).toBe('workflow_policy'); + git(['fetch', 'origin', 'sync/integration'], forkSeed); + expect(git(['rev-parse', 'refs/remotes/origin/sync/integration'], forkSeed)).toBe(publishedSha); + + git(['fetch', 'upstream', 'main'], forkSeed); + git(['push', '--force', 'origin', 'upstream/main:refs/heads/sync/integration'], forkSeed); + const unvalidatedSha = git(['rev-parse', 'upstream/main'], forkSeed); + git(['clone', forkBare, promoteWork], tempRoot); + configureUser(promoteWork); + const promoteOut = path.join(tempRoot, 'promote.out'); + const promote = runPromote( + promoteWork, + promoteOut, + path.join(tempRoot, 'promote.summary'), + unvalidatedSha, + 'ci.yml', + ); + expect(promote.status).not.toBe(0); + expect(readOutput(promoteOut, 'status')).toBe('workflow_policy'); + git(['fetch', 'origin', 'main'], forkSeed); + expect(remoteHasPath(forkSeed, 'refs/remotes/origin/main', '.github/workflows/release.yml')).toBe(false); + } finally { + rmSync(tempRoot, { force: true, recursive: true }); + } +}); diff --git a/tests/workflow-policy.test.ts b/tests/workflow-policy.test.ts new file mode 100644 index 0000000..15c10eb --- /dev/null +++ b/tests/workflow-policy.test.ts @@ -0,0 +1,56 @@ +import { expect, test } from 'vitest'; +import { validateWorkflowPolicy } from '../src/workflow-policy.js'; + +test('accepts an exact composed workflow allowlist', () => { + const violations = validateWorkflowPolicy( + ['ci.yml', 'promote-tested-sync.yml', 'sync-upstream.yml'], + [ + { file: 'sync-upstream.yml', content: 'name: Sync\n' }, + { file: 'ci.yml', content: 'name: CI\n' }, + { file: 'promote-tested-sync.yml', content: 'name: Promote\n' }, + ], + ); + + expect(violations).toEqual([]); +}); + +test('reports added, deleted, and dangling reusable workflows in a composed tree', () => { + const violations = validateWorkflowPolicy( + ['ci.yml', 'deleted.yml', 'reusable.yml'], + [ + { + file: 'ci.yml', + content: [ + 'name: CI', + 'jobs:', + ' allowed:', + ' uses: ./.github/workflows/reusable.yml', + ' missing:', + ' uses: ./.github/workflows/missing.yml', + ' disallowed:', + ' uses: ./.github/workflows/unexpected.yml', + ].join('\n'), + }, + { file: 'reusable.yml', content: 'name: Reusable\n' }, + { file: 'unexpected.yml', content: 'name: Unexpected\n' }, + ], + ); + + expect(violations.map(({ message }) => message)).toEqual([ + "Unexpected workflow '.github/workflows/unexpected.yml' is not in allowedWorkflows.", + "Allowed workflow '.github/workflows/deleted.yml' is missing from the composed tree.", + "Workflow '.github/workflows/ci.yml' references missing local reusable workflow '.github/workflows/missing.yml'.", + "Workflow '.github/workflows/ci.yml' references disallowed local reusable workflow '.github/workflows/unexpected.yml'.", + ]); +}); + +test('preserves existing behavior when allowedWorkflows is omitted', () => { + expect( + validateWorkflowPolicy(undefined, [ + { + file: 'release.yml', + content: 'jobs:\n missing:\n uses: ./.github/workflows/missing.yml\n', + }, + ]), + ).toEqual([]); +}); From 3d8a98001971686be6591e291125ff95c497a511 Mon Sep 17 00:00:00 2001 From: Adam Poit Date: Fri, 17 Jul 2026 23:44:01 -0700 Subject: [PATCH 2/5] Implicitly allow Patchlane generated workflows --- docs/configuration.md | 6 ++---- src/workflow-policy.ts | 3 ++- tests/doctor.test.ts | 4 ++-- tests/integration/sync.test.ts | 2 ++ tests/workflow-policy.test.ts | 18 +++++++++++++++++- 5 files changed, 25 insertions(+), 8 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index d1dd9ab..1b2746f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -17,8 +17,6 @@ patchRefs: ciWorkflow: CI allowedWorkflows: - ci.yml - - promote-tested-sync.yml - - sync-upstream.yml ``` | Field | Required | Description | @@ -30,9 +28,9 @@ allowedWorkflows: | `syncBranch` | no | Generated branch published for CI; defaults to `sync/integration` | | `patchRefs` | yes | Independent patch branches applied in order | | `ciWorkflow` | recommended | Exact existing CI workflow name used by `workflow_run` | -| `allowedWorkflows` | no | Exact workflow filenames permitted in the composed tree | +| `allowedWorkflows` | no | Repository workflow filenames permitted alongside Patchlane's | -When `allowedWorkflows` is configured, doctor, every sync mode, and promotion reject unexpected or missing workflow files and dangling local reusable-workflow references. Sync validates after all patches are composed and before publishing `syncBranch`; promotion validates the exact `EXPECTED_SYNC_SHA`. Omitting the field preserves the existing behavior. +When `allowedWorkflows` is configured, Patchlane implicitly adds its generated `sync-upstream.yml` and `promote-tested-sync.yml` workflows to the allowlist. Configure only repository-specific workflows such as CI. Doctor, every sync mode, and promotion then reject unexpected or missing workflow files and dangling local reusable-workflow references. Sync validates after all patches are composed and before publishing `syncBranch`; promotion validates the exact `EXPECTED_SYNC_SHA`. Omitting the field preserves the existing behavior. Supported sources: diff --git a/src/workflow-policy.ts b/src/workflow-policy.ts index 9f19696..2493672 100644 --- a/src/workflow-policy.ts +++ b/src/workflow-policy.ts @@ -3,6 +3,7 @@ import { parse } from 'yaml'; const WORKFLOW_DIRECTORY = '.github/workflows'; const LOCAL_WORKFLOW_PREFIX = `./${WORKFLOW_DIRECTORY}/`; +export const PATCHLANE_GENERATED_WORKFLOWS = ['promote-tested-sync.yml', 'sync-upstream.yml'] as const; export type WorkflowFile = { file: string; @@ -52,7 +53,7 @@ export function validateWorkflowPolicy( ): WorkflowPolicyViolation[] { if (allowedWorkflows === undefined) return []; - const allowed = new Set(allowedWorkflows); + const allowed = new Set([...PATCHLANE_GENERATED_WORKFLOWS, ...allowedWorkflows]); const actual = new Set(workflowFiles.map(({ file }) => file)); const violations: WorkflowPolicyViolation[] = []; diff --git a/tests/doctor.test.ts b/tests/doctor.test.ts index bcd56f4..ac28816 100644 --- a/tests/doctor.test.ts +++ b/tests/doctor.test.ts @@ -88,7 +88,7 @@ test('composes non-overlapping changes to the same workflow from independent pat 'source: branch:main', 'patchRefs: [patch/sync, patch/ci, patch/product, patch/product-workflow]', 'ciWorkflow: Product CI', - 'allowedWorkflows: [ci.yml, promote-tested-sync.yml, sync-upstream.yml]', + 'allowedWorkflows: [ci.yml]', '', ].join('\n'), ); @@ -107,7 +107,7 @@ test('composes non-overlapping changes to the same workflow from independent pat 'source: branch:main', 'patchRefs: [patch/sync, patch/ci, patch/product, patch/product-workflow]', 'ciWorkflow: Product CI', - 'allowedWorkflows: [missing.yml, promote-tested-sync.yml, sync-upstream.yml]', + 'allowedWorkflows: [missing.yml]', '', ].join('\n'), ); diff --git a/tests/integration/sync.test.ts b/tests/integration/sync.test.ts index 5b6383f..bb18c2e 100644 --- a/tests/integration/sync.test.ts +++ b/tests/integration/sync.test.ts @@ -1664,6 +1664,8 @@ test('workflow policy blocks dry-run, publishing, and promotion without changing configureUser(upstreamWork); mkdirSync(path.join(upstreamWork, '.github', 'workflows'), { recursive: true }); writeFileSync(path.join(upstreamWork, '.github', 'workflows', 'ci.yml'), 'name: CI\n'); + writeFileSync(path.join(upstreamWork, '.github', 'workflows', 'promote-tested-sync.yml'), 'name: Promote\n'); + writeFileSync(path.join(upstreamWork, '.github', 'workflows', 'sync-upstream.yml'), 'name: Sync\n'); writeFileSync(path.join(upstreamWork, 'README.md'), '# Upstream\n'); git(['add', '.'], upstreamWork); git(['commit', '-m', 'Initial upstream'], upstreamWork); diff --git a/tests/workflow-policy.test.ts b/tests/workflow-policy.test.ts index 15c10eb..0fb458e 100644 --- a/tests/workflow-policy.test.ts +++ b/tests/workflow-policy.test.ts @@ -3,7 +3,7 @@ import { validateWorkflowPolicy } from '../src/workflow-policy.js'; test('accepts an exact composed workflow allowlist', () => { const violations = validateWorkflowPolicy( - ['ci.yml', 'promote-tested-sync.yml', 'sync-upstream.yml'], + ['ci.yml'], [ { file: 'sync-upstream.yml', content: 'name: Sync\n' }, { file: 'ci.yml', content: 'name: CI\n' }, @@ -14,10 +14,26 @@ test('accepts an exact composed workflow allowlist', () => { expect(violations).toEqual([]); }); +test('requires implicitly allowed Patchlane workflows', () => { + const violations = validateWorkflowPolicy( + ['ci.yml'], + [ + { file: 'ci.yml', content: 'name: CI\n' }, + { file: 'sync-upstream.yml', content: 'name: Sync\n' }, + ], + ); + + expect(violations.map(({ message }) => message)).toEqual([ + "Allowed workflow '.github/workflows/promote-tested-sync.yml' is missing from the composed tree.", + ]); +}); + test('reports added, deleted, and dangling reusable workflows in a composed tree', () => { const violations = validateWorkflowPolicy( ['ci.yml', 'deleted.yml', 'reusable.yml'], [ + { file: 'promote-tested-sync.yml', content: 'name: Promote\n' }, + { file: 'sync-upstream.yml', content: 'name: Sync\n' }, { file: 'ci.yml', content: [ From 890b29666e72a0a4ae2bbed403df887a8f1d852f Mon Sep 17 00:00:00 2001 From: Adam Poit Date: Sat, 18 Jul 2026 05:40:24 -0700 Subject: [PATCH 3/5] Require workflow policy in Patchlane config --- docs/configuration.md | 9 ++--- scripts/sync-skill-assets.ts | 1 + skills/patchlane-fork-setup/SKILL.md | 4 +-- src/cli.ts | 2 ++ src/config.ts | 53 ++++++++++++++-------------- src/init.ts | 24 ++++++++++--- tests/bootstrap.test.ts | 2 ++ tests/config.test.ts | 11 +++++- tests/doctor.test.ts | 2 +- tests/init.test.ts | 3 ++ tests/integration/cli.test.ts | 4 +++ 11 files changed, 77 insertions(+), 38 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 1b2746f..2b4dc4b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -28,9 +28,9 @@ allowedWorkflows: | `syncBranch` | no | Generated branch published for CI; defaults to `sync/integration` | | `patchRefs` | yes | Independent patch branches applied in order | | `ciWorkflow` | recommended | Exact existing CI workflow name used by `workflow_run` | -| `allowedWorkflows` | no | Repository workflow filenames permitted alongside Patchlane's | +| `allowedWorkflows` | yes | Repository workflow filenames permitted alongside generated ones | -When `allowedWorkflows` is configured, Patchlane implicitly adds its generated `sync-upstream.yml` and `promote-tested-sync.yml` workflows to the allowlist. Configure only repository-specific workflows such as CI. Doctor, every sync mode, and promotion then reject unexpected or missing workflow files and dangling local reusable-workflow references. Sync validates after all patches are composed and before publishing `syncBranch`; promotion validates the exact `EXPECTED_SYNC_SHA`. Omitting the field preserves the existing behavior. +Patchlane implicitly adds its generated `sync-upstream.yml` and `promote-tested-sync.yml` workflows to the allowlist. Configure only repository-specific workflows such as CI; use an empty list when no additional workflows are expected. Doctor, every sync mode, and promotion reject unexpected or missing workflow files and dangling local reusable-workflow references. Sync validates after all patches are composed and before publishing `syncBranch`; promotion validates the exact `EXPECTED_SYNC_SHA`. Supported sources: @@ -50,10 +50,11 @@ npx patchlane init \ --upstream=upstream-org/upstream-repo \ --source=release:latest \ --patch-refs=patch/sync,patch/ci \ - --ci-workflow="CI" + --ci-workflow="CI" \ + --allowed-workflows=ci.yml ``` -This writes `.patchlane.yml`, `.github/workflows/sync-upstream.yml`, and `.github/workflows/promote-tested-sync.yml`. It does not create patch branches or modify existing CI triggers. +This writes `.patchlane.yml`, `.github/workflows/sync-upstream.yml`, and `.github/workflows/promote-tested-sync.yml`. When `--allowed-workflows` is omitted, init adds the detected CI filename (or `fork-ci.yml`) to the configuration. It does not create patch branches or modify existing CI triggers. ### Inspect setup diff --git a/scripts/sync-skill-assets.ts b/scripts/sync-skill-assets.ts index f11975c..db037b4 100644 --- a/scripts/sync-skill-assets.ts +++ b/scripts/sync-skill-assets.ts @@ -20,6 +20,7 @@ const exampleConfig: PatchlaneConfig = { syncBranch: 'sync/integration', patchRefs: ['patch/sync', 'patch/ci'], ciWorkflow: 'Fork CI', + allowedWorkflows: ['fork-ci.yml'], }; const prettierOptions = { diff --git a/skills/patchlane-fork-setup/SKILL.md b/skills/patchlane-fork-setup/SKILL.md index 8bab663..397e8a9 100644 --- a/skills/patchlane-fork-setup/SKILL.md +++ b/skills/patchlane-fork-setup/SKILL.md @@ -26,7 +26,7 @@ Resolve and show the source tag or branch and commit SHA. Before pushing or rewr 2. Create each patch branch independently from the resolved upstream source. Never create `patch/sync` from `patch/product`, or another patch branch, unless that dependency is intentional and explicitly allowed. 3. Prefer the order `patch/sync`, `patch/ci`, then product-specific patches. Foundational changes must precede patches that depend on them. 4. Put `.patchlane.yml`, Patchlane workflows, and installed `.agents/skills` on `patch/sync`. -5. Put only the existing CI trigger adjustment on `patch/ci`. Preserve the existing workflow's `name`; configure `ciWorkflow` and the promotion workflow to reference that exact name. +5. Put only the existing CI trigger adjustment on `patch/ci`. Preserve the existing workflow's `name`; configure `ciWorkflow` and the promotion workflow to reference that exact name. Add the CI filename and every other intentionally retained repository workflow to `allowedWorkflows`; Patchlane adds its generated sync and promotion workflows implicitly. 6. Use `npx patchlane init` to generate `.patchlane.yml` and pinned workflow files when practical, then adapt rather than replace existing repository conventions. 7. Ensure fork CI covers normal pull requests plus pushes to both the generated base and sync branches. @@ -41,7 +41,7 @@ Use the bundled assets as invariants when adapting workflows: If Patchlane workflows or patch branches already exist, migrate incrementally instead of treating the repository as a new installation. 1. Read the existing workflow environment and map `UPSTREAM_OWNER`, `UPSTREAM_REPO`, `RELEASE_SELECTOR` or `UPSTREAM_REF`, `BASE_BRANCH`, `SYNC_BRANCH`, and `PATCH_REFS` into `.patchlane.yml`. -2. Preserve the configured source behavior, branch names, patch order, CI workflow name, schedule, and repository-specific workflow changes unless the user approves changing them. +2. Preserve the configured source behavior, branch names, patch order, CI workflow name, schedule, and repository-specific workflow changes unless the user approves changing them. Inventory the intended composed workflow set and configure their filenames in `allowedWorkflows`. 3. Add the config and adapted workflows to the existing `patch/sync` branch. Do not use `patchlane init --force` unless replacing those workflows is intentional. 4. Run `doctor` and `sync --dry-run`, then show the migration plan before pushing rewritten patch branches. 5. If sync and promotion workflows are already active on the generated base, roll the migration forward through the existing tested sync flow. Use initial bootstrap only when the promotion workflow is absent from the base. diff --git a/src/cli.ts b/src/cli.ts index 2e972fb..0e2d722 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -51,6 +51,7 @@ cli.command('init', 'Create Patchlane config and workflow files') .option('--base-branch ', 'Fork branch promoted later', { default: 'main' }) .option('--sync-branch ', 'Published generated branch name', { default: 'sync/integration' }) .option('--ci-workflow ', 'Existing CI workflow name used by workflow_run') + .option('--allowed-workflows ', 'Comma-separated repository workflow filenames') .option('--force', 'Replace existing Patchlane config and workflow files') .action((args) => { try { @@ -61,6 +62,7 @@ cli.command('init', 'Create Patchlane config and workflow files') baseBranch: args.baseBranch, syncBranch: args.syncBranch, ciWorkflow: args.ciWorkflow, + allowedWorkflows: args.allowedWorkflows, force: args.force === true, }); } catch (error) { diff --git a/src/config.ts b/src/config.ts index 297545b..3a2f330 100644 --- a/src/config.ts +++ b/src/config.ts @@ -13,7 +13,7 @@ export type PatchlaneConfig = { syncBranch: string; patchRefs: string[]; ciWorkflow?: string; - allowedWorkflows?: string[]; + allowedWorkflows: string[]; }; function isPlainObject(value: unknown): value is Record { @@ -59,30 +59,7 @@ export function parsePatchlaneConfig(value: unknown): PatchlaneConfig { throw new Error("Patchlane config field 'ciWorkflow' must be a non-empty string when provided."); } - const rawAllowedWorkflows = value.allowedWorkflows; - let allowedWorkflows: string[] | undefined; - if (rawAllowedWorkflows !== undefined) { - if (!Array.isArray(rawAllowedWorkflows)) { - throw new Error("Patchlane config field 'allowedWorkflows' must be an array when provided."); - } - allowedWorkflows = rawAllowedWorkflows.map((workflow) => { - if (typeof workflow !== 'string' || !workflow.trim()) { - throw new Error( - "Patchlane config field 'allowedWorkflows' must contain only non-empty workflow filenames.", - ); - } - const filename = workflow.trim(); - if (path.basename(filename) !== filename || filename.includes('\\') || !/\.ya?ml$/.test(filename)) { - throw new Error( - "Patchlane config field 'allowedWorkflows' must contain filenames ending in .yml or .yaml.", - ); - } - return filename; - }); - if (new Set(allowedWorkflows).size !== allowedWorkflows.length) { - throw new Error("Patchlane config field 'allowedWorkflows' must not contain duplicate filenames."); - } - } + const allowedWorkflows = parseAllowedWorkflows(value.allowedWorkflows); return { upstreamOwner, @@ -99,6 +76,30 @@ export function parsePatchlaneConfig(value: unknown): PatchlaneConfig { }; } +export function parseAllowedWorkflows(value: unknown) { + if (!Array.isArray(value)) { + throw new Error("Patchlane config field 'allowedWorkflows' must be an array."); + } + const allowedWorkflows = value.map((workflow) => { + if (typeof workflow !== 'string' || !workflow.trim()) { + throw new Error( + "Patchlane config field 'allowedWorkflows' must contain only non-empty workflow filenames.", + ); + } + const filename = workflow.trim(); + if (path.basename(filename) !== filename || filename.includes('\\') || !/\.ya?ml$/.test(filename)) { + throw new Error( + "Patchlane config field 'allowedWorkflows' must contain filenames ending in .yml or .yaml.", + ); + } + return filename; + }); + if (new Set(allowedWorkflows).size !== allowedWorkflows.length) { + throw new Error("Patchlane config field 'allowedWorkflows' must not contain duplicate filenames."); + } + return allowedWorkflows; +} + export function serializePatchlaneConfig(config: PatchlaneConfig) { return stringify({ version: 1, @@ -108,7 +109,7 @@ export function serializePatchlaneConfig(config: PatchlaneConfig) { syncBranch: config.syncBranch, patchRefs: config.patchRefs, ...(config.ciWorkflow ? { ciWorkflow: config.ciWorkflow } : {}), - ...(config.allowedWorkflows !== undefined ? { allowedWorkflows: config.allowedWorkflows } : {}), + allowedWorkflows: config.allowedWorkflows, }); } diff --git a/src/init.ts b/src/init.ts index ece4945..d1ff4ef 100644 --- a/src/init.ts +++ b/src/init.ts @@ -2,7 +2,12 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from import path from 'node:path'; import { spawnSync } from 'node:child_process'; import { parse } from 'yaml'; -import { PATCHLANE_CONFIG_FILE, serializePatchlaneConfig, type PatchlaneConfig } from './config.js'; +import { + parseAllowedWorkflows, + PATCHLANE_CONFIG_FILE, + serializePatchlaneConfig, + type PatchlaneConfig, +} from './config.js'; import { getPackageVersion } from './package-version.js'; import { parseUpstreamSource } from './upstream-source.js'; import { renderPromotionWorkflow, renderSyncWorkflow } from './workflow-templates.js'; @@ -14,6 +19,7 @@ export type InitOptions = { syncBranch?: string; patchRefs?: string; ciWorkflow?: string; + allowedWorkflows?: string; force?: boolean; cwd?: string; }; @@ -59,8 +65,8 @@ function detectCiWorkflow(cwd: string) { .filter((candidate): candidate is { file: string; name: string } => Boolean(candidate.name)); return ( - candidates.find((candidate) => /^ci\.ya?ml$/i.test(candidate.file))?.name ?? - candidates.find((candidate) => /\bci\b/i.test(candidate.name))?.name + candidates.find((candidate) => /^ci\.ya?ml$/i.test(candidate.file)) ?? + candidates.find((candidate) => /\bci\b/i.test(candidate.name)) ); } @@ -93,6 +99,14 @@ export function initializePatchlane(options: InitOptions = {}) { const source = options.source ?? 'release:latest'; parseUpstreamSource(source); + const detectedCiWorkflow = detectCiWorkflow(cwd); + const configuredAllowedWorkflows = + options.allowedWorkflows === undefined + ? [detectedCiWorkflow?.file ?? 'fork-ci.yml'] + : options.allowedWorkflows + .split(/\r?\n|,/) + .map((workflow) => workflow.trim()) + .filter(Boolean); const config: PatchlaneConfig = { upstreamOwner: upstream.slice(0, separator), upstreamRepo: upstream.slice(separator + 1), @@ -100,7 +114,8 @@ export function initializePatchlane(options: InitOptions = {}) { baseBranch: options.baseBranch ?? 'main', syncBranch: options.syncBranch ?? 'sync/integration', patchRefs: parsePatchRefs(options.patchRefs ?? 'patch/sync,patch/ci'), - ciWorkflow: options.ciWorkflow ?? detectCiWorkflow(cwd) ?? 'Fork CI', + ciWorkflow: options.ciWorkflow ?? detectedCiWorkflow?.name ?? 'Fork CI', + allowedWorkflows: parseAllowedWorkflows(configuredAllowedWorkflows), }; const force = options.force ?? false; @@ -124,6 +139,7 @@ export function initializePatchlane(options: InitOptions = {}) { `Upstream source: ${config.source}`, `Patch order: ${config.patchRefs.join(', ')}`, `CI workflow: ${config.ciWorkflow}`, + `Allowed repository workflows: ${config.allowedWorkflows.join(', ') || '(none)'}`, 'Run `npx patchlane doctor` before publishing any branches.', ].join('\n') + '\n', ); diff --git a/tests/bootstrap.test.ts b/tests/bootstrap.test.ts index 7066953..3460124 100644 --- a/tests/bootstrap.test.ts +++ b/tests/bootstrap.test.ts @@ -24,6 +24,7 @@ const config: PatchlaneConfig = { syncBranch: 'sync/integration', patchRefs: ['patch/sync', 'patch/product'], ciWorkflow: 'Fork CI', + allowedWorkflows: ['fork-ci.yml'], }; function commandResult(overrides: Partial = {}): CommandResult { @@ -133,6 +134,7 @@ describe('bootstrapPatchlane', () => { expect(run).toHaveBeenNthCalledWith(4, 'gh', ['run', 'watch', '42', '--exit-status'], cwd); expect(runPromoteSync).toHaveBeenCalledWith({ expectedSyncSha: syncSha, + allowedWorkflows: config.allowedWorkflows, baseBranch: config.baseBranch, syncBranch: config.syncBranch, originRemoteName: 'origin', diff --git a/tests/config.test.ts b/tests/config.test.ts index 3c290ca..eae3024 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -9,6 +9,7 @@ test('parses Patchlane configuration', () => { source: 'release:latest', patchRefs: ['patch/sync', 'patch/ci'], ciWorkflow: 'CI', + allowedWorkflows: ['ci.yml'], }), ).toEqual({ upstreamOwner: 'example', @@ -18,7 +19,7 @@ test('parses Patchlane configuration', () => { syncBranch: 'sync/integration', patchRefs: ['patch/sync', 'patch/ci'], ciWorkflow: 'CI', - allowedWorkflows: undefined, + allowedWorkflows: ['ci.yml'], }); }); @@ -46,6 +47,14 @@ test('parses and validates allowed workflow filenames', () => { test('rejects incomplete Patchlane configuration', () => { expect(() => parsePatchlaneConfig({ version: 1 })).toThrow(/upstream/); + expect(() => + parsePatchlaneConfig({ + version: 1, + upstream: 'example/upstream', + source: 'branch:main', + patchRefs: ['patch/sync'], + }), + ).toThrow(/allowedWorkflows/); expect(() => parsePatchlaneConfig({ version: 1, diff --git a/tests/doctor.test.ts b/tests/doctor.test.ts index ac28816..06a9657 100644 --- a/tests/doctor.test.ts +++ b/tests/doctor.test.ts @@ -154,7 +154,7 @@ test('reports a ready configuration and required bootstrap', () => { writeFileSync( path.join(forkWork, '.patchlane.yml'), - 'version: 1\nupstream: example/upstream\nsource: branch:main\npatchRefs: [patch/sync]\nciWorkflow: Existing CI\n', + 'version: 1\nupstream: example/upstream\nsource: branch:main\npatchRefs: [patch/sync]\nciWorkflow: Existing CI\nallowedWorkflows: [ci.yml]\n', ); const workflowDir = path.join(forkWork, '.github', 'workflows'); mkdirSync(workflowDir, { recursive: true }); diff --git a/tests/init.test.ts b/tests/init.test.ts index 25aeae0..87e0f2b 100644 --- a/tests/init.test.ts +++ b/tests/init.test.ts @@ -31,11 +31,14 @@ test('initializes config and pinned workflows from repository conventions', () = upstreamRepo: 'upstream', source: 'release:latest', ciWorkflow: 'Existing CI', + allowedWorkflows: ['ci.yml'], }); const configFile = parse(readFileSync(path.join(tempRoot, '.patchlane.yml'), 'utf8')) as { patchRefs: string[]; + allowedWorkflows: string[]; }; expect(configFile.patchRefs).toEqual(['patch/sync', 'patch/ci', 'patch/product']); + expect(configFile.allowedWorkflows).toEqual(['ci.yml']); const { version } = JSON.parse( readFileSync(path.resolve(import.meta.dirname, '..', 'package.json'), 'utf8'), diff --git a/tests/integration/cli.test.ts b/tests/integration/cli.test.ts index 4d1d779..91668ac 100644 --- a/tests/integration/cli.test.ts +++ b/tests/integration/cli.test.ts @@ -64,6 +64,7 @@ test('sync skip-push flags do not publish the generated branch', () => { 'patchRefs:', ' - patch/product', 'ciWorkflow: Existing CI', + 'allowedWorkflows: [ci.yml]', '', ].join('\n'), ); @@ -78,6 +79,9 @@ test('sync skip-push flags do not publish the generated branch', () => { path.join(workflowDir, 'promote-tested-sync.yml'), 'name: Promote\non:\n workflow_run:\n workflows: [Existing CI]\npermissions:\n contents: write\n', ); + git(['add', '.patchlane.yml', '.github/workflows'], forkWork); + git(['commit', '-m', 'Configure Patchlane'], forkWork); + git(['push', 'origin', 'patch/product'], forkWork); const result = run('node', [cliPath, 'sync', `--upstream-remote-url=${upstreamBare}`, '--skip-push'], forkWork); From d82ff3cfd5038fb7f6423c5d21d626c463859656 Mon Sep 17 00:00:00 2001 From: Adam Poit Date: Sat, 18 Jul 2026 05:52:23 -0700 Subject: [PATCH 4/5] Consolidate versioned migration guidance --- README.md | 4 +- docs/configuration.md | 2 + docs/manual-setup.md | 2 +- docs/{migrating-to-0.4.md => migrations.md} | 76 +++++++++++++++++++-- skills/patchlane-fork-setup/SKILL.md | 13 ++-- src/config.ts | 4 +- 6 files changed, 88 insertions(+), 13 deletions(-) rename docs/{migrating-to-0.4.md => migrations.md} (51%) diff --git a/README.md b/README.md index ba88a9d..6286961 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Then ask your coding agent: The setup skill inspects the repository, asks which upstream release or branch to track, and shows its complete plan before pushing or rewriting branches. It then creates the patch stack, validates it, and guides the first tested promotion. -Prefer to configure it yourself? Follow the [manual setup guide](docs/manual-setup.md). Already using an earlier Patchlane workflow? Use the [0.4 migration guide](docs/migrating-to-0.4.md). +Prefer to configure it yourself? Follow the [manual setup guide](docs/manual-setup.md). Already using an earlier Patchlane version? Use the [migration guide](docs/migrations.md). ## How It Works @@ -33,7 +33,7 @@ The promoted base and sync branches are generated output. Fork-owned changes bel ## Documentation - [Manual setup](docs/manual-setup.md) -- [Migrating to Patchlane 0.4](docs/migrating-to-0.4.md) +- [Migration guide](docs/migrations.md) - [Configuration and command reference](docs/configuration.md) ## Development diff --git a/docs/configuration.md b/docs/configuration.md index 2b4dc4b..043e62c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -32,6 +32,8 @@ allowedWorkflows: Patchlane implicitly adds its generated `sync-upstream.yml` and `promote-tested-sync.yml` workflows to the allowlist. Configure only repository-specific workflows such as CI; use an empty list when no additional workflows are expected. Doctor, every sync mode, and promotion reject unexpected or missing workflow files and dangling local reusable-workflow references. Sync validates after all patches are composed and before publishing `syncBranch`; promotion validates the exact `EXPECTED_SYNC_SHA`. +Existing configurations that do not define this field must follow the [vNext migration instructions](migrations.md#vnext). + Supported sources: - `release:latest` diff --git a/docs/manual-setup.md b/docs/manual-setup.md index 42d685e..630b45a 100644 --- a/docs/manual-setup.md +++ b/docs/manual-setup.md @@ -2,7 +2,7 @@ This walkthrough configures an existing GitHub fork. You need Node.js 22+, `git`, and an authenticated `gh` CLI. -Already using an earlier Patchlane workflow? Follow the [0.4 migration guide](migrating-to-0.4.md) instead. +Already using an earlier Patchlane version? Follow the [migration guide](migrations.md) instead. ## 1. Choose the upstream source diff --git a/docs/migrating-to-0.4.md b/docs/migrations.md similarity index 51% rename from docs/migrating-to-0.4.md rename to docs/migrations.md index b3de7d9..5e19b3c 100644 --- a/docs/migrating-to-0.4.md +++ b/docs/migrations.md @@ -1,8 +1,74 @@ -# Migrating to Patchlane 0.4 +# Patchlane Migration Guide + +Follow the section for the version you are adopting. Migration notes are listed newest first. + +## vNext + +vNext requires every version-1 `.patchlane.yml` file to define `allowedWorkflows`. Patchlane implicitly includes its generated `sync-upstream.yml` and `promote-tested-sync.yml` workflows, so list only repository-specific workflows. + +### 1. Inventory the composed workflow tree + +Inspect the workflows on the current generated base branch and review the configured patch branches for workflow additions or deletions: + +```bash +git fetch origin +git ls-tree -r --name-only origin/main -- .github/workflows +``` + +Decide which workflows should remain after Patchlane composes the upstream source and every configured patch. Include CI, documentation, maintenance, and local reusable workflows only when they are intentionally retained. Every target referenced through `uses: ./.github/workflows/` must also be present and allowed. + +Do not add these generated workflows explicitly; Patchlane allows and requires them automatically: + +- `sync-upstream.yml` +- `promote-tested-sync.yml` + +### 2. Add the required allowlist + +Update `.patchlane.yml` on the patch branch that owns Patchlane configuration, normally `patch/sync`: + +```yaml +version: 1 +upstream: upstream-org/upstream-repo +source: release:latest +baseBranch: main +syncBranch: sync/integration +patchRefs: + - patch/sync + - patch/ci + - patch/product +ciWorkflow: CI +allowedWorkflows: + - ci.yml +``` + +Use `allowedWorkflows: []` when the composed tree should contain only Patchlane's generated workflows. Add patches that delete unwanted upstream workflows rather than allowing them merely because they currently exist. + +### 3. Validate before rollout + +After pushing the updated patch refs, validate with the vNext package version selected for the migration: + +```bash +npx patchlane@VERSION doctor +npx patchlane@VERSION sync --dry-run +``` + +Doctor should identify every unexpected or missing workflow by filename. The dry run validates the actual output after all configured patches are replayed without changing the local or remote sync branch. + +### 4. Roll out through a tested promotion + +Update the pinned Patchlane version in the sync and promotion workflows as part of the same configuration patch. From that patch branch, use the new client for the first policy-enforced rebuild and promotion: + +```bash +npx patchlane@VERSION bootstrap --wait +``` + +This validates the allowlist before publishing `sync/integration`, waits for CI on the exact published SHA, revalidates that SHA, and then promotes it. Confirm afterward that the generated base contains the intended workflow set and that scheduled syncs use the new Patchlane version. + +## 0.4 Existing Patchlane forks can migrate without rebuilding their patch strategy or interrupting scheduled syncs. Legacy workflow environment variables remain supported, so migration can be rolled out through the existing sync and promotion flow. -## 1. Translate the existing workflow configuration +### 1. Translate the existing workflow configuration Read the current sync workflow and map its environment variables into `.patchlane.yml`: @@ -33,7 +99,7 @@ ciWorkflow: CI Do not infer the source from the checked-out branch or a version file. Preserve the source behavior already configured in the workflow unless you intentionally want to change it. -## 2. Update `patch/sync` +### 2. Update `patch/sync` Add `.patchlane.yml` to the existing `patch/sync` branch. Adapt the new generated sync and promotion workflows while preserving: @@ -46,7 +112,7 @@ Avoid `patchlane init --force` for migration unless replacing the existing workf Commit and push the updated `patch/sync` branch. -## 3. Validate before rollout +### 3. Validate before rollout From `patch/sync`, run: @@ -57,7 +123,7 @@ npx patchlane@0.4.0 sync --dry-run `doctor` should resolve the same upstream source and patch order as the legacy workflow. Review every warning before publishing. -## 4. Roll the config onto the generated base +### 4. Roll the config onto the generated base If the existing sync and promotion workflows are already active on the generated base, trigger the existing sync workflow. Its legacy environment variables remain compatible with Patchlane 0.4, and the resulting tested promotion will place `.patchlane.yml` and the updated workflows on the base branch. diff --git a/skills/patchlane-fork-setup/SKILL.md b/skills/patchlane-fork-setup/SKILL.md index 397e8a9..e86202c 100644 --- a/skills/patchlane-fork-setup/SKILL.md +++ b/skills/patchlane-fork-setup/SKILL.md @@ -38,14 +38,19 @@ Use the bundled assets as invariants when adapting workflows: ## Migrate an existing Patchlane fork +Before planning an upgrade, fetch and read the current migration guide from: + +`https://raw.githubusercontent.com/adampoit/patchlane/main/docs/migrations.md` + +Use the section for the target version, including `vNext` for an unreleased upgrade. Fetch this file dynamically instead of relying on migration details bundled with the installed skill. + If Patchlane workflows or patch branches already exist, migrate incrementally instead of treating the repository as a new installation. -1. Read the existing workflow environment and map `UPSTREAM_OWNER`, `UPSTREAM_REPO`, `RELEASE_SELECTOR` or `UPSTREAM_REF`, `BASE_BRANCH`, `SYNC_BRANCH`, and `PATCH_REFS` into `.patchlane.yml`. -2. Preserve the configured source behavior, branch names, patch order, CI workflow name, schedule, and repository-specific workflow changes unless the user approves changing them. Inventory the intended composed workflow set and configure their filenames in `allowedWorkflows`. +1. Preserve the configured source behavior, branch names, patch order, CI workflow name, schedule, and repository-specific workflow changes unless the user approves changing them. +2. Follow the fetched guide to update `.patchlane.yml` and inventory the intended composed workflow set. 3. Add the config and adapted workflows to the existing `patch/sync` branch. Do not use `patchlane init --force` unless replacing those workflows is intentional. 4. Run `doctor` and `sync --dry-run`, then show the migration plan before pushing rewritten patch branches. -5. If sync and promotion workflows are already active on the generated base, roll the migration forward through the existing tested sync flow. Use initial bootstrap only when the promotion workflow is absent from the base. -6. Follow the [Patchlane 0.4 migration guide](https://github.com/adampoit/patchlane/blob/v0.4.0/docs/migrating-to-0.4.md) for the full rollout sequence. +5. Roll the migration forward through the tested sync flow described by the fetched guide. ## Validate and bootstrap diff --git a/src/config.ts b/src/config.ts index 3a2f330..8c0a671 100644 --- a/src/config.ts +++ b/src/config.ts @@ -78,7 +78,9 @@ export function parsePatchlaneConfig(value: unknown): PatchlaneConfig { export function parseAllowedWorkflows(value: unknown) { if (!Array.isArray(value)) { - throw new Error("Patchlane config field 'allowedWorkflows' must be an array."); + throw new Error( + "Patchlane config field 'allowedWorkflows' must be an array. See https://github.com/adampoit/patchlane/blob/main/docs/migrations.md for migration instructions.", + ); } const allowedWorkflows = value.map((workflow) => { if (typeof workflow !== 'string' || !workflow.trim()) { From 8dad4264d4592ec28bf5bcd2b6f90310360270ae Mon Sep 17 00:00:00 2001 From: Adam Poit Date: Sat, 18 Jul 2026 05:59:02 -0700 Subject: [PATCH 5/5] Remove redundant migration callout --- docs/configuration.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 043e62c..2b4dc4b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -32,8 +32,6 @@ allowedWorkflows: Patchlane implicitly adds its generated `sync-upstream.yml` and `promote-tested-sync.yml` workflows to the allowlist. Configure only repository-specific workflows such as CI; use an empty list when no additional workflows are expected. Doctor, every sync mode, and promotion reject unexpected or missing workflow files and dangling local reusable-workflow references. Sync validates after all patches are composed and before publishing `syncBranch`; promotion validates the exact `EXPECTED_SYNC_SHA`. -Existing configurations that do not define this field must follow the [vNext migration instructions](migrations.md#vnext). - Supported sources: - `release:latest`