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)