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: 3 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ The generated workflows require:

The App installation must grant Contents read/write and Workflows write. Enable Issues read/write when GitHub issue notifications are configured. Patchlane requests these permissions explicitly when creating each short-lived token, passes the token to checkout and `gh` as `GH_TOKEN`, and leaves the built-in `GITHUB_TOKEN` read-only.

Adapted workflows may instead use a composite action that creates the App token and exposes it as `outputs.token`. Checkout and every `patchlane sync`, `promote`, or `notify` step in a job must consume the same `${{ steps.<id>.outputs.token }}` expression. No wrapper details belong in `.patchlane.yml`. Doctor validates this token flow but cannot inspect the wrapper's internal permission requests or credentials; use `verify-auth` for runtime validation. Generated workflows continue to use `actions/create-github-app-token` directly with the standard credentials and explicit least-privilege permissions.

A GitHub App or user token is required for pushes that must start another workflow. GitHub deliberately suppresses most workflow events caused by the built-in `GITHUB_TOKEN`; increasing its workflow permissions does not change that behavior. See [Manual setup](manual-setup.md) for App creation and repository configuration.

## Commands
Expand All @@ -83,7 +85,7 @@ npx patchlane doctor
npx patchlane doctor --json
```

Doctor checks source resolution, remote patch refs, patch bases, composed workflow configuration and policy, CI triggers, App-token wiring, permissions, and bootstrap state without changing repository state. For GitHub origins it also attempts to inspect Actions enablement and the names—not values—of the expected repository variable and secret. Insufficient metadata access is reported as a warning.
Doctor checks source resolution, remote patch refs, patch bases, composed workflow configuration and policy, CI triggers, App-token wiring, permissions, and bootstrap state without changing repository state. For GitHub origins it also attempts to inspect Actions enablement. When a job uses `actions/create-github-app-token` directly, Doctor additionally inspects the names—not values—of the expected repository variable and secret and strictly validates the action's credential and permission inputs. These direct-provider metadata checks are skipped for wrapper-only workflows. Insufficient metadata access is reported as a warning.

### Verify workflow authentication

Expand Down
115 changes: 73 additions & 42 deletions src/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,7 @@ import { parse } from 'yaml';
import { loadPatchlaneConfig, type PatchlaneConfig } from './config.js';
import { parseUpstreamSource } from './upstream-source.js';
import { validateWorkflowPolicy } from './workflow-policy.js';
import {
GITHUB_APP_CLIENT_ID_VARIABLE,
GITHUB_APP_PRIVATE_KEY_SECRET,
GITHUB_APP_TOKEN_STEP_ID,
} from './workflow-templates.js';
import { GITHUB_APP_CLIENT_ID_VARIABLE, GITHUB_APP_PRIVATE_KEY_SECRET } from './workflow-templates.js';

export type DoctorCheck = {
severity: 'error' | 'warning' | 'info';
Expand Down Expand Up @@ -249,71 +245,99 @@ function jobSteps(job: Record<string, unknown> | undefined) {
return Array.isArray(steps) ? steps.flatMap((step) => (objectValue(step) ? [objectValue(step)!] : [])) : [];
}

type AuthenticationProvider = 'direct' | 'wrapper';

export function inspectAuthenticatedJob(
workflowFile: string,
jobName: string,
job: Record<string, unknown> | undefined,
requirements: { contents: 'read' | 'write'; workflows?: boolean; issues?: boolean },
checks: DoctorCheck[],
) {
): AuthenticationProvider | undefined {
if (!job) {
checks.push({ severity: 'error', message: `${workflowFile} must define the '${jobName}' job.` });
return;
return undefined;
}
const steps = jobSteps(job);
const tokenStep = steps.find(
(step) =>
step.id === GITHUB_APP_TOKEN_STEP_ID &&
typeof step.uses === 'string' &&
step.uses.startsWith('actions/create-github-app-token@'),
);
const tokenWith = objectValue(tokenStep?.with);
const expectedToken = `\${{ steps.${GITHUB_APP_TOKEN_STEP_ID}.outputs.token }}`;
const expectedClientId = `\${{ vars.${GITHUB_APP_CLIENT_ID_VARIABLE} }}`;
const expectedPrivateKey = `\${{ secrets.${GITHUB_APP_PRIVATE_KEY_SECRET} }}`;
if (
!tokenWith ||
tokenWith['client-id'] !== expectedClientId ||
tokenWith['private-key'] !== expectedPrivateKey ||
tokenWith['permission-contents'] !== requirements.contents ||
(requirements.workflows && tokenWith['permission-workflows'] !== 'write') ||
(requirements.issues && tokenWith['permission-issues'] !== 'write')
) {
const permissions = [
`contents: ${requirements.contents}`,
requirements.workflows ? 'workflows: write' : '',
requirements.issues ? 'issues: write' : '',
]
.filter(Boolean)
.join(', ');
const checkout = steps.find((step) => typeof step.uses === 'string' && step.uses.startsWith('actions/checkout@'));
const checkoutToken = objectValue(checkout?.with)?.token;
const tokenExpression =
typeof checkoutToken === 'string'
? checkoutToken.match(/^\$\{\{\s*steps\.([A-Za-z_][A-Za-z0-9_-]*)\.outputs\.token\s*}}$/)
: undefined;
if (!tokenExpression) {
checks.push({
severity: 'error',
message: `${workflowFile} job '${jobName}' must create a Patchlane GitHub App token with ${permissions}.`,
message: `${workflowFile} job '${jobName}' must check out with a GitHub App token expression in the form \${{ steps.<id>.outputs.token }}.`,
});
return undefined;
}

const checkout = steps.find((step) => typeof step.uses === 'string' && step.uses.startsWith('actions/checkout@'));
if (objectValue(checkout?.with)?.token !== expectedToken) {
const tokenStepId = tokenExpression[1];
const tokenStep = steps.find((step) => step.id === tokenStepId);
if (!tokenStep) {
checks.push({
severity: 'error',
message: `${workflowFile} job '${jobName}' must check out with the Patchlane GitHub App token.`,
message: `${workflowFile} job '${jobName}' checkout references missing token producer step '${tokenStepId}'.`,
});
return undefined;
}
if (typeof tokenStep.uses !== 'string') {
checks.push({
severity: 'error',
message: `${workflowFile} job '${jobName}' token producer step '${tokenStepId}' must use an action.`,
});
return undefined;
}

const directProvider = tokenStep.uses.startsWith('actions/create-github-app-token@');
if (directProvider) {
const tokenWith = objectValue(tokenStep.with);
const expectedClientId = `\${{ vars.${GITHUB_APP_CLIENT_ID_VARIABLE} }}`;
const expectedPrivateKey = `\${{ secrets.${GITHUB_APP_PRIVATE_KEY_SECRET} }}`;
if (
!tokenWith ||
tokenWith['client-id'] !== expectedClientId ||
tokenWith['private-key'] !== expectedPrivateKey ||
tokenWith['permission-contents'] !== requirements.contents ||
(requirements.workflows && tokenWith['permission-workflows'] !== 'write') ||
(requirements.issues && tokenWith['permission-issues'] !== 'write')
) {
const permissions = [
`contents: ${requirements.contents}`,
requirements.workflows ? 'workflows: write' : '',
requirements.issues ? 'issues: write' : '',
]
.filter(Boolean)
.join(', ');
checks.push({
severity: 'error',
message: `${workflowFile} job '${jobName}' must create a Patchlane GitHub App token with ${permissions}.`,
});
}
} else {
checks.push({
severity: 'info',
message: `${workflowFile} job '${jobName}' uses token wrapper '${tokenStep.uses}'; its internal GitHub App permissions cannot be verified statically. Run patchlane verify-auth to validate the token at runtime.`,
});
}

for (const step of steps.filter(
(step) => typeof step.run === 'string' && /\bpatchlane(?:@[^\s]+)?\s+(?:sync|promote|notify)\b/.test(step.run),
)) {
if (objectValue(step.env)?.GH_TOKEN !== expectedToken) {
if (objectValue(step.env)?.GH_TOKEN !== checkoutToken) {
checks.push({
severity: 'error',
message: `${workflowFile} job '${jobName}' must pass the Patchlane GitHub App token as GH_TOKEN.`,
});
break;
}
}
return directProvider ? 'direct' : 'wrapper';
}

function inspectWorkflows(config: PatchlaneConfig, sourceSha: string | undefined, cwd: string, checks: DoctorCheck[]) {
const authenticationProviders: AuthenticationProvider[] = [];
const files = workflowFiles(config, sourceSha, cwd);
for (const violation of validateWorkflowPolicy(config.allowedWorkflows, files)) {
checks.push({ severity: 'error', message: violation.message });
Expand All @@ -328,13 +352,14 @@ function inspectWorkflows(config: PatchlaneConfig, sourceSha: string | undefined
const promotionNotifications = config.notifications?.githubIssues.events.includes('promotion-failed') ?? false;
if (!sync?.workflow) checks.push({ severity: 'error', message: 'Missing .github/workflows/sync-upstream.yml.' });
else {
inspectAuthenticatedJob(
const provider = inspectAuthenticatedJob(
'.github/workflows/sync-upstream.yml',
'fork-sync',
workflowJob(sync.workflow, 'fork-sync'),
{ contents: 'write', workflows: true, issues: syncNotifications },
checks,
);
if (provider) authenticationProviders.push(provider);
const workflowDispatch = objectValue(eventConfig(sync.workflow, 'workflow_dispatch'));
if (!objectValue(workflowDispatch?.inputs)?.verification_id) {
checks.push({
Expand All @@ -346,7 +371,7 @@ function inspectWorkflows(config: PatchlaneConfig, sourceSha: string | undefined
if (!promotion?.workflow) {
checks.push({ severity: 'error', message: 'Missing .github/workflows/promote-tested-sync.yml.' });
} else {
inspectAuthenticatedJob(
const promotionProvider = inspectAuthenticatedJob(
'.github/workflows/promote-tested-sync.yml',
'promote',
workflowJob(promotion.workflow, 'promote'),
Expand All @@ -359,14 +384,16 @@ function inspectWorkflows(config: PatchlaneConfig, sourceSha: string | undefined
},
checks,
);
if (promotionProvider) authenticationProviders.push(promotionProvider);
if (ciNotifications) {
inspectAuthenticatedJob(
const notificationProvider = inspectAuthenticatedJob(
'.github/workflows/promote-tested-sync.yml',
'notify-ci-failure',
workflowJob(promotion.workflow, 'notify-ci-failure'),
{ contents: 'read', issues: true },
checks,
);
if (notificationProvider) authenticationProviders.push(notificationProvider);
}
const workflowRun = eventConfig(promotion.workflow, 'workflow_run');
const workflows =
Expand Down Expand Up @@ -394,6 +421,7 @@ function inspectWorkflows(config: PatchlaneConfig, sourceSha: string | undefined
}
}
}
return authenticationProviders;
}

function inspectPatchRefs(config: PatchlaneConfig, sourceSha: string | undefined, cwd: string, checks: DoctorCheck[]) {
Expand Down Expand Up @@ -437,6 +465,7 @@ export function inspectGitHubAutomation(
cwd: string,
checks: DoctorCheck[],
runCommand: typeof run = run,
inspectDirectProviderCredentials = true,
) {
if (!repository) return;

Expand All @@ -452,6 +481,8 @@ export function inspectGitHubAutomation(
});
}

if (!inspectDirectProviderCredentials) return;

const variables = runCommand(
'gh',
['api', '--paginate', `repos/${repository}/actions/variables?per_page=100`, '--jq', '.variables[].name'],
Expand Down Expand Up @@ -536,9 +567,9 @@ export function runDoctor(options: DoctorOptions = {}): DoctorReport {
if (resolved)
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);
const authenticationProviders = inspectWorkflows(config, resolved?.sha, cwd, checks);
const repository = githubRepository(remoteUrl(cwd, 'origin'));
inspectGitHubAutomation(repository, cwd, checks);
inspectGitHubAutomation(repository, cwd, checks, run, authenticationProviders.includes('direct'));
inspectNotificationAssignees(config, cwd, checks);
inspectBootstrap(config, cwd, checks);

Expand Down
Loading