Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions sites/sh1pt.com/app/admin/github/setup/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const dynamic = 'force-dynamic';

interface ManifestPermissions {
contents: 'write';
workflows: 'write';
pull_requests: 'write';
metadata: 'read';
actions: 'read';
Expand Down Expand Up @@ -46,6 +47,7 @@ function buildManifest(base: string) {
public: true,
default_permissions: {
contents: 'write',
workflows: 'write',
pull_requests: 'write',
metadata: 'read',
actions: 'read',
Expand Down Expand Up @@ -121,8 +123,9 @@ export default async function AdminGithubSetupPage({
</li>
<li>
Permissions: <code>Contents: write</code>, <code>Pull requests: write</code>,{' '}
<code>Metadata: read</code>, <code>Actions: read</code>. Enough to push a branch, open
a PR, and read workflow runs.
<code>Workflows: write</code>, <code>Metadata: read</code>,{' '}
<code>Actions: read</code>. Enough to push a branch, open a PR, write workflow files,
and read workflow runs.
</li>
<li>
No default events subscribed (installation/repo events fire on every App regardless).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
);
}
Comment on lines +77 to +85
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Pre-flight check depends on DB permissions staying fresh

hasWorkflowWrite reads from the permissions column that was just added to the SELECT in authorizeInstallation. If the webhook handler that updates github_installations doesn't write the permissions JSON when an installation is updated (i.e., when a user accepts the new Workflows: write request in GitHub), a user who has already accepted the permission will still receive a 403 here until the row is resynced. The fallback handler on line 118 catches the inverse case (DB says ✓ but GitHub says ✗), but there's no matching recovery path for the false-negative. Is the permissions column reliably refreshed by the installation webhook? Is the permissions column in github_installations updated by the installation webhook event when a user accepts new GitHub App permissions?

Fix in Codex Fix in Claude Code


let render;
try {
Expand Down Expand Up @@ -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') {
Expand All @@ -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<string, string> | 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)) {
Expand Down
3 changes: 2 additions & 1 deletion sites/sh1pt.com/lib/github-installation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export interface InstallationRow {
account_type: 'User' | 'Organization';
repository_selection: 'all' | 'selected';
status: 'active' | 'suspended' | 'deleted';
permissions?: Record<string, string> | null;
}

export interface GithubRepoResult {
Expand Down Expand Up @@ -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)
Expand Down
Loading