diff --git a/docs/configuration.md b/docs/configuration.md index b5c9590..f68da5d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -48,7 +48,18 @@ 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. +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 + +The generated workflows require: + +- 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. + +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 @@ -72,7 +83,17 @@ npx patchlane doctor npx patchlane doctor --json ``` -Doctor checks source resolution, remote patch refs, patch bases, composed workflow configuration and policy, 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, 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. + +### 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`. ### Validate or publish a sync diff --git a/docs/manual-setup.md b/docs/manual-setup.md index 630b45a..879c30c 100644 --- a/docs/manual-setup.md +++ b/docs/manual-setup.md @@ -4,7 +4,27 @@ 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. Choose the upstream source +## 1. Configure 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. + +Create or reuse a GitHub App, install it on the fork, and grant these repository permissions: + +- **Contents: read and write** +- **Workflows: write** +- **Issues: read and write** only when GitHub issue notifications are enabled + +The App does not need Actions write permission. Disable webhooks unless the App has another use that requires them. Record its Client ID and generate a private key, then configure the repository without printing the private key: + +```bash +FORK=OWNER/REPOSITORY +gh variable set PATCHLANE_APP_CLIENT_ID --repo "$FORK" --body "YOUR_APP_CLIENT_ID" +gh secret set PATCHLANE_APP_PRIVATE_KEY --repo "$FORK" < /path/to/app-private-key.pem +``` + +The generated workflows use `actions/create-github-app-token` to request only the permissions needed by each job. Token creation fails if the App is not installed on the fork or lacks a requested permission. + +## 2. Choose the upstream source Add the upstream remote if needed: @@ -32,7 +52,7 @@ git fetch upstream main Keep `SOURCE_REF` unchanged for the remaining steps. Every patch branch must start independently from this commit. -## 2. Create the Patchlane patch +## 3. Create the Patchlane patch Find the exact `name:` of the existing CI workflow, then create `patch/sync`: @@ -52,7 +72,7 @@ git push -u origin patch/sync Replace `CI` with the existing workflow's exact name. Do not rename the workflow just for Patchlane. -## 3. Make CI test generated syncs +## 4. Make CI test generated syncs Create `patch/ci` from the same source—not from `patch/sync`: @@ -79,7 +99,7 @@ git commit -m "Run fork CI on Patchlane syncs" git push -u origin patch/ci ``` -## 4. Validate +## 5. Validate Return to the branch containing `.patchlane.yml`: @@ -89,9 +109,9 @@ npx patchlane doctor npx patchlane sync --dry-run ``` -`doctor` should have no errors. A bootstrap warning is expected because the workflows are not on the generated base branch yet. +`doctor` should have no errors. It checks the generated App-token wiring and, when your local `gh` credentials permit it, confirms that Actions is enabled and the expected repository variable and secret exist. GitHub does not expose secret values, so this cannot prove that the uploaded private key is valid. A bootstrap warning is expected because the workflows are not on the generated base branch yet. -## 5. Bootstrap the first promotion +## 6. Bootstrap the first promotion Validate once more without publishing: @@ -105,7 +125,13 @@ Then publish, wait for CI, and promote the exact successful SHA: npx patchlane bootstrap --wait ``` -After this succeeds, scheduled syncs and automatic promotions are active. +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. ## Adding product patches diff --git a/docs/migrations.md b/docs/migrations.md index ef57758..92089d2 100644 --- a/docs/migrations.md +++ b/docs/migrations.md @@ -4,7 +4,7 @@ Follow the section for the version you are adopting. Migration notes are listed ## 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. +vNext requires every version-1 `.patchlane.yml` file to define `allowedWorkflows` and updates generated workflows to authenticate with a GitHub App. 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 @@ -43,7 +43,19 @@ 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. Validate before rollout +### 3. Configure GitHub App 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: + +```bash +FORK=OWNER/REPOSITORY +gh variable set PATCHLANE_APP_CLIENT_ID --repo "$FORK" --body "YOUR_APP_CLIENT_ID" +gh secret set PATCHLANE_APP_PRIVATE_KEY --repo "$FORK" < /path/to/app-private-key.pem +``` + +Replace both generated workflow files with the vNext 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. + +### 4. Validate before rollout After pushing the updated patch refs, validate with the vNext package version selected for the migration: @@ -54,7 +66,7 @@ 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 +### 5. 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: @@ -62,9 +74,13 @@ Update the pinned Patchlane version in the sync and promotion workflows as part 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. +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@VERSION verify-auth +``` -### 5. Optionally enable maintainer notifications +### 6. Optionally enable maintainer notifications Failure notifications are opt-in. Existing configurations that omit `notifications` keep their current behavior and require no notification-specific migration. @@ -87,8 +103,8 @@ notifications: 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. +- `sync-upstream.yml` must request `permission-issues: write` for its App token, report `sync-failed` after a failed sync, and optionally report recovery after success. +- `promote-tested-sync.yml` must request `permission-issues: write` in jobs that report issues, 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. diff --git a/examples/promote-tested-sync.yml b/examples/promote-tested-sync.yml index 2f1982e..f67e2fb 100644 --- a/examples/promote-tested-sync.yml +++ b/examples/promote-tested-sync.yml @@ -6,7 +6,7 @@ on: types: [completed] permissions: - contents: write + contents: read jobs: promote: @@ -15,10 +15,18 @@ jobs: github.event.workflow_run.head_branch == 'sync/integration' runs-on: ubuntu-latest steps: + - name: Create Patchlane token + id: patchlane-token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.PATCHLANE_APP_CLIENT_ID }} + private-key: ${{ secrets.PATCHLANE_APP_PRIVATE_KEY }} + permission-contents: write + permission-workflows: write - uses: actions/checkout@v4 with: fetch-depth: 0 - token: ${{ secrets.GITHUB_TOKEN }} + token: ${{ steps.patchlane-token.outputs.token }} - uses: actions/setup-node@v4 with: @@ -27,4 +35,5 @@ jobs: - name: Run patchlane promote run: npx patchlane@0.4.1 promote env: + GH_TOKEN: ${{ steps.patchlane-token.outputs.token }} EXPECTED_SYNC_SHA: ${{ github.event.workflow_run.head_sha }} diff --git a/examples/sync-upstream.yml b/examples/sync-upstream.yml index 0a042d2..5e791b8 100644 --- a/examples/sync-upstream.yml +++ b/examples/sync-upstream.yml @@ -1,4 +1,5 @@ name: Sync Upstream Integration +run-name: ${{ inputs.verification_id && format('Verify Patchlane authentication ({0})', inputs.verification_id) || 'Sync Upstream Integration' }} on: schedule: @@ -20,18 +21,31 @@ on: type: string required: false default: '' + verification_id: + description: Correlate a Patchlane authentication check run. + type: string + required: false + default: '' permissions: - contents: write + contents: read jobs: fork-sync: runs-on: ubuntu-latest steps: + - name: Create Patchlane token + id: patchlane-token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.PATCHLANE_APP_CLIENT_ID }} + private-key: ${{ secrets.PATCHLANE_APP_PRIVATE_KEY }} + permission-contents: write + permission-workflows: write - uses: actions/checkout@v4 with: fetch-depth: 0 - token: ${{ secrets.GITHUB_TOKEN }} + token: ${{ steps.patchlane-token.outputs.token }} - uses: actions/setup-node@v4 with: @@ -40,6 +54,7 @@ jobs: - name: Run patchlane sync run: npx patchlane@0.4.1 sync env: + GH_TOKEN: ${{ steps.patchlane-token.outputs.token }} UPSTREAM_SOURCE: ${{ inputs.source }} PATCH_REFS: ${{ inputs.patch_refs }} NO_PUSH: ${{ inputs.no_push || false }} diff --git a/skills/patchlane-fork-setup/SKILL.md b/skills/patchlane-fork-setup/SKILL.md index e86202c..9e5d3ce 100644 --- a/skills/patchlane-fork-setup/SKILL.md +++ b/skills/patchlane-fork-setup/SKILL.md @@ -18,7 +18,24 @@ Ask the user which upstream source to track. Do not infer this from version file - `release:` for matching release tags - `branch:` for an upstream development branch -Resolve and show the source tag or branch and commit SHA. Before pushing or rewriting branches, show the complete plan and get confirmation. Include the source, base branch, sync branch, ordered patch refs, existing CI workflow name, and any force-pushes required. +Resolve and show the source tag or branch and commit SHA. Before pushing or rewriting branches, show the complete plan and get confirmation. Include the source, base branch, sync branch, ordered patch refs, existing CI workflow name, GitHub authentication approach, and any force-pushes required. + +## 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. + +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. + +Include all external repository changes in the plan and obtain confirmation before setting variables, secrets, or dispatching workflows. ## Configure the fork @@ -27,7 +44,7 @@ Resolve and show the source tag or branch and commit SHA. Before pushing or rewr 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. +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. 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: @@ -63,7 +80,9 @@ The workflows do not exist on the default branch before the first promotion. Boo 1. Run `npx patchlane bootstrap` to validate without publishing. 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 and that future workflows are active. +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. @@ -77,3 +96,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 diff --git a/skills/patchlane-fork-setup/assets/promote-tested-sync.yml b/skills/patchlane-fork-setup/assets/promote-tested-sync.yml index 2f1982e..f67e2fb 100644 --- a/skills/patchlane-fork-setup/assets/promote-tested-sync.yml +++ b/skills/patchlane-fork-setup/assets/promote-tested-sync.yml @@ -6,7 +6,7 @@ on: types: [completed] permissions: - contents: write + contents: read jobs: promote: @@ -15,10 +15,18 @@ jobs: github.event.workflow_run.head_branch == 'sync/integration' runs-on: ubuntu-latest steps: + - name: Create Patchlane token + id: patchlane-token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.PATCHLANE_APP_CLIENT_ID }} + private-key: ${{ secrets.PATCHLANE_APP_PRIVATE_KEY }} + permission-contents: write + permission-workflows: write - uses: actions/checkout@v4 with: fetch-depth: 0 - token: ${{ secrets.GITHUB_TOKEN }} + token: ${{ steps.patchlane-token.outputs.token }} - uses: actions/setup-node@v4 with: @@ -27,4 +35,5 @@ jobs: - name: Run patchlane promote run: npx patchlane@0.4.1 promote env: + GH_TOKEN: ${{ steps.patchlane-token.outputs.token }} EXPECTED_SYNC_SHA: ${{ github.event.workflow_run.head_sha }} diff --git a/skills/patchlane-fork-setup/assets/sync-upstream.yml b/skills/patchlane-fork-setup/assets/sync-upstream.yml index 0a042d2..5e791b8 100644 --- a/skills/patchlane-fork-setup/assets/sync-upstream.yml +++ b/skills/patchlane-fork-setup/assets/sync-upstream.yml @@ -1,4 +1,5 @@ name: Sync Upstream Integration +run-name: ${{ inputs.verification_id && format('Verify Patchlane authentication ({0})', inputs.verification_id) || 'Sync Upstream Integration' }} on: schedule: @@ -20,18 +21,31 @@ on: type: string required: false default: '' + verification_id: + description: Correlate a Patchlane authentication check run. + type: string + required: false + default: '' permissions: - contents: write + contents: read jobs: fork-sync: runs-on: ubuntu-latest steps: + - name: Create Patchlane token + id: patchlane-token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.PATCHLANE_APP_CLIENT_ID }} + private-key: ${{ secrets.PATCHLANE_APP_PRIVATE_KEY }} + permission-contents: write + permission-workflows: write - uses: actions/checkout@v4 with: fetch-depth: 0 - token: ${{ secrets.GITHUB_TOKEN }} + token: ${{ steps.patchlane-token.outputs.token }} - uses: actions/setup-node@v4 with: @@ -40,6 +54,7 @@ jobs: - name: Run patchlane sync run: npx patchlane@0.4.1 sync env: + GH_TOKEN: ${{ steps.patchlane-token.outputs.token }} UPSTREAM_SOURCE: ${{ inputs.source }} PATCH_REFS: ${{ inputs.patch_refs }} NO_PUSH: ${{ inputs.no_push || false }} diff --git a/src/cli.ts b/src/cli.ts index 5716c04..685fdee 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -10,6 +10,7 @@ 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'); @@ -100,6 +101,23 @@ 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('--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({ + 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 c267bae..bf85923 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -6,6 +6,11 @@ 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'; export type DoctorCheck = { severity: 'error' | 'warning' | 'info'; @@ -229,14 +234,83 @@ function configuredBranches(workflow: Record) { return Array.isArray(branches) ? branches.filter((branch): branch is string => typeof branch === 'string') : []; } -function hasWritePermission(workflow: Record, permission: string) { - const permissions = workflow.permissions; - return ( - typeof permissions === 'object' && - permissions !== null && - !Array.isArray(permissions) && - (permissions as Record)[permission] === 'write' +function objectValue(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function workflowJob(workflow: Record, jobName: string) { + return objectValue(objectValue(workflow.jobs)?.[jobName]); +} + +function jobSteps(job: Record | undefined) { + const steps = job?.steps; + return Array.isArray(steps) ? steps.flatMap((step) => (objectValue(step) ? [objectValue(step)!] : [])) : []; +} + +export function inspectAuthenticatedJob( + workflowFile: string, + jobName: string, + job: Record | undefined, + requirements: { contents: 'read' | 'write'; workflows?: boolean; issues?: boolean }, + checks: DoctorCheck[], +) { + if (!job) { + checks.push({ severity: 'error', message: `${workflowFile} must define the '${jobName}' job.` }); + return; + } + 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(', '); + checks.push({ + severity: 'error', + message: `${workflowFile} job '${jobName}' must create a Patchlane GitHub App token with ${permissions}.`, + }); + } + + const checkout = steps.find((step) => typeof step.uses === 'string' && step.uses.startsWith('actions/checkout@')); + if (objectValue(checkout?.with)?.token !== expectedToken) { + checks.push({ + severity: 'error', + message: `${workflowFile} job '${jobName}' must check out with the Patchlane GitHub App token.`, + }); + } + + 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) { + checks.push({ + severity: 'error', + message: `${workflowFile} job '${jobName}' must pass the Patchlane GitHub App token as GH_TOKEN.`, + }); + break; + } + } } function inspectWorkflows(config: PatchlaneConfig, sourceSha: string | undefined, cwd: string, checks: DoctorCheck[]) { @@ -249,37 +323,50 @@ function inspectWorkflows(config: PatchlaneConfig, sourceSha: string | undefined const promotion = parsed.find((file) => file.file === 'promote-tested-sync.yml'); const ci = parsed.find((file) => workflowName(file.workflow) === config.ciWorkflow); + const syncNotifications = config.notifications?.githubIssues.events.includes('sync-failed') ?? false; + const ciNotifications = config.notifications?.githubIssues.events.includes('ci-failed') ?? false; + 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 { - 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') - ) { + inspectAuthenticatedJob( + '.github/workflows/sync-upstream.yml', + 'fork-sync', + workflowJob(sync.workflow, 'fork-sync'), + { contents: 'write', workflows: true, issues: syncNotifications }, + checks, + ); + const workflowDispatch = objectValue(eventConfig(sync.workflow, 'workflow_dispatch')); + if (!objectValue(workflowDispatch?.inputs)?.verification_id) { checks.push({ severity: 'error', - message: 'The sync workflow must grant issues: write for GitHub issue notifications.', + 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.' }); } else { - 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.', - }); + inspectAuthenticatedJob( + '.github/workflows/promote-tested-sync.yml', + 'promote', + workflowJob(promotion.workflow, 'promote'), + { + contents: 'write', + workflows: true, + issues: + promotionNotifications || + (ciNotifications && (config.notifications?.githubIssues.closeOnRecovery ?? false)), + }, + checks, + ); + if (ciNotifications) { + inspectAuthenticatedJob( + '.github/workflows/promote-tested-sync.yml', + 'notify-ci-failure', + workflowJob(promotion.workflow, 'notify-ci-failure'), + { contents: 'read', issues: true }, + checks, + ); } const workflowRun = eventConfig(promotion.workflow, 'workflow_run'); const workflows = @@ -345,6 +432,65 @@ function inspectPatchRefs(config: PatchlaneConfig, sourceSha: string | undefined } } +export function inspectGitHubAutomation( + repository: string | undefined, + cwd: string, + checks: DoctorCheck[], + runCommand: typeof run = run, +) { + if (!repository) return; + + const actions = runCommand('gh', ['api', `repos/${repository}/actions/permissions`, '--jq', '.enabled'], cwd); + if (actions.status === 0) { + if (actions.stdout !== 'true') { + checks.push({ severity: 'error', message: `GitHub Actions is disabled for '${repository}'.` }); + } + } else { + checks.push({ + severity: 'warning', + message: `GitHub Actions enablement could not be inspected for '${repository}'.`, + }); + } + + const variables = runCommand( + 'gh', + ['api', '--paginate', `repos/${repository}/actions/variables?per_page=100`, '--jq', '.variables[].name'], + cwd, + ); + if (variables.status === 0) { + if (!variables.stdout.split(/\r?\n/).includes(GITHUB_APP_CLIENT_ID_VARIABLE)) { + checks.push({ + severity: 'error', + message: `Repository variable '${GITHUB_APP_CLIENT_ID_VARIABLE}' is not configured for '${repository}'.`, + }); + } + } else { + checks.push({ + severity: 'warning', + message: `Repository variables could not be inspected for '${repository}'.`, + }); + } + + const secrets = runCommand( + 'gh', + ['api', '--paginate', `repos/${repository}/actions/secrets?per_page=100`, '--jq', '.secrets[].name'], + cwd, + ); + if (secrets.status === 0) { + if (!secrets.stdout.split(/\r?\n/).includes(GITHUB_APP_PRIVATE_KEY_SECRET)) { + checks.push({ + severity: 'error', + message: `Repository secret '${GITHUB_APP_PRIVATE_KEY_SECRET}' is not configured for '${repository}'.`, + }); + } + } else { + checks.push({ + severity: 'warning', + message: `Repository secrets could not be inspected for '${repository}'.`, + }); + } +} + function inspectNotificationAssignees(config: PatchlaneConfig, cwd: string, checks: DoctorCheck[]) { const assignees = config.notifications?.githubIssues.assignees ?? []; const repository = githubRepository(remoteUrl(cwd, 'origin')); @@ -391,6 +537,8 @@ 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); + const repository = githubRepository(remoteUrl(cwd, 'origin')); + inspectGitHubAutomation(repository, cwd, checks); inspectNotificationAssignees(config, cwd, checks); inspectBootstrap(config, cwd, checks); diff --git a/src/init.ts b/src/init.ts index d1ff4ef..9653a12 100644 --- a/src/init.ts +++ b/src/init.ts @@ -140,6 +140,7 @@ export function initializePatchlane(options: InitOptions = {}) { `Patch order: ${config.patchRefs.join(', ')}`, `CI workflow: ${config.ciWorkflow}`, `Allowed repository workflows: ${config.allowedWorkflows.join(', ') || '(none)'}`, + 'Configure PATCHLANE_APP_CLIENT_ID and PATCHLANE_APP_PRIVATE_KEY in the fork repository.', 'Run `npx patchlane doctor` before publishing any branches.', ].join('\n') + '\n', ); diff --git a/src/verify-auth.ts b/src/verify-auth.ts new file mode 100644 index 0000000..740a304 --- /dev/null +++ b/src/verify-auth.ts @@ -0,0 +1,149 @@ +import { randomUUID } from 'node:crypto'; +import { loadPatchlaneConfig } from './config.js'; +import { run } from './subprocess.js'; + +const DEFAULT_TIMEOUT_SECONDS = 60; +const DEFAULT_POLL_INTERVAL_SECONDS = 2; + +type VerifyAuthOptions = { + cwd?: 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 repositoryResult = run('gh', ['repo', 'view', '--json', 'nameWithOwner', '--jq', '.nameWithOwner'], cwd); + if (repositoryResult.status !== 0 || !repositoryResult.stdout) { + throw new Error( + `Could not determine the GitHub repository: ${repositoryResult.stderr || repositoryResult.stdout || 'unknown error'}`, + ); + } + const repository = repositoryResult.stdout; + 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})`; + 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 54271ab..c7b53e1 100644 --- a/src/workflow-templates.ts +++ b/src/workflow-templates.ts @@ -1,5 +1,9 @@ import type { NotificationEvent, PatchlaneConfig } from './config.js'; +export const GITHUB_APP_CLIENT_ID_VARIABLE = 'PATCHLANE_APP_CLIENT_ID'; +export const GITHUB_APP_PRIVATE_KEY_SECRET = 'PATCHLANE_APP_PRIVATE_KEY'; +export const GITHUB_APP_TOKEN_STEP_ID = 'patchlane-token'; + function hasEvent(config: PatchlaneConfig, event: NotificationEvent) { return config.notifications?.githubIssues.events.includes(event) ?? false; } @@ -8,9 +12,32 @@ function closeOnRecovery(config: PatchlaneConfig) { return config.notifications?.githubIssues.closeOnRecovery ?? false; } -function permissions(needsIssues: boolean) { +function githubTokenPermissions() { return `permissions: - contents: write${needsIssues ? '\n issues: write' : ''}`; + contents: read`; +} + +function appTokenStep(options: { contents: 'read' | 'write'; workflows?: boolean; issues?: boolean }) { + return ` - name: Create Patchlane token + id: ${GITHUB_APP_TOKEN_STEP_ID} + uses: actions/create-github-app-token@v3 + with: + client-id: \${{ vars.${GITHUB_APP_CLIENT_ID_VARIABLE} }} + private-key: \${{ secrets.${GITHUB_APP_PRIVATE_KEY_SECRET} }} + permission-contents: ${options.contents}${options.workflows ? '\n permission-workflows: write' : ''}${options.issues ? '\n permission-issues: write' : ''} +`; +} + +function checkoutStep() { + return ` - uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: \${{ steps.${GITHUB_APP_TOKEN_STEP_ID}.outputs.token }} +`; +} + +function githubTokenEnvironment() { + return ` GH_TOKEN: \${{ steps.${GITHUB_APP_TOKEN_STEP_ID}.outputs.token }}`; } function githubExpressionString(value: string) { @@ -21,6 +48,7 @@ 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: @@ -42,18 +70,19 @@ on: type: string required: false default: "" + verification_id: + description: Correlate a Patchlane authentication check run. + type: string + required: false + default: "" -${permissions(notifyFailures)} +${githubTokenPermissions()} jobs: fork-sync: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - token: \${{ secrets.GITHUB_TOKEN }} - +${appTokenStep({ contents: 'write', workflows: true, issues: notifyFailures })}${checkoutStep()} - uses: actions/setup-node@v4 with: node-version: "22" @@ -61,6 +90,7 @@ jobs: - name: Run patchlane sync${notifyFailures ? '\n id: sync' : ''} run: npx patchlane@${packageVersion} sync env: +${githubTokenEnvironment()} UPSTREAM_SOURCE: \${{ inputs.source }} PATCH_REFS: \${{ inputs.patch_refs }} NO_PUSH: \${{ inputs.no_push || false }} @@ -72,6 +102,7 @@ ${ continue-on-error: true run: npx patchlane@${packageVersion} notify --event=sync-failed env: +${githubTokenEnvironment()} PATCHLANE_STATUS: \${{ steps.sync.outputs.status || 'failed' }} UPSTREAM_SHA: \${{ steps.sync.outputs.upstream_sha }} SYNC_SHA: \${{ steps.sync.outputs.sync_sha }} @@ -89,6 +120,7 @@ ${ continue-on-error: true run: npx patchlane@${packageVersion} notify --event=sync-failed --recovered env: +${githubTokenEnvironment()} PATCHLANE_STATUS: \${{ steps.sync.outputs.status }} UPSTREAM_SHA: \${{ steps.sync.outputs.upstream_sha }} SYNC_SHA: \${{ steps.sync.outputs.sync_sha }} @@ -103,7 +135,6 @@ export function renderPromotionWorkflow(config: PatchlaneConfig, packageVersion: const notifyPromotion = hasEvent(config, 'promotion-failed'); const notifyCiRecovery = notifyCi && closeOnRecovery(config); const notifyPromotionRecovery = notifyPromotion && closeOnRecovery(config); - const needsIssues = notifyCi || notifyPromotion; const syncBranch = githubExpressionString(config.syncBranch); return `name: Promote Tested Sync Branch @@ -112,7 +143,7 @@ on: workflows: ["${ciWorkflow.replaceAll('"', '\\"')}"] types: [completed] -${permissions(needsIssues)} +${githubTokenPermissions()} jobs: promote: @@ -121,11 +152,7 @@ jobs: github.event.workflow_run.head_branch == '${syncBranch}' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - token: \${{ secrets.GITHUB_TOKEN }} - +${appTokenStep({ contents: 'write', workflows: true, issues: notifyCiRecovery || notifyPromotion })}${checkoutStep()} - uses: actions/setup-node@v4 with: node-version: "22" @@ -136,6 +163,7 @@ ${ continue-on-error: true run: npx patchlane@${packageVersion} notify --event=ci-failed --recovered env: +${githubTokenEnvironment()} PATCHLANE_STATUS: \${{ github.event.workflow_run.conclusion }} PATCHLANE_RUN_URL: \${{ github.event.workflow_run.html_url }} SYNC_SHA: \${{ github.event.workflow_run.head_sha }} @@ -145,6 +173,7 @@ ${ - name: Run patchlane promote${notifyPromotion ? '\n id: promote' : ''} run: npx patchlane@${packageVersion} promote env: +${githubTokenEnvironment()} EXPECTED_SYNC_SHA: \${{ github.event.workflow_run.head_sha }} ${ notifyPromotion @@ -154,6 +183,7 @@ ${ continue-on-error: true run: npx patchlane@${packageVersion} notify --event=promotion-failed env: +${githubTokenEnvironment()} PATCHLANE_STATUS: \${{ steps.promote.outputs.status || 'failed' }} PATCHLANE_RUN_URL: \${{ github.event.workflow_run.html_url }} SYNC_SHA: \${{ github.event.workflow_run.head_sha }} @@ -167,6 +197,7 @@ ${ continue-on-error: true run: npx patchlane@${packageVersion} notify --event=promotion-failed --recovered env: +${githubTokenEnvironment()} PATCHLANE_STATUS: \${{ steps.promote.outputs.status }} PATCHLANE_RUN_URL: \${{ github.event.workflow_run.html_url }} SYNC_SHA: \${{ github.event.workflow_run.head_sha }} @@ -181,8 +212,7 @@ ${ github.event.workflow_run.head_branch == '${syncBranch}' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - +${appTokenStep({ contents: 'read', issues: true })}${checkoutStep()} - uses: actions/setup-node@v4 with: node-version: "22" @@ -191,6 +221,7 @@ ${ continue-on-error: true run: npx patchlane@${packageVersion} notify --event=ci-failed env: +${githubTokenEnvironment()} PATCHLANE_STATUS: \${{ github.event.workflow_run.conclusion }} PATCHLANE_RUN_URL: \${{ github.event.workflow_run.html_url }} SYNC_SHA: \${{ github.event.workflow_run.head_sha }} diff --git a/tests/doctor.test.ts b/tests/doctor.test.ts index a3741c2..2b6a53f 100644 --- a/tests/doctor.test.ts +++ b/tests/doctor.test.ts @@ -1,9 +1,24 @@ -import { expect, test } from 'vitest'; +import { describe, expect, test } from 'vitest'; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { spawnSync } from 'node:child_process'; -import { runDoctor } from '../src/doctor.js'; +import { inspectAuthenticatedJob, inspectGitHubAutomation, runDoctor, type DoctorCheck } from '../src/doctor.js'; +import type { PatchlaneConfig } from '../src/config.js'; +import { renderPromotionWorkflow, renderSyncWorkflow } from '../src/workflow-templates.js'; + +function workflowConfig(ciWorkflow: string): PatchlaneConfig { + return { + upstreamOwner: 'example', + upstreamRepo: 'upstream', + source: 'branch:main', + baseBranch: 'main', + syncBranch: 'sync/integration', + patchRefs: ['patch/sync'], + ciWorkflow, + allowedWorkflows: ['ci.yml'], + }; +} function git(args: string[], cwd: string) { const result = spawnSync('git', args, { cwd, encoding: 'utf8' }); @@ -16,6 +31,171 @@ function configureUser(repo: string) { git(['config', 'user.email', 'patchlane@example.test'], repo); } +const appToken = '${{ steps.patchlane-token.outputs.token }}'; + +function validTokenWith() { + return { + 'client-id': '${{ vars.PATCHLANE_APP_CLIENT_ID }}', + 'private-key': '${{ secrets.PATCHLANE_APP_PRIVATE_KEY }}', + 'permission-contents': 'write', + 'permission-workflows': 'write', + 'permission-issues': 'write', + }; +} + +function authenticatedJob( + options: { + includeTokenStep?: boolean; + tokenWith?: Record; + checkoutToken?: string; + ghToken?: string; + } = {}, +) { + const tokenWith = options.tokenWith ?? validTokenWith(); + return { + steps: [ + ...(options.includeTokenStep === false + ? [] + : [ + { + id: 'patchlane-token', + uses: 'actions/create-github-app-token@v3', + with: tokenWith, + }, + ]), + { + uses: 'actions/checkout@v4', + with: { token: options.checkoutToken ?? appToken }, + }, + { + run: 'npx patchlane@1.2.3 sync', + env: { GH_TOKEN: options.ghToken ?? appToken }, + }, + ], + }; +} + +function inspectJob(job: Record | undefined) { + const checks: DoctorCheck[] = []; + inspectAuthenticatedJob( + '.github/workflows/sync-upstream.yml', + 'fork-sync', + job, + { contents: 'write', workflows: true, issues: true }, + checks, + ); + return checks; +} + +type AutomationResponse = { status: number; stdout: string; stderr: string }; + +function automationRunner(overrides: Partial> = {}) { + const responses = { + actions: { status: 0, stdout: 'true', stderr: '' }, + variables: { status: 0, stdout: 'PATCHLANE_APP_CLIENT_ID', stderr: '' }, + secrets: { status: 0, stdout: 'PATCHLANE_APP_PRIVATE_KEY', stderr: '' }, + ...overrides, + }; + return (_command: string, args: string[]) => { + const endpoint = args.find((arg) => arg.startsWith('repos/')) ?? ''; + if (endpoint.endsWith('/actions/permissions')) return responses.actions; + if (endpoint.includes('/actions/variables')) return responses.variables; + if (endpoint.includes('/actions/secrets')) return responses.secrets; + throw new Error(`Unexpected command: ${args.join(' ')}`); + }; +} + +describe('inspectAuthenticatedJob', () => { + test('reports a missing job', () => { + expect(inspectJob(undefined)).toEqual([ + { + severity: 'error', + message: ".github/workflows/sync-upstream.yml must define the 'fork-sync' job.", + }, + ]); + }); + + test('reports a missing App token step', () => { + expect(inspectJob(authenticatedJob({ includeTokenStep: false }))).toContainEqual( + expect.objectContaining({ message: expect.stringContaining('must create a Patchlane GitHub App token') }), + ); + }); + + test.each([ + ['client ID', { 'client-id': '${{ vars.WRONG_CLIENT_ID }}' }], + ['private key', { 'private-key': '${{ secrets.WRONG_PRIVATE_KEY }}' }], + ['contents permission', { 'permission-contents': 'read' }], + ['workflows permission', { 'permission-workflows': 'read' }], + ['issues permission', { 'permission-issues': 'read' }], + ])('reports an incorrect App token %s', (_name, override) => { + const checks = inspectJob(authenticatedJob({ tokenWith: { ...validTokenWith(), ...override } })); + expect(checks).toEqual([ + expect.objectContaining({ message: expect.stringContaining('must create a Patchlane GitHub App token') }), + ]); + }); + + test('reports checkout that does not use the App token', () => { + expect(inspectJob(authenticatedJob({ checkoutToken: '${{ github.token }}' }))).toContainEqual( + expect.objectContaining({ + message: expect.stringContaining('must check out with the Patchlane GitHub App 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') }), + ); + }); +}); + +describe('inspectGitHubAutomation', () => { + function inspect(overrides: Parameters[0] = {}) { + const checks: DoctorCheck[] = []; + inspectGitHubAutomation('example/fork', '/tmp/fork', checks, automationRunner(overrides)); + return checks; + } + + test('reports disabled Actions', () => { + expect(inspect({ actions: { status: 0, stdout: 'false', stderr: '' } })).toContainEqual({ + severity: 'error', + message: "GitHub Actions is disabled for 'example/fork'.", + }); + }); + + test('reports a missing App client ID variable', () => { + expect(inspect({ variables: { status: 0, stdout: 'OTHER_VARIABLE', stderr: '' } })).toContainEqual({ + severity: 'error', + message: "Repository variable 'PATCHLANE_APP_CLIENT_ID' is not configured for 'example/fork'.", + }); + }); + + test('reports a missing App private key secret', () => { + expect(inspect({ secrets: { status: 0, stdout: 'OTHER_SECRET', stderr: '' } })).toContainEqual({ + severity: 'error', + message: "Repository secret 'PATCHLANE_APP_PRIVATE_KEY' is not configured for 'example/fork'.", + }); + }); + + test('warns when GitHub metadata APIs are inaccessible', () => { + const failed = { status: 1, stdout: '', stderr: 'HTTP 403' }; + expect(inspect({ actions: failed, variables: failed, secrets: failed })).toEqual([ + { + severity: 'warning', + message: "GitHub Actions enablement could not be inspected for 'example/fork'.", + }, + { + severity: 'warning', + message: "Repository variables could not be inspected for 'example/fork'.", + }, + { + severity: 'warning', + message: "Repository secrets could not be inspected for 'example/fork'.", + }, + ]); + }); +}); + test('composes non-overlapping changes to the same workflow from independent patches', () => { const tempRoot = mkdtempSync(path.join(tmpdir(), 'patchlane-doctor-composed-')); try { @@ -46,10 +226,13 @@ test('composes non-overlapping changes to the same workflow from independent pat git(['switch', '-c', 'patch/sync', 'upstream/main'], forkWork); const workflowDir = path.join(forkWork, '.github', 'workflows'); - writeFileSync(path.join(workflowDir, 'sync-upstream.yml'), 'name: Sync\npermissions:\n contents: write\n'); + writeFileSync( + path.join(workflowDir, 'sync-upstream.yml'), + renderSyncWorkflow(workflowConfig('Product CI'), '1.2.3'), + ); writeFileSync( path.join(workflowDir, 'promote-tested-sync.yml'), - 'name: Promote\non:\n workflow_run:\n workflows: [Product CI]\npermissions:\n contents: write\n', + renderPromotionWorkflow(workflowConfig('Product CI'), '1.2.3'), ); git(['add', '.github/workflows'], forkWork); git(['commit', '-m', 'Add sync workflows'], forkWork); @@ -162,10 +345,13 @@ test('reports a ready configuration and required bootstrap', () => { path.join(workflowDir, 'ci.yml'), 'name: Existing CI\non:\n push:\n branches: [main, sync/integration]\n', ); - writeFileSync(path.join(workflowDir, 'sync-upstream.yml'), 'name: Sync\npermissions:\n contents: write\n'); + writeFileSync( + path.join(workflowDir, 'sync-upstream.yml'), + renderSyncWorkflow(workflowConfig('Existing CI'), '1.2.3'), + ); writeFileSync( path.join(workflowDir, 'promote-tested-sync.yml'), - 'name: Promote\non:\n workflow_run:\n workflows: [Existing CI]\npermissions:\n contents: write\n', + renderPromotionWorkflow(workflowConfig('Existing CI'), '1.2.3'), ); git(['remote', 'set-url', 'upstream', upstreamBare], forkWork); diff --git a/tests/integration/cli.test.ts b/tests/integration/cli.test.ts index 91668ac..2237e56 100644 --- a/tests/integration/cli.test.ts +++ b/tests/integration/cli.test.ts @@ -3,6 +3,8 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { spawnSync } from 'node:child_process'; +import type { PatchlaneConfig } from '../../src/config.js'; +import { renderPromotionWorkflow, renderSyncWorkflow } from '../../src/workflow-templates.js'; const repoRoot = path.resolve(import.meta.dirname, '..', '..'); const cliPath = path.join(repoRoot, 'dist', 'cli.js'); @@ -74,10 +76,20 @@ test('sync skip-push flags do not publish the generated branch', () => { path.join(workflowDir, 'ci.yml'), 'name: Existing CI\non:\n push:\n branches: [main, sync/integration]\n', ); - writeFileSync(path.join(workflowDir, 'sync-upstream.yml'), 'name: Sync\npermissions:\n contents: write\n'); + const workflowConfig: PatchlaneConfig = { + upstreamOwner: 'example', + upstreamRepo: 'upstream', + source: 'branch:main', + baseBranch: 'main', + syncBranch: 'sync/integration', + patchRefs: ['patch/product'], + ciWorkflow: 'Existing CI', + allowedWorkflows: ['ci.yml'], + }; + writeFileSync(path.join(workflowDir, 'sync-upstream.yml'), renderSyncWorkflow(workflowConfig, '1.2.3')); writeFileSync( path.join(workflowDir, 'promote-tested-sync.yml'), - 'name: Promote\non:\n workflow_run:\n workflows: [Existing CI]\npermissions:\n contents: write\n', + renderPromotionWorkflow(workflowConfig, '1.2.3'), ); git(['add', '.patchlane.yml', '.github/workflows'], forkWork); git(['commit', '-m', 'Configure Patchlane'], forkWork); diff --git a/tests/verify-auth.test.ts b/tests/verify-auth.test.ts new file mode 100644 index 0000000..2329e5d --- /dev/null +++ b/tests/verify-auth.test.ts @@ -0,0 +1,106 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { loadPatchlaneConfig, type PatchlaneConfig } from '../src/config.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/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); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('verifyGitHubAuth', () => { + test('dispatches and watches a correlated no-push workflow run', async () => { + vi.mocked(run) + .mockReturnValueOnce(result({ stdout: repository })) + .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(run).toHaveBeenNthCalledWith( + 2, + '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({ stdout: repository })) + .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({ stdout: repository })) + .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.', + ); + }); +}); diff --git a/tests/workflow-templates.test.ts b/tests/workflow-templates.test.ts index 9355800..15db312 100644 --- a/tests/workflow-templates.test.ts +++ b/tests/workflow-templates.test.ts @@ -21,15 +21,25 @@ const config: PatchlaneConfig = { }, }; -test('renders failure and recovery notifications with minimal permissions', () => { +test('renders GitHub App authentication and failure notifications with minimal permissions', () => { const sync = renderSyncWorkflow(config, '1.2.3'); - expect(sync).toContain('issues: write'); + expect(sync).toContain('uses: actions/create-github-app-token@v3'); + expect(sync).toContain('client-id: ${{ vars.PATCHLANE_APP_CLIENT_ID }}'); + expect(sync).toContain('private-key: ${{ secrets.PATCHLANE_APP_PRIVATE_KEY }}'); + expect(sync).toContain('permission-contents: write'); + expect(sync).toContain('permission-workflows: write'); + expect(sync).toContain('permission-issues: write'); + expect(sync).toContain('token: ${{ steps.patchlane-token.outputs.token }}'); + expect(sync).toContain('GH_TOKEN: ${{ steps.patchlane-token.outputs.token }}'); + expect(sync).not.toContain('secrets.GITHUB_TOKEN'); expect(sync).toContain('if: failure()'); expect(sync).toContain('notify --event=sync-failed'); expect(sync).toContain('notify --event=sync-failed --recovered'); expect(sync).toContain('FAILED_PATCH_REF: ${{ steps.sync.outputs.failed_bookmark }}'); const promotion = renderPromotionWorkflow(config, '1.2.3'); + expect(promotion.split('uses: actions/create-github-app-token@v3')).toHaveLength(3); + expect(promotion).toContain('permission-workflows: write'); expect(promotion).toContain('notify-ci-failure:'); expect(promotion).toContain("github.event.workflow_run.conclusion != 'success'"); expect(promotion).toContain('notify --event=ci-failed'); @@ -46,8 +56,8 @@ test('escapes the sync branch in GitHub Actions expressions', () => { test('does not add notification permissions or steps when notifications are omitted', () => { const withoutNotifications = { ...config, notifications: undefined }; - expect(renderSyncWorkflow(withoutNotifications, '1.2.3')).not.toContain('issues: write'); + expect(renderSyncWorkflow(withoutNotifications, '1.2.3')).not.toContain('permission-issues: write'); expect(renderSyncWorkflow(withoutNotifications, '1.2.3')).not.toContain(' patchlane@1.2.3 notify'); - expect(renderPromotionWorkflow(withoutNotifications, '1.2.3')).not.toContain('issues: write'); + expect(renderPromotionWorkflow(withoutNotifications, '1.2.3')).not.toContain('permission-issues: write'); expect(renderPromotionWorkflow(withoutNotifications, '1.2.3')).not.toContain(' patchlane@1.2.3 notify'); });