diff --git a/sites/sh1pt.com/app/admin/github/setup/page.tsx b/sites/sh1pt.com/app/admin/github/setup/page.tsx index 7fedc66d..aec6c326 100644 --- a/sites/sh1pt.com/app/admin/github/setup/page.tsx +++ b/sites/sh1pt.com/app/admin/github/setup/page.tsx @@ -12,6 +12,7 @@ export const dynamic = 'force-dynamic'; interface ManifestPermissions { contents: 'write'; + workflows: 'write'; pull_requests: 'write'; metadata: 'read'; actions: 'read'; @@ -46,6 +47,7 @@ function buildManifest(base: string) { public: true, default_permissions: { contents: 'write', + workflows: 'write', pull_requests: 'write', metadata: 'read', actions: 'read', @@ -121,8 +123,9 @@ export default async function AdminGithubSetupPage({
  • Permissions: Contents: write, Pull requests: write,{' '} - Metadata: read, Actions: read. Enough to push a branch, open - a PR, and read workflow runs. + Workflows: write, Metadata: read,{' '} + Actions: read. Enough to push a branch, open a PR, write workflow files, + and read workflow runs.
  • No default events subscribed (installation/repo events fire on every App regardless). diff --git a/sites/sh1pt.com/app/api/v1/github/installations/[id]/repos/[repoId]/actions/[actionId]/install/route.ts b/sites/sh1pt.com/app/api/v1/github/installations/[id]/repos/[repoId]/actions/[actionId]/install/route.ts index 9f33bff2..11b2a881 100644 --- a/sites/sh1pt.com/app/api/v1/github/installations/[id]/repos/[repoId]/actions/[actionId]/install/route.ts +++ b/sites/sh1pt.com/app/api/v1/github/installations/[id]/repos/[repoId]/actions/[actionId]/install/route.ts @@ -74,6 +74,15 @@ export async function POST( if (!entry.manifest.compatibility.providers.includes('github')) { return NextResponse.json({ error: 'Action does not support GitHub' }, { status: 400 }); } + if (requiresWorkflowWrite(entry.manifest.files) && !hasWorkflowWrite(auth.installation.permissions)) { + return NextResponse.json( + { + error: + 'GitHub App needs Workflows: write permission to install actions into .github/workflows. Update the sh1pt GitHub App permissions, accept the installation update in GitHub, then retry.', + }, + { status: 403 }, + ); + } let render; try { @@ -106,6 +115,20 @@ export async function POST( }); if (outcome.kind === 'error') { + if ( + outcome.status === 403 && + typeof outcome.error === 'string' && + outcome.error.includes('Resource not accessible by integration') + ) { + return NextResponse.json( + { + ...outcome, + error: + 'GitHub App needs Workflows: write permission to install actions into .github/workflows. Update the sh1pt GitHub App permissions, accept the installation update in GitHub, then retry.', + }, + { status: 403 }, + ); + } return NextResponse.json(outcome, { status: outcome.status || 500 }); } if (outcome.kind === 'conflict') { @@ -114,6 +137,14 @@ export async function POST( return NextResponse.json(outcome); } +function requiresWorkflowWrite(files: Array<{ destination: string }>): boolean { + return files.some((file) => file.destination.replace(/^\/+/, '').startsWith('.github/workflows/')); +} + +function hasWorkflowWrite(permissions: Record | null | undefined): boolean { + return permissions?.workflows === 'write'; +} + function normalizeInputs(value: unknown): { ok: true; value: RenderInputs } | { ok: false; error: string } { if (value === undefined) return { ok: true, value: {} }; if (!value || typeof value !== 'object' || Array.isArray(value)) { diff --git a/sites/sh1pt.com/lib/github-installation.ts b/sites/sh1pt.com/lib/github-installation.ts index fa12b2de..4b904fe2 100644 --- a/sites/sh1pt.com/lib/github-installation.ts +++ b/sites/sh1pt.com/lib/github-installation.ts @@ -16,6 +16,7 @@ export interface InstallationRow { account_type: 'User' | 'Organization'; repository_selection: 'all' | 'selected'; status: 'active' | 'suspended' | 'deleted'; + permissions?: Record | null; } export interface GithubRepoResult { @@ -58,7 +59,7 @@ export async function authorizeInstallation( const { data: installation } = await admin .from('github_installations') .select( - 'id, profile_id, installation_id, account_login, account_type, repository_selection, status', + 'id, profile_id, installation_id, account_login, account_type, repository_selection, status, permissions', ) .eq('id', installationPk) .eq('profile_id', profile.id)