diff --git a/docs/configuration.md b/docs/configuration.md index 4b59af7..b133e46 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -50,18 +50,26 @@ CLI flags and environment variables override config values for one run. Legacy ` 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 App tokens 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. -## GitHub App authentication +## Workflow authentication -The generated workflows require: +Patchlane automation needs a token that can push repository contents, update workflow files, and start the downstream workflows triggered by those pushes. Enable issue access when GitHub issue notifications are configured. The built-in `GITHUB_TOKEN` is not suitable because GitHub deliberately suppresses most workflow events caused by it; increasing its workflow permissions does not change that behavior. + +The generated workflows use `actions/create-github-app-token` with: - repository variable `PATCHLANE_APP_CLIENT_ID` - repository secret `PATCHLANE_APP_PRIVATE_KEY` -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. +The App installation must grant Contents read/write and Workflows write, plus Issues read/write when notifications are enabled. Generated jobs request the required least-privilege permissions explicitly, pass the token to checkout and `gh` as `GH_TOKEN`, and leave the built-in `GITHUB_TOKEN` read-only. + +Adapted workflows may use another authentication source selected by the maintainer: + +- an action step that exposes a token output; +- a `run` step that writes a token output to `GITHUB_OUTPUT`; or +- an Actions secret containing a suitable GitHub App or user token. -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..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. +For a step output, checkout must use an expression in the form `${{ steps..outputs. }}`. For a stored token, it must use `${{ secrets. }}`. Every `patchlane sync`, `promote`, or `notify` step in that job must receive the exact same expression as `GH_TOKEN`. Compound expressions, environment indirection, `github.token`, and `secrets.GITHUB_TOKEN` are not accepted. Authentication implementation details do not belong in `.patchlane.yml`. -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. +Doctor strictly checks credentials and requested permissions when `actions/create-github-app-token` is used directly. For other sources it validates only the token data flow because it cannot determine statically how the token is minted or which capabilities it has. Confirm custom authentication with the first workflow-driven published sync, including its downstream CI run and promotion. See [Manual setup](manual-setup.md) for the generated GitHub App setup. ## Commands @@ -85,17 +93,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. 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 - -After the generated workflows are present on the base branch, run: - -```bash -npx patchlane verify-auth -``` - -This dispatches `sync-upstream.yml` with `no_push=true`, finds the newly created run, and waits for it to finish. A successful run validates the uploaded App key, installation, requested permissions, API access, authenticated checkout, and Patchlane rebuild without changing a branch. Use `--timeout ` and `--poll-interval ` to override the 60-second discovery timeout and 2-second polling interval. The equivalent environment variables are `PATCHLANE_AUTH_TIMEOUT_SECONDS` and `PATCHLANE_AUTH_POLL_INTERVAL_SECONDS`. +Doctor checks source resolution, remote patch refs, patch bases, composed workflow configuration and policy, CI triggers, authentication-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 standard credential metadata checks are skipped when all jobs use custom token sources. Insufficient metadata access is reported as a warning. GitHub operations resolve the fork from the configured `origin` push URL, independently of `gh repo set-default`. Use `--origin-remote-name ` for a differently named push remote or `--repository ` to override repository detection. diff --git a/docs/manual-setup.md b/docs/manual-setup.md index 879c30c..b984afc 100644 --- a/docs/manual-setup.md +++ b/docs/manual-setup.md @@ -4,9 +4,9 @@ This walkthrough configures an existing GitHub fork. You need Node.js 22+, `git` Already using an earlier Patchlane version? Follow the [migration guide](migrations.md) instead. -## 1. Configure GitHub App authentication +## 1. Configure the generated GitHub App authentication -Patchlane must push as a GitHub App so pushes to `sync/integration` and the promoted base branch trigger their configured workflows. GitHub suppresses most workflow events caused by the built-in `GITHUB_TOKEN`, so granting that token `contents: write` is not sufficient. +Patchlane's generated workflows push as a GitHub App so pushes to `sync/integration` and the promoted base branch trigger their configured workflows. GitHub suppresses most workflow events caused by the built-in `GITHUB_TOKEN`, so granting that token `contents: write` is not sufficient. Adapted workflows may use another token source that satisfies the contract in [Workflow authentication](configuration.md#workflow-authentication). Create or reuse a GitHub App, install it on the fork, and grant these repository permissions: @@ -125,13 +125,7 @@ Then publish, wait for CI, and promote the exact successful SHA: npx patchlane bootstrap --wait ``` -After this succeeds, run the post-bootstrap authentication check: - -```bash -npx patchlane verify-auth -``` - -This dispatches the sync workflow with `no_push=true`, waits for it, and fails if the App credentials, installation, requested permissions, API access, checkout, or rebuild are invalid. It does not change any branches. After it succeeds, scheduled syncs and automatic promotions are active. +After bootstrap, scheduled syncs and automatic promotions are active. On the first workflow-driven sync that publishes a new integration SHA, confirm that authentication succeeds, CI runs as a `push` for that SHA, and promotion updates the base branch. ## Adding product patches diff --git a/docs/migrations.md b/docs/migrations.md index c4ec8c5..dfa8fb0 100644 --- a/docs/migrations.md +++ b/docs/migrations.md @@ -43,9 +43,11 @@ allowedWorkflows: 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. Configure GitHub App authentication +### 3. Choose workflow authentication -Create or reuse a GitHub App installed on the fork with Contents read/write and Workflows write. Add Issues read/write when issue notifications are enabled. Configure its credentials using the standard names: +Inspect the existing workflows and choose how the fork should authenticate before replacing or adapting them. The token must be able to push repository contents, update workflow files, and start downstream workflows. Enable issue access when GitHub issue notifications are configured. Do not use `github.token` or `secrets.GITHUB_TOKEN`: pushes made by the built-in `GITHUB_TOKEN` do not start the required downstream workflow runs. + +The generated 0.5.1 workflows use `actions/create-github-app-token`. For this default, create or reuse a GitHub App installed on the fork with Contents read/write and Workflows write, plus Issues read/write when notifications are enabled. Configure its credentials using the standard names: ```bash FORK=OWNER/REPOSITORY @@ -53,15 +55,17 @@ gh variable set PATCHLANE_APP_CLIENT_ID --repo "$FORK" --body "YOUR_APP_CLIENT_I gh secret set PATCHLANE_APP_PRIVATE_KEY --repo "$FORK" < /path/to/app-private-key.pem ``` -Replace both generated workflow files with the 0.5 templates. Do not merely pass `github.token` as `GH_TOKEN`: that fixes API authentication, but pushes made by the built-in `GITHUB_TOKEN` do not start the required downstream workflow runs. +Alternatively, preserve an established authentication source or use another source selected by the maintainer. Patchlane accepts a token exposed by an action or `run` step as `${{ steps..outputs. }}`, or a suitable App or user token stored as `${{ secrets. }}`. Checkout and every Patchlane command in the job must use the exact same expression. Keep the selected source's existing inputs and secret names; do not request a client ID or duplicate private-key secret unless that source requires them. + +Update both workflow files to the 0.5.1 structure without overwriting the selected authentication implementation. Doctor validates custom token wiring but cannot inspect how the token is created or which capabilities it has. Confirm the selected authentication with the first workflow-driven published sync, including the downstream CI run and promotion. ### 4. Validate before rollout After pushing the updated patch refs, validate with Patchlane 0.5: ```bash -npx patchlane@0.5.0 doctor -npx patchlane@0.5.0 sync --dry-run +npx patchlane@0.5.1 doctor +npx patchlane@0.5.1 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. @@ -71,14 +75,10 @@ Doctor should identify every unexpected or missing workflow by filename. The dry 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@0.5.0 bootstrap --wait +npx patchlane@0.5.1 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. Then validate the uploaded App credentials with a no-push workflow run: - -```bash -npx patchlane@0.5.0 verify-auth -``` +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. On the first workflow-driven sync that publishes a new integration SHA, confirm that authentication succeeds, CI runs as a `push` for that SHA, and promotion updates the base branch. ### 6. Optionally enable maintainer notifications diff --git a/examples/sync-upstream.yml b/examples/sync-upstream.yml index 12cadc1..3824656 100644 --- a/examples/sync-upstream.yml +++ b/examples/sync-upstream.yml @@ -1,5 +1,4 @@ name: Sync Upstream Integration -run-name: ${{ inputs.verification_id && format('Verify Patchlane authentication ({0})', inputs.verification_id) || 'Sync Upstream Integration' }} on: schedule: @@ -21,11 +20,6 @@ on: type: string required: false default: '' - verification_id: - description: Correlate a Patchlane authentication check run. - type: string - required: false - default: '' permissions: contents: read diff --git a/skills/patchlane-fork-setup/SKILL.md b/skills/patchlane-fork-setup/SKILL.md index 9e5d3ce..ac25f25 100644 --- a/skills/patchlane-fork-setup/SKILL.md +++ b/skills/patchlane-fork-setup/SKILL.md @@ -22,20 +22,26 @@ Resolve and show the source tag or branch and commit SHA. Before pushing or rewr ## Configure GitHub authentication -Treat working GitHub App authentication as a setup prerequisite. The built-in `GITHUB_TOKEN` is not sufficient: GitHub suppresses most workflow events caused by it, so its pushes do not reliably start integration CI or the promotion chain. +Treat working workflow authentication as a setup prerequisite. The token must be able to push repository contents, update workflow files, and start downstream workflows. Enable issue access when GitHub issue notifications are configured. The built-in `GITHUB_TOKEN` is not sufficient because GitHub suppresses most workflow events caused by it. -1. Inspect existing workflows and repository secret/variable names for an established GitHub App pattern. Ask whether the user wants to reuse that App or create one; do not silently select an identity. -2. Require the App installation to grant Contents read/write and Workflows write. Require Issues read/write only when GitHub issue notifications are enabled. Actions write is not required. -3. Use the standard repository variable `PATCHLANE_APP_CLIENT_ID` and repository secret `PATCHLANE_APP_PRIVATE_KEY`. An existing App identity can be reused through these names even when other workflows wrap it in a custom action. -4. App creation, permission approval, installation, and private-key generation require the user. Never ask them to paste a private key into chat or print it. After explicit approval, the agent may configure a variable and secret from a local key file with `gh variable set` and `gh secret set`. -5. Check deterministically where possible: - - `gh auth status` - - `gh api repos/OWNER/REPO/actions/permissions` - - `gh variable get PATCHLANE_APP_CLIENT_ID --repo OWNER/REPO` - - `gh secret list --repo OWNER/REPO --json name,updatedAt` -6. Explain that secret metadata proves only that a secret exists. The generated `actions/create-github-app-token` step verifies the installation and requested permissions at runtime. +Inspect existing workflows and available repository secret and variable names. Show what authentication is already established, then ask the user to choose an approach; do not silently select an identity or credential source: -Include all external repository changes in the plan and obtain confirmation before setting variables, secrets, or dispatching workflows. +1. Use Patchlane's generated `actions/create-github-app-token` setup. +2. Preserve an existing token source and its inputs, output name, and secret names. +3. Use another action or `run` step selected by the user to produce a token output. +4. Use an Actions secret containing a suitable GitHub App or user token. + +For the generated setup, use repository variable `PATCHLANE_APP_CLIENT_ID` and repository secret `PATCHLANE_APP_PRIVATE_KEY`. Require the App installation to grant Contents read/write and Workflows write, plus Issues read/write when notifications are enabled. Actions write is not required. + +For another source, do not require the standard variable or secret names. A producing step must have an `id` and expose `${{ steps..outputs. }}`; a stored token must use `${{ secrets. }}`. Checkout and every `patchlane sync`, `promote`, or `notify` command in the job must consume the exact same expression. Do not use compound expressions, environment indirection, `github.token`, or `secrets.GITHUB_TOKEN`. Explain that Doctor can validate custom token wiring but not how the token is minted or which capabilities it has. Confirm write access and downstream workflow triggering with the first workflow-driven published sync, including its downstream CI run and promotion. + +Credential creation, permission approval, App installation, and private-key generation require the user. Never ask them to paste a token or private key into chat or print one. After explicit approval, the agent may configure repository variables and secrets from local files. Check deterministically where possible: + +- `gh auth status` +- `gh api repos/OWNER/REPO/actions/permissions` +- the metadata for variables and secrets used by the selected approach + +Secret metadata proves only that a secret exists. Include all external repository changes in the plan and obtain confirmation before setting variables, secrets, or dispatching workflows. ## Configure the fork @@ -44,7 +50,7 @@ Include all external repository changes in the plan and obtain confirmation befo 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. 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. Preserve the generated GitHub App token creation, explicit permission requests, authenticated checkout, and `GH_TOKEN` wiring. +6. Use `npx patchlane init` to generate `.patchlane.yml` and pinned workflow files when practical, then adapt rather than replace existing repository conventions. Preserve the user's selected authentication source, authenticated checkout, and matching `GH_TOKEN` wiring. For the generated GitHub App source, also preserve its explicit permission requests. 7. Ensure fork CI covers normal pull requests plus pushes to both the generated base and sync branches. Use the bundled assets as invariants when adapting workflows: @@ -81,10 +87,7 @@ The workflows do not exist on the default branch before the first promotion. Boo 2. After user approval, run `npx patchlane bootstrap --publish` and wait for the configured CI workflow. 3. Promote the exact successful SHA printed by bootstrap, or use `npx patchlane bootstrap --wait` to wait and promote automatically. 4. Confirm the generated base is rooted at the selected source. -5. After the workflows are present on the base branch, obtain approval and run `npx patchlane verify-auth`. This dispatches a no-push sync and waits for it, validating the uploaded App key, installation, permissions, API access, checkout, and rebuild without changing branches. -6. On the first App-authenticated published sync, confirm CI ran as a `push` for the exact integration SHA and promotion moved the base branch to that SHA. - -After bootstrap, a remote no-push test can be dispatched safely from the default branch. +5. On the first workflow-driven sync that publishes a new integration SHA, confirm authentication succeeds, CI runs as a `push` for that exact SHA, and promotion moves the base branch to that SHA. ## Finish @@ -96,4 +99,4 @@ Summarize: - files and workflows added or updated - doctor and dry-run results - bootstrap CI and promotion results -- GitHub App metadata and post-bootstrap authentication verification results +- selected authentication approach, relevant credential metadata, and first workflow-driven sync results diff --git a/skills/patchlane-fork-setup/assets/sync-upstream.yml b/skills/patchlane-fork-setup/assets/sync-upstream.yml index 12cadc1..3824656 100644 --- a/skills/patchlane-fork-setup/assets/sync-upstream.yml +++ b/skills/patchlane-fork-setup/assets/sync-upstream.yml @@ -1,5 +1,4 @@ name: Sync Upstream Integration -run-name: ${{ inputs.verification_id && format('Verify Patchlane authentication ({0})', inputs.verification_id) || 'Sync Upstream Integration' }} on: schedule: @@ -21,11 +20,6 @@ on: type: string required: false default: '' - verification_id: - description: Correlate a Patchlane authentication check run. - type: string - required: false - default: '' permissions: contents: read diff --git a/src/cli.ts b/src/cli.ts index 06ad6ae..080fb93 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -10,7 +10,6 @@ import { getPackageVersion } from './package-version.js'; import { runNotification } from './notify.js'; import { runPromoteSync } from './promote-sync.js'; import { parseUpstreamSource } from './upstream-source.js'; -import { verifyGitHubAuth } from './verify-auth.js'; const cli = cac('patchlane'); @@ -107,29 +106,6 @@ cli.command('doctor', 'Check Patchlane configuration without changing repository if (!report.ok) process.exitCode = 1; }); -cli.command('verify-auth', 'Dispatch a no-push sync to verify GitHub App authentication') - .option('--repository ', 'Fork GitHub repository; inferred from the origin push target') - .option('--origin-remote-name ', 'Name of the origin remote', { - default: env('ORIGIN_REMOTE_NAME', 'origin'), - }) - .option('--timeout ', 'Maximum time to wait for the workflow run to appear', { - default: env('PATCHLANE_AUTH_TIMEOUT_SECONDS'), - }) - .option('--poll-interval ', 'Interval between workflow run lookups', { - default: env('PATCHLANE_AUTH_POLL_INTERVAL_SECONDS'), - }) - .action((args) => { - void verifyGitHubAuth({ - repository: args.repository, - originRemoteName: args.originRemoteName, - timeoutSeconds: args.timeout === undefined ? undefined : Number(args.timeout), - pollIntervalSeconds: args.pollInterval === undefined ? undefined : Number(args.pollInterval), - }).catch((error: unknown) => { - process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); - process.exit(1); - }); - }); - cli.command('sync', 'Rebuild integration branch from upstream and patches') .option('--upstream-owner ', 'GitHub owner/org of the upstream repository', { default: env('UPSTREAM_OWNER', config?.upstreamOwner), diff --git a/src/doctor.ts b/src/doctor.ts index 2d1c047..bd4c3e7 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -250,7 +250,21 @@ function invokesPatchlaneCommand(job: Record, command: string) return jobSteps(job).some((step) => typeof step.run === 'string' && pattern.test(step.run)); } -type AuthenticationProvider = 'direct' | 'wrapper'; +type AuthenticationProvider = 'direct' | 'custom'; + +type AuthenticationSource = + { kind: 'step'; stepId: string; outputName: string } | { kind: 'secret'; secretName: string }; + +function authenticationSource(value: unknown): AuthenticationSource | undefined { + if (typeof value !== 'string') return undefined; + const stepOutput = value.match( + /^\$\{\{\s*steps\.([A-Za-z_][A-Za-z0-9_-]*)\.outputs\.([A-Za-z_][A-Za-z0-9_-]*)\s*}}$/, + ); + if (stepOutput) return { kind: 'step', stepId: stepOutput[1], outputName: stepOutput[2] }; + const secret = value.match(/^\$\{\{\s*secrets\.([A-Za-z_][A-Za-z0-9_]*)\s*}}$/); + if (secret && secret[1].toUpperCase() !== 'GITHUB_TOKEN') return { kind: 'secret', secretName: secret[1] }; + return undefined; +} export function inspectAuthenticatedJob( workflowFile: string, @@ -266,64 +280,72 @@ export function inspectAuthenticatedJob( const steps = jobSteps(job); 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) { + const source = authenticationSource(checkoutToken); + if (!source) { checks.push({ severity: 'error', - message: `${workflowFile} job '${jobName}' must check out with a GitHub App token expression in the form \${{ steps..outputs.token }}.`, + message: `${workflowFile} job '${jobName}' must check out with an authentication token from \${{ steps..outputs. }} or \${{ secrets. }}; the built-in GITHUB_TOKEN is not supported.`, }); return undefined; } - const tokenStepId = tokenExpression[1]; - const tokenStep = steps.find((step) => step.id === tokenStepId); - if (!tokenStep) { - checks.push({ - severity: 'error', - 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(', '); + let directProvider = false; + if (source.kind === 'step') { + const tokenStep = steps.find((step) => step.id === source.stepId); + if (!tokenStep) { checks.push({ severity: 'error', - message: `${workflowFile} job '${jobName}' must create a Patchlane GitHub App token with ${permissions}.`, + message: `${workflowFile} job '${jobName}' checkout references missing token producer step '${source.stepId}'.`, + }); + return undefined; + } + if (typeof tokenStep.uses !== 'string' && typeof tokenStep.run !== 'string') { + checks.push({ + severity: 'error', + message: `${workflowFile} job '${jobName}' token producer step '${source.stepId}' must use an action or run a command.`, + }); + return undefined; + } + + directProvider = + typeof tokenStep.uses === 'string' && 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 ( + source.outputName !== 'token' || + !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 { + const producer = + typeof tokenStep.uses === 'string' ? `action '${tokenStep.uses}'` : `run step '${source.stepId}'`; + checks.push({ + severity: 'info', + message: `${workflowFile} job '${jobName}' uses custom token producer ${producer}; its token capabilities cannot be verified statically. Confirm write access and downstream workflow triggering with the first workflow-driven published sync.`, }); } } 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.`, + message: `${workflowFile} job '${jobName}' uses Actions secret '${source.secretName}' as its authentication token; its token capabilities cannot be verified statically. Confirm write access and downstream workflow triggering with the first workflow-driven published sync.`, }); } @@ -333,12 +355,12 @@ export function inspectAuthenticatedJob( 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.`, + message: `${workflowFile} job '${jobName}' must pass the checkout authentication token as GH_TOKEN.`, }); break; } } - return directProvider ? 'direct' : 'wrapper'; + return directProvider ? 'direct' : 'custom'; } export function inspectAuthenticatedCommandJob( @@ -396,13 +418,6 @@ function inspectWorkflows(config: PatchlaneConfig, sourceSha: string | undefined checks, ); if (provider) authenticationProviders.push(provider); - const workflowDispatch = objectValue(eventConfig(sync.workflow, 'workflow_dispatch')); - if (!objectValue(workflowDispatch?.inputs)?.verification_id) { - checks.push({ - severity: 'error', - message: '.github/workflows/sync-upstream.yml must expose the verification_id dispatch input.', - }); - } } if (!promotion?.workflow) { checks.push({ severity: 'error', message: 'Missing .github/workflows/promote-tested-sync.yml.' }); diff --git a/src/verify-auth.ts b/src/verify-auth.ts deleted file mode 100644 index d704b52..0000000 --- a/src/verify-auth.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { randomUUID } from 'node:crypto'; -import { loadPatchlaneConfig } from './config.js'; -import { resolveForkRepository } from './github-repository.js'; -import { run } from './subprocess.js'; - -const DEFAULT_TIMEOUT_SECONDS = 60; -const DEFAULT_POLL_INTERVAL_SECONDS = 2; - -type VerifyAuthOptions = { - cwd?: string; - repository?: string; - originRemoteName?: string; - timeoutSeconds?: number; - pollIntervalSeconds?: number; - verificationId?: string; -}; - -type WorkflowRun = { - databaseId?: unknown; - displayTitle?: unknown; -}; - -function delay(milliseconds: number) { - return new Promise((resolve) => setTimeout(resolve, milliseconds)); -} - -function positiveSeconds(value: number | undefined, fallback: number, name: string) { - const resolved = value ?? fallback; - if (!Number.isFinite(resolved) || resolved <= 0) { - throw new Error(`${name} must be a positive number of seconds.`); - } - return resolved; -} - -function parseRuns(stdout: string) { - let payload: unknown; - try { - payload = JSON.parse(stdout); - } catch { - throw new Error('GitHub returned invalid JSON while looking for the authentication check run.'); - } - if (!Array.isArray(payload)) { - throw new Error('GitHub returned an invalid workflow run list while checking authentication.'); - } - return payload.flatMap((item) => { - if (typeof item !== 'object' || item === null) return []; - const { databaseId, displayTitle } = item as WorkflowRun; - if ((typeof databaseId !== 'number' && typeof databaseId !== 'string') || typeof displayTitle !== 'string') { - return []; - } - return [{ id: String(databaseId), displayTitle }]; - }); -} - -function listWorkflowRuns(repository: string, baseBranch: string, cwd: string) { - const result = run( - 'gh', - [ - 'run', - 'list', - '--repo', - repository, - '--workflow', - 'sync-upstream.yml', - '--event', - 'workflow_dispatch', - '--branch', - baseBranch, - '--limit', - '20', - '--json', - 'databaseId,displayTitle', - ], - cwd, - ); - if (result.status !== 0) { - throw new Error( - `Could not list Patchlane authentication check runs: ${result.stderr || result.stdout || 'unknown error'}`, - ); - } - return parseRuns(result.stdout); -} - -export async function verifyGitHubAuth(options: VerifyAuthOptions = {}) { - const cwd = options.cwd ?? process.cwd(); - const config = loadPatchlaneConfig(cwd); - if (!config) throw new Error('Missing .patchlane.yml. Run `npx patchlane init` first.'); - - const repository = resolveForkRepository({ - cwd, - repository: options.repository, - originRemoteName: options.originRemoteName ?? process.env.ORIGIN_REMOTE_NAME, - }); - const timeoutSeconds = positiveSeconds(options.timeoutSeconds, DEFAULT_TIMEOUT_SECONDS, 'Auth timeout'); - const pollIntervalSeconds = positiveSeconds( - options.pollIntervalSeconds, - DEFAULT_POLL_INTERVAL_SECONDS, - 'Auth poll interval', - ); - const verificationId = options.verificationId ?? randomUUID(); - const expectedTitle = `Verify Patchlane authentication (${verificationId})`; - process.stdout.write(`Dispatching the Patchlane authentication check in ${repository}.\n`); - const dispatch = run( - 'gh', - [ - 'workflow', - 'run', - 'sync-upstream.yml', - '--repo', - repository, - '--ref', - config.baseBranch, - '--field', - 'no_push=true', - '--field', - `verification_id=${verificationId}`, - ], - cwd, - ); - if (dispatch.status !== 0) { - throw new Error( - `Could not dispatch the Patchlane authentication check: ${dispatch.stderr || dispatch.stdout || 'unknown error'}`, - ); - } - - const timeoutAt = Date.now() + timeoutSeconds * 1_000; - let runId: string | undefined; - while (!runId) { - runId = listWorkflowRuns(repository, config.baseBranch, cwd).find( - (workflowRun) => workflowRun.displayTitle === expectedTitle, - )?.id; - if (runId) break; - const remaining = timeoutAt - Date.now(); - if (remaining <= 0) { - throw new Error( - `Timed out after ${timeoutSeconds} seconds waiting for the Patchlane authentication check run to appear.`, - ); - } - await delay(Math.min(pollIntervalSeconds * 1_000, remaining)); - } - - process.stdout.write(`Watching Patchlane authentication check run ${runId}.\n`); - const watch = run('gh', ['run', 'watch', runId, '--repo', repository, '--exit-status'], cwd); - if (watch.stdout) process.stdout.write(`${watch.stdout}\n`); - if (watch.stderr) process.stderr.write(`${watch.stderr}\n`); - if (watch.status !== 0) { - throw new Error(`Patchlane authentication check run ${runId} failed.`); - } - process.stdout.write('Patchlane GitHub App authentication is ready.\n'); - return { status: 'verified' as const, repository, runId }; -} diff --git a/src/workflow-templates.ts b/src/workflow-templates.ts index c7b53e1..8542ebe 100644 --- a/src/workflow-templates.ts +++ b/src/workflow-templates.ts @@ -48,7 +48,6 @@ export function renderSyncWorkflow(config: PatchlaneConfig, packageVersion: stri const notifyFailures = hasEvent(config, 'sync-failed'); const notifyRecovery = notifyFailures && closeOnRecovery(config); return `name: Sync Upstream Integration -run-name: \${{ inputs.verification_id && format('Verify Patchlane authentication ({0})', inputs.verification_id) || 'Sync Upstream Integration' }} on: schedule: @@ -70,11 +69,6 @@ on: type: string required: false default: "" - verification_id: - description: Correlate a Patchlane authentication check run. - type: string - required: false - default: "" ${githubTokenPermissions()} diff --git a/tests/doctor.test.ts b/tests/doctor.test.ts index 79a0582..fa6c4fc 100644 --- a/tests/doctor.test.ts +++ b/tests/doctor.test.ts @@ -55,12 +55,13 @@ function authenticatedJob( tokenStepId?: string; tokenUses?: string; tokenWith?: Record; + tokenOutputName?: string; checkoutToken?: string; ghToken?: string; } = {}, ) { const tokenStepId = options.tokenStepId ?? 'patchlane-token'; - const token = `\${{ steps.${tokenStepId}.outputs.token }}`; + const token = `\${{ steps.${tokenStepId}.outputs.${options.tokenOutputName ?? 'token'} }}`; return { steps: [ ...(options.includeTokenStep === false @@ -136,15 +137,20 @@ describe('inspectAuthenticatedJob', () => { expect(inspectJob(authenticatedJob())).toEqual([]); }); - test('accepts a wrapper action when checkout and Patchlane use its token output', () => { + test('accepts a custom action when checkout and Patchlane use its token output', () => { const checks = inspectJob( - authenticatedJob({ tokenStepId: 'not-adam', tokenUses: 'adampoit/not-adam@v1', tokenWith: {} }), + authenticatedJob({ + tokenStepId: 'custom-auth', + tokenUses: 'example/custom-auth@v1', + tokenWith: {}, + tokenOutputName: 'access_token', + }), ); expect(checks).toEqual([ { severity: 'info', message: expect.stringContaining( - "uses token wrapper 'adampoit/not-adam@v1'; its internal GitHub App permissions cannot be verified statically", + "uses custom token producer action 'example/custom-auth@v1'; its token capabilities cannot be verified statically", ), }, ]); @@ -162,22 +168,64 @@ describe('inspectAuthenticatedJob', () => { ).toContainEqual(expect.objectContaining({ message: expect.stringContaining("producer step 'missing'") })); }); - test('reports a non-action token producer', () => { + test('accepts a run step as an opaque token producer', () => { + const job = authenticatedJob({ tokenOutputName: 'access_token' }) as { steps: Record[] }; + job.steps[0] = { + id: 'patchlane-token', + run: 'echo "access_token=$MINTED_TOKEN" >> "$GITHUB_OUTPUT"', + }; + expect(inspectJob(job)).toEqual([ + { + severity: 'info', + message: expect.stringContaining( + "uses custom token producer run step 'patchlane-token'; its token capabilities cannot be verified statically", + ), + }, + ]); + }); + + test('reports a token producer without an action or command', () => { const job = authenticatedJob() as { steps: Record[] }; - job.steps[0] = { id: 'patchlane-token', run: 'echo token' }; + job.steps[0] = { id: 'patchlane-token', name: 'Missing implementation' }; expect(inspectJob(job)).toContainEqual( - expect.objectContaining({ message: expect.stringContaining('must use an action') }), + expect.objectContaining({ message: expect.stringContaining('must use an action or run a command') }), ); }); - test.each(['${{ github.token }}', '${{ secrets.GITHUB_TOKEN }}', '${{ steps.patchlane-token.outputs.other }}'])( - 'reports invalid checkout token expression %s', - (checkoutToken) => { - expect(inspectJob(authenticatedJob({ checkoutToken }))).toContainEqual( - expect.objectContaining({ message: expect.stringContaining('steps..outputs.token') }), - ); - }, - ); + test('accepts a repository secret as the authentication token', () => { + expect( + inspectJob( + authenticatedJob({ + includeTokenStep: false, + checkoutToken: '${{ secrets.PATCHLANE_TOKEN }}', + ghToken: '${{ secrets.PATCHLANE_TOKEN }}', + }), + ), + ).toEqual([ + { + severity: 'info', + message: expect.stringContaining("uses Actions secret 'PATCHLANE_TOKEN' as its authentication token"), + }, + ]); + }); + + test.each([ + '${{ github.token }}', + '${{ secrets.GITHUB_TOKEN }}', + '${{ secrets.github_token }}', + '${{ env.PATCHLANE_TOKEN }}', + '${{ steps.patchlane-token.outputs.token || secrets.PATCHLANE_TOKEN }}', + ])('reports invalid checkout token expression %s', (checkoutToken) => { + expect(inspectJob(authenticatedJob({ checkoutToken }))).toContainEqual( + expect.objectContaining({ message: expect.stringContaining('steps..outputs.') }), + ); + }); + + test('requires the standard token output from the direct App provider', () => { + expect(inspectJob(authenticatedJob({ tokenOutputName: 'access_token' }))).toEqual([ + expect.objectContaining({ message: expect.stringContaining('must create a Patchlane GitHub App token') }), + ]); + }); test.each([ ['client ID', { 'client-id': '${{ vars.WRONG_CLIENT_ID }}' }], @@ -192,7 +240,7 @@ describe('inspectAuthenticatedJob', () => { ]); }); - test('reports a wrapper token mismatch between checkout and Patchlane', () => { + test('reports a custom token mismatch between checkout and Patchlane', () => { expect( inspectJob( authenticatedJob({ @@ -202,13 +250,17 @@ describe('inspectAuthenticatedJob', () => { }), ), ).toContainEqual( - expect.objectContaining({ message: expect.stringContaining('must pass the Patchlane GitHub App token') }), + expect.objectContaining({ + message: expect.stringContaining('must pass the checkout authentication token'), + }), ); }); test('reports a Patchlane command without GH_TOKEN', () => { expect(inspectJob(authenticatedJob({ ghToken: '${{ github.token }}' }))).toContainEqual( - expect.objectContaining({ message: expect.stringContaining('must pass the Patchlane GitHub App token') }), + expect.objectContaining({ + message: expect.stringContaining('must pass the checkout authentication token'), + }), ); }); @@ -219,7 +271,9 @@ describe('inspectAuthenticatedJob', () => { env: { GH_TOKEN: '${{ steps.other.outputs.token }}' }, }); expect(inspectJob(job)).toContainEqual( - expect.objectContaining({ message: expect.stringContaining('must pass the Patchlane GitHub App token') }), + expect.objectContaining({ + message: expect.stringContaining('must pass the checkout authentication token'), + }), ); }); }); @@ -241,7 +295,7 @@ describe('inspectAuthenticatedCommandJob', () => { expect(inspectJobs({ sync: authenticatedJob() })).toEqual([]); }); - test('accepts a wrapper action in a custom sync job', () => { + test('accepts a custom authentication action in a custom sync job', () => { expect( inspectJobs({ update: authenticatedJob({ @@ -253,7 +307,9 @@ describe('inspectAuthenticatedCommandJob', () => { ).toEqual([ { severity: 'info', - message: expect.stringContaining("job 'update' uses token wrapper 'adampoit/not-adam@v1'"), + message: expect.stringContaining( + "job 'update' uses custom token producer action 'adampoit/not-adam@v1'", + ), }, ]); }); @@ -324,7 +380,7 @@ describe('inspectGitHubAutomation', () => { ]); }); - test('skips standard credential metadata checks for wrapper providers', () => { + test('skips standard credential metadata checks for custom token providers', () => { const checks: DoctorCheck[] = []; const endpoints: string[] = []; const runner = (_command: string, args: string[]) => { diff --git a/tests/verify-auth.test.ts b/tests/verify-auth.test.ts deleted file mode 100644 index db401fb..0000000 --- a/tests/verify-auth.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; -import { loadPatchlaneConfig, type PatchlaneConfig } from '../src/config.js'; -import { resolveForkRepository } from '../src/github-repository.js'; -import { run, type CommandResult } from '../src/subprocess.js'; -import { verifyGitHubAuth } from '../src/verify-auth.js'; - -vi.mock('../src/config.js', () => ({ loadPatchlaneConfig: vi.fn() })); -vi.mock('../src/github-repository.js', () => ({ resolveForkRepository: vi.fn() })); -vi.mock('../src/subprocess.js', () => ({ run: vi.fn() })); - -const cwd = '/tmp/patchlane-auth'; -const repository = 'example/fork'; -const config: PatchlaneConfig = { - upstreamOwner: 'example', - upstreamRepo: 'upstream', - source: 'release:latest', - baseBranch: 'main', - syncBranch: 'sync/integration', - patchRefs: ['patch/sync'], - ciWorkflow: 'Fork CI', - allowedWorkflows: ['ci.yml'], -}; - -function result(overrides: Partial = {}): CommandResult { - return { status: 0, stdout: '', stderr: '', ...overrides }; -} - -function runs(...workflowRuns: Array<{ databaseId: number; displayTitle: string }>) { - return result({ stdout: JSON.stringify(workflowRuns) }); -} - -function verificationRun(databaseId = 11) { - return { databaseId, displayTitle: 'Verify Patchlane authentication (verify-123)' }; -} - -beforeEach(() => { - vi.resetAllMocks(); - vi.mocked(loadPatchlaneConfig).mockReturnValue(config); - vi.mocked(resolveForkRepository).mockReturnValue(repository); -}); - -afterEach(() => { - vi.useRealTimers(); -}); - -describe('verifyGitHubAuth', () => { - test('dispatches and watches a correlated no-push workflow run', async () => { - vi.mocked(run) - .mockReturnValueOnce(result()) - .mockReturnValueOnce(runs(verificationRun(), { databaseId: 10, displayTitle: 'Sync Upstream Integration' })) - .mockReturnValueOnce(result({ stdout: 'Authentication passed' })); - - await expect(verifyGitHubAuth({ cwd, verificationId: 'verify-123' })).resolves.toEqual({ - status: 'verified', - repository, - runId: '11', - }); - expect(resolveForkRepository).toHaveBeenCalledWith({ - cwd, - repository: undefined, - originRemoteName: undefined, - }); - expect(run).toHaveBeenNthCalledWith( - 1, - 'gh', - [ - 'workflow', - 'run', - 'sync-upstream.yml', - '--repo', - repository, - '--ref', - 'main', - '--field', - 'no_push=true', - '--field', - 'verification_id=verify-123', - ], - cwd, - ); - expect(run).toHaveBeenLastCalledWith('gh', ['run', 'watch', '11', '--repo', repository, '--exit-status'], cwd); - }); - - test('waits for GitHub to expose the correlated run', async () => { - vi.useFakeTimers(); - vi.mocked(run) - .mockReturnValueOnce(result()) - .mockReturnValueOnce(runs({ databaseId: 10, displayTitle: 'Sync Upstream Integration' })) - .mockReturnValueOnce(runs(verificationRun())) - .mockReturnValueOnce(result()); - - const verification = verifyGitHubAuth({ - cwd, - pollIntervalSeconds: 2, - verificationId: 'verify-123', - }); - await vi.advanceTimersByTimeAsync(2_000); - await expect(verification).resolves.toEqual(expect.objectContaining({ runId: '11' })); - }); - - test('reports a failed authentication workflow', async () => { - vi.mocked(run) - .mockReturnValueOnce(result()) - .mockReturnValueOnce(runs(verificationRun())) - .mockReturnValueOnce(result({ status: 1, stderr: 'token creation failed' })); - - await expect(verifyGitHubAuth({ cwd, verificationId: 'verify-123' })).rejects.toThrow( - 'Patchlane authentication check run 11 failed.', - ); - }); -});