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
23 changes: 23 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ patchRefs:
ciWorkflow: CI
allowedWorkflows:
- ci.yml
notifications:
githubIssues:
assignees: [maintainer]
labels: [patchlane, automation-failure]
events: [sync-failed, ci-failed, promotion-failed]
closeOnRecovery: true
```

| Field | Required | Description |
Expand All @@ -29,6 +35,7 @@ allowedWorkflows:
| `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 |
| `notifications` | no | Automation failure notification providers |

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`.

Expand All @@ -41,6 +48,8 @@ Supported sources:

CLI flags and environment variables override config values for one run. Legacy `UPSTREAM_OWNER`, `UPSTREAM_REPO`, `UPSTREAM_REF`, `RELEASE_SELECTOR`, `BASE_BRANCH`, `SYNC_BRANCH`, and `PATCH_REFS` variables remain supported for migration.

GitHub issue notifications are optional. Patchlane keeps one issue per failure event, updates it on repeated failures, assigns configured users individually, and closes it after a successful run when `closeOnRecovery` is enabled. The generated workflows request `issues: write` only when they handle an enabled GitHub issue event. Notification API errors are warnings and do not replace the sync, CI, or promotion result.

## Commands

### Initialize files
Expand Down Expand Up @@ -105,6 +114,19 @@ npx patchlane promote --expected-sync-sha=<sha>

Promotion verifies that the tested SHA is still the current sync-branch head before updating the generated base branch with force-with-lease.

### Report an automation result

Generated workflows invoke this command automatically:

```bash
npx patchlane notify --event=sync-failed
npx patchlane notify --event=ci-failed
npx patchlane notify --event=promotion-failed
npx patchlane notify --event=sync-failed --recovered
```

The repository defaults to `GITHUB_REPOSITORY` in Actions or the GitHub `origin` remote locally. Structured context can be supplied with flags or the `PATCHLANE_STATUS`, `PATCHLANE_RUN_URL`, `UPSTREAM_SHA`, `SYNC_SHA`, `FAILED_PATCH_REF`, `FAILED_COMMIT`, `CONFLICT_PATHS`, and `APPLIED_PATCH_REFS` environment variables.

### Install agent skills

```bash
Expand Down Expand Up @@ -135,6 +157,7 @@ Patchlane writes these outputs when `GITHUB_OUTPUT` is available:
- `status`
- `sync_branch`
- `sync_sha`
- `upstream_sha`
- `applied_refs`
- `failed_bookmark`
- `failed_commit`
Expand Down
30 changes: 30 additions & 0 deletions docs/migrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,36 @@ 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.

### 5. Optionally enable maintainer notifications

Failure notifications are opt-in. Existing configurations that omit `notifications` keep their current behavior and require no notification-specific migration.

To enable notifications, add the provider configuration to the patch branch that owns `.patchlane.yml`:

```yaml
notifications:
githubIssues:
assignees:
- maintainer
labels:
- patchlane
- automation-failure
events:
- sync-failed
- ci-failed
- promotion-failed
closeOnRecovery: true
```

Update both generated workflow files from the vNext templates in the same patch:

- `sync-upstream.yml` must grant `issues: write`, report `sync-failed` after a failed sync, and optionally report recovery after success.
- `promote-tested-sync.yml` must grant `issues: write`, report unsuccessful CI workflow runs, report failed promotions, and optionally report each recovery.

Do not add only the configuration block: existing workflow files do not invoke `patchlane notify`, and `patchlane doctor` reports missing `issues: write` permissions. Keep notification steps on `continue-on-error` so a GitHub API or assignment failure cannot replace the original automation result.

Run `doctor`, then roll the configuration and workflow changes forward through `bootstrap --wait` as described above. Confirm that configured labels exist and that each assignee can be assigned to issues in the fork.

## 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.
Expand Down
70 changes: 68 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,19 @@
import cac from 'cac';
import { installPatchlaneAgents } from './agents-install.js';
import { bootstrapPatchlane } from './bootstrap.js';
import { loadPatchlaneConfig } from './config.js';
import { loadPatchlaneConfig, NOTIFICATION_EVENTS, type NotificationEvent } from './config.js';
import { runDoctor } from './doctor.js';
import { initializePatchlane } from './init.js';
import { runIntegrationSync } from './integration-sync.js';
import { getPackageVersion } from './package-version.js';
import { runNotification } from './notify.js';
import { runPromoteSync } from './promote-sync.js';
import { parseUpstreamSource } from './upstream-source.js';

const cli = cac('patchlane');

let config: ReturnType<typeof loadPatchlaneConfig>;
if (['sync', 'promote'].includes(process.argv[2] ?? '')) {
if (['sync', 'promote', 'notify'].includes(process.argv[2] ?? '')) {
try {
config = loadPatchlaneConfig();
} catch (error) {
Expand Down Expand Up @@ -190,6 +191,71 @@ cli.command('sync', 'Rebuild integration branch from upstream and patches')
});
});

cli.command('notify', 'Create, update, or close an automation failure issue')
.option('--event <event>', 'Notification event: sync-failed, ci-failed, or promotion-failed')
.option('--recovered', 'Close the open notification after recovery')
.option('--repository <owner/repo>', 'GitHub repository; defaults to the current fork', {
default: env('GITHUB_REPOSITORY'),
})
.option('--status <status>', 'Failure status', { default: env('PATCHLANE_STATUS') })
.option('--run-url <url>', 'Workflow run URL', {
default: env(
'PATCHLANE_RUN_URL',
process.env.GITHUB_REPOSITORY && process.env.GITHUB_RUN_ID
? `${env('GITHUB_SERVER_URL', 'https://github.com')}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`
: undefined,
),
})
.option('--upstream-source <source>', 'Configured upstream source', {
default: env('UPSTREAM_SOURCE', config?.source),
})
.option('--upstream-sha <sha>', 'Resolved upstream SHA', { default: env('UPSTREAM_SHA') })
.option('--sync-sha <sha>', 'Generated or tested sync SHA', { default: env('SYNC_SHA') })
.option('--base-branch <branch>', 'Fork base branch', {
default: env('BASE_BRANCH', config?.baseBranch),
})
.option('--sync-branch <branch>', 'Generated sync branch', {
default: env('SYNC_BRANCH', config?.syncBranch),
})
.option('--failed-patch-ref <ref>', 'Failing patch ref', { default: env('FAILED_PATCH_REF') })
.option('--failed-commit <sha>', 'Failing patch commit', { default: env('FAILED_COMMIT') })
.option('--conflict-paths <paths>', 'Newline- or comma-delimited conflict paths', {
default: env('CONFLICT_PATHS'),
})
.option('--applied-patch-refs <refs>', 'Newline- or comma-delimited applied patch refs', {
default: env('APPLIED_PATCH_REFS'),
})
.action((args) => {
if (!config) {
process.stderr.write('Missing .patchlane.yml. Run `npx patchlane init` first.\n');
process.exitCode = 1;
return;
}
if (!NOTIFICATION_EVENTS.includes(args.event as NotificationEvent)) {
process.stderr.write(`Invalid notification event '${String(args.event ?? '')}'.\n`);
process.exitCode = 1;
return;
}
const result = runNotification({
config,
event: args.event as NotificationEvent,
recovered: args.recovered === true,
repository: args.repository,
status: args.status,
runUrl: args.runUrl,
upstreamSource: args.upstreamSource,
upstreamSha: args.upstreamSha,
syncSha: args.syncSha,
baseBranch: args.baseBranch,
syncBranch: args.syncBranch,
failedPatchRef: args.failedPatchRef,
failedCommit: args.failedCommit,
conflictPaths: args.conflictPaths,
appliedPatchRefs: args.appliedPatchRefs,
});
if (result.status !== 'failed') process.stdout.write(`Notification status: ${result.status}\n`);
});

cli.command('promote', 'Promote tested sync branch onto base branch')
.option('--expected-sync-sha <sha>', 'Tested commit SHA', {
default: env('EXPECTED_SYNC_SHA'),
Expand Down
63 changes: 63 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,17 @@ import { parseUpstreamSource } from './upstream-source.js';

export const PATCHLANE_CONFIG_FILE = '.patchlane.yml';

export const NOTIFICATION_EVENTS = ['sync-failed', 'ci-failed', 'promotion-failed'] as const;

export type NotificationEvent = (typeof NOTIFICATION_EVENTS)[number];

export type GithubIssueNotifications = {
assignees: string[];
labels: string[];
events: NotificationEvent[];
closeOnRecovery: boolean;
};

export type PatchlaneConfig = {
upstreamOwner: string;
upstreamRepo: string;
Expand All @@ -14,6 +25,9 @@ export type PatchlaneConfig = {
patchRefs: string[];
ciWorkflow?: string;
allowedWorkflows: string[];
notifications?: {
githubIssues: GithubIssueNotifications;
};
};

function isPlainObject(value: unknown): value is Record<string, unknown> {
Expand All @@ -28,6 +42,53 @@ function requireString(config: Record<string, unknown>, key: string) {
return value.trim();
}

function parseStringArray(value: unknown, field: string) {
if (!Array.isArray(value)) throw new Error(`Patchlane config field '${field}' must be an array.`);
const values = value.map((item) => {
if (typeof item !== 'string' || !item.trim()) {
throw new Error(`Patchlane config field '${field}' must contain only non-empty strings.`);
}
return item.trim();
});
if (new Set(values).size !== values.length) {
throw new Error(`Patchlane config field '${field}' must not contain duplicates.`);
}
return values;
}

function parseNotifications(value: unknown): PatchlaneConfig['notifications'] {
if (value === undefined) return undefined;
if (!isPlainObject(value) || !isPlainObject(value.githubIssues)) {
throw new Error("Patchlane config field 'notifications.githubIssues' must be a YAML object.");
}
const provider = value.githubIssues;
const assignees = parseStringArray(provider.assignees ?? [], 'notifications.githubIssues.assignees');
const labels = parseStringArray(provider.labels ?? [], 'notifications.githubIssues.labels');
const rawEvents = parseStringArray(provider.events, 'notifications.githubIssues.events');
if (!rawEvents.length) {
throw new Error("Patchlane config field 'notifications.githubIssues.events' must not be empty.");
}
const events = rawEvents.map((event) => {
if (!(NOTIFICATION_EVENTS as readonly string[]).includes(event)) {
throw new Error(
`Patchlane config field 'notifications.githubIssues.events' contains invalid event '${event}'.`,
);
}
return event as NotificationEvent;
});
if (provider.closeOnRecovery !== undefined && typeof provider.closeOnRecovery !== 'boolean') {
throw new Error("Patchlane config field 'notifications.githubIssues.closeOnRecovery' must be a boolean.");
}
return {
githubIssues: {
assignees,
labels,
events,
closeOnRecovery: provider.closeOnRecovery ?? false,
},
};
}

export function parsePatchlaneConfig(value: unknown): PatchlaneConfig {
if (!isPlainObject(value)) throw new Error('Patchlane config must be a YAML object.');
if (value.version !== 1) throw new Error("Patchlane config field 'version' must be 1.");
Expand Down Expand Up @@ -73,6 +134,7 @@ export function parsePatchlaneConfig(value: unknown): PatchlaneConfig {
patchRefs,
ciWorkflow: typeof ciWorkflow === 'string' ? ciWorkflow.trim() : undefined,
allowedWorkflows,
notifications: parseNotifications(value.notifications),
};
}

Expand Down Expand Up @@ -112,6 +174,7 @@ export function serializePatchlaneConfig(config: PatchlaneConfig) {
patchRefs: config.patchRefs,
...(config.ciWorkflow ? { ciWorkflow: config.ciWorkflow } : {}),
allowedWorkflows: config.allowedWorkflows,
...(config.notifications ? { notifications: config.notifications } : {}),
});
}

Expand Down
54 changes: 49 additions & 5 deletions src/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ function remoteUrl(cwd: string, remote: string) {
return result.status === 0 ? result.stdout : undefined;
}

function githubRepository(remote: string | undefined) {
const match = remote?.match(/github\.com[/:]([^/]+)\/([^/]+?)(?:\.git)?$/);
return match ? `${match[1]}/${match[2]}` : undefined;
}

function resolveRelease(config: PatchlaneConfig, selector: string, cwd: string) {
const repo = `${config.upstreamOwner}/${config.upstreamRepo}`;
if (selector === 'latest') {
Expand Down Expand Up @@ -224,13 +229,13 @@ function configuredBranches(workflow: Record<string, unknown>) {
return Array.isArray(branches) ? branches.filter((branch): branch is string => typeof branch === 'string') : [];
}

function hasWriteContents(workflow: Record<string, unknown>) {
function hasWritePermission(workflow: Record<string, unknown>, permission: string) {
const permissions = workflow.permissions;
return (
typeof permissions === 'object' &&
permissions !== null &&
!Array.isArray(permissions) &&
(permissions as Record<string, unknown>).contents === 'write'
(permissions as Record<string, unknown>)[permission] === 'write'
);
}

Expand All @@ -245,15 +250,37 @@ function inspectWorkflows(config: PatchlaneConfig, sourceSha: string | undefined
const ci = parsed.find((file) => workflowName(file.workflow) === config.ciWorkflow);

if (!sync?.workflow) checks.push({ severity: 'error', message: 'Missing .github/workflows/sync-upstream.yml.' });
else if (!hasWriteContents(sync.workflow)) {
checks.push({ severity: 'error', message: 'The sync workflow must grant contents: write.' });
else {
if (!hasWritePermission(sync.workflow, 'contents')) {
checks.push({ severity: 'error', message: 'The sync workflow must grant contents: write.' });
}
if (
config.notifications?.githubIssues.events.includes('sync-failed') &&
!hasWritePermission(sync.workflow, 'issues')
) {
checks.push({
severity: 'error',
message: 'The sync workflow must grant issues: write for GitHub issue notifications.',
});
}
}
if (!promotion?.workflow) {
checks.push({ severity: 'error', message: 'Missing .github/workflows/promote-tested-sync.yml.' });
} else {
if (!hasWriteContents(promotion.workflow)) {
if (!hasWritePermission(promotion.workflow, 'contents')) {
checks.push({ severity: 'error', message: 'The promotion workflow must grant contents: write.' });
}
if (
config.notifications?.githubIssues.events.some((event) =>
['ci-failed', 'promotion-failed'].includes(event),
) &&
!hasWritePermission(promotion.workflow, 'issues')
) {
checks.push({
severity: 'error',
message: 'The promotion workflow must grant issues: write for GitHub issue notifications.',
});
}
const workflowRun = eventConfig(promotion.workflow, 'workflow_run');
const workflows =
typeof workflowRun === 'object' && workflowRun !== null && !Array.isArray(workflowRun)
Expand Down Expand Up @@ -318,6 +345,22 @@ function inspectPatchRefs(config: PatchlaneConfig, sourceSha: string | undefined
}
}

function inspectNotificationAssignees(config: PatchlaneConfig, cwd: string, checks: DoctorCheck[]) {
const assignees = config.notifications?.githubIssues.assignees ?? [];
const repository = githubRepository(remoteUrl(cwd, 'origin'));
if (!assignees.length || !repository || run('gh', ['auth', 'status'], cwd).status !== 0) return;

for (const assignee of assignees) {
const result = run('gh', ['api', `repos/${repository}/assignees/${assignee}`, '--silent'], cwd);
if (result.status !== 0) {
checks.push({
severity: 'warning',
message: `Notification user '${assignee}' could not be verified as assignable to '${repository}'.`,
});
}
}
}

function inspectBootstrap(config: PatchlaneConfig, cwd: string, checks: DoctorCheck[]) {
const remoteBase = `refs/remotes/origin/${config.baseBranch}`;
if (git(['rev-parse', '--verify', '--quiet', remoteBase], cwd).status !== 0) return;
Expand Down Expand Up @@ -348,6 +391,7 @@ export function runDoctor(options: DoctorOptions = {}): DoctorReport {
checks.push({ severity: 'info', message: `Resolved ${config.source} to ${resolved.label} @ ${resolved.sha}.` });
inspectPatchRefs(config, resolved?.sha, cwd, checks);
inspectWorkflows(config, resolved?.sha, cwd, checks);
inspectNotificationAssignees(config, cwd, checks);
inspectBootstrap(config, cwd, checks);

return printReport(
Expand Down
1 change: 1 addition & 0 deletions src/integration-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,7 @@ export function runIntegrationSync(options: IntegrationSyncOptions) {
) {
fail(`Upstream ref '${upstreamRef}' was not fetched from ${upstreamRemoteName}.`);
}
writeOutput('upstream_sha', git(['rev-parse', `${upstreamBase}^{commit}`]).stdout.trim());

const patchRefs = parsePatchRefs(patchRefsRaw);
if (!patchRefs.length) fail('PATCH_REFS did not contain any patch branch names.');
Expand Down
Loading