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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
32 changes: 19 additions & 13 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,22 @@ patchRefs:
- patch/ci
- patch/product
ciWorkflow: CI
allowedWorkflows:
- ci.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` | yes | Repository workflow filenames permitted alongside generated ones |

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:

Expand All @@ -45,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

Expand All @@ -57,7 +63,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

Expand Down Expand Up @@ -134,4 +140,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`.
2 changes: 1 addition & 1 deletion docs/manual-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
76 changes: 71 additions & 5 deletions docs/migrating-to-0.4.md → docs/migrations.md
Original file line number Diff line number Diff line change
@@ -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/<file>` 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`:

Expand Down Expand Up @@ -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:

Expand All @@ -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:

Expand All @@ -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.

Expand Down
1 change: 1 addition & 0 deletions scripts/sync-skill-assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const exampleConfig: PatchlaneConfig = {
syncBranch: 'sync/integration',
patchRefs: ['patch/sync', 'patch/ci'],
ciWorkflow: 'Fork CI',
allowedWorkflows: ['fork-ci.yml'],
};

const prettierOptions = {
Expand Down
15 changes: 10 additions & 5 deletions skills/patchlane-fork-setup/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.
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

Expand Down
2 changes: 2 additions & 0 deletions src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -203,6 +204,7 @@ export async function bootstrapPatchlane(options: BootstrapOptions = {}) {

runPromoteSync({
expectedSyncSha: syncSha,
allowedWorkflows: config.allowedWorkflows,
baseBranch: config.baseBranch,
syncBranch: config.syncBranch,
originRemoteName,
Expand Down
4 changes: 4 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ cli.command('init', 'Create Patchlane config and workflow files')
.option('--base-branch <branch>', 'Fork branch promoted later', { default: 'main' })
.option('--sync-branch <branch>', 'Published generated branch name', { default: 'sync/integration' })
.option('--ci-workflow <name>', 'Existing CI workflow name used by workflow_run')
.option('--allowed-workflows <files>', 'Comma-separated repository workflow filenames')
.option('--force', 'Replace existing Patchlane config and workflow files')
.action((args) => {
try {
Expand All @@ -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) {
Expand Down Expand Up @@ -172,6 +174,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,
Expand Down Expand Up @@ -209,6 +212,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,
Expand Down
31 changes: 31 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export type PatchlaneConfig = {
syncBranch: string;
patchRefs: string[];
ciWorkflow?: string;
allowedWorkflows: string[];
};

function isPlainObject(value: unknown): value is Record<string, unknown> {
Expand Down Expand Up @@ -58,6 +59,8 @@ export function parsePatchlaneConfig(value: unknown): PatchlaneConfig {
throw new Error("Patchlane config field 'ciWorkflow' must be a non-empty string when provided.");
}

const allowedWorkflows = parseAllowedWorkflows(value.allowedWorkflows);

return {
upstreamOwner,
upstreamRepo,
Expand All @@ -69,9 +72,36 @@ export function parsePatchlaneConfig(value: unknown): PatchlaneConfig {
: 'sync/integration',
patchRefs,
ciWorkflow: typeof ciWorkflow === 'string' ? ciWorkflow.trim() : undefined,
allowedWorkflows,
};
}

export function parseAllowedWorkflows(value: unknown) {
if (!Array.isArray(value)) {
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()) {
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,
Expand All @@ -81,6 +111,7 @@ export function serializePatchlaneConfig(config: PatchlaneConfig) {
syncBranch: config.syncBranch,
patchRefs: config.patchRefs,
...(config.ciWorkflow ? { ciWorkflow: config.ciWorkflow } : {}),
allowedWorkflows: config.allowedWorkflows,
});
}

Expand Down
8 changes: 7 additions & 1 deletion src/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -233,6 +236,9 @@ function hasWriteContents(workflow: Record<string, unknown>) {

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');
Expand Down
Loading