Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
f94d1ee
feat(github): add helper functions for GHES URL resolution
balcsida Feb 16, 2026
6999081
feat(github): add parseGitRemote for any GitHub-compatible host
balcsida Feb 16, 2026
689d327
feat(github): parameterize Octokit and GraphQL client for GHES
balcsida Feb 16, 2026
0b1dcc1
feat(github): parameterize git config and emails for GHES
balcsida Feb 16, 2026
533593d
feat(github): parameterize fork remote and image URLs for GHES
balcsida Feb 16, 2026
cac4e71
feat(github): update install command for GHES detection
balcsida Feb 16, 2026
2c2bdd9
feat(github): parameterize token revocation URL for GHES
balcsida Feb 16, 2026
8abeca8
test(github): add parseGitRemote tests for GHES URLs
balcsida Feb 16, 2026
1e2f2f2
feat(github): add host to getAppInfo return for GHES URLs
balcsida Feb 16, 2026
eba77ce
feat(github): add GHES types and HTML helpers for manifest flow
balcsida Feb 16, 2026
75e7197
feat(github): implement createGHESApp manifest flow
balcsida Feb 16, 2026
04bcc1b
feat(github): rewrite installGitHubApp with GHES auth strategy selection
balcsida Feb 16, 2026
ed77fc4
feat(github): generate GHES workflow variants for Option A and B
balcsida Feb 16, 2026
2231c9c
feat(github): update printNextSteps with GHES-specific guidance
balcsida Feb 16, 2026
83c609c
feat(github): add GHES_APP_TOKEN auth path in GithubRunCommand
balcsida Feb 16, 2026
7178c7b
feat(github): add GHES_APP_TOKEN to legacy getAccessToken
balcsida Feb 16, 2026
082cae6
feat(github): pass GHES_APP_TOKEN env var in action.yml
balcsida Feb 16, 2026
87879da
feat(ghes-exchange): add self-hosted OIDC exchange server package
balcsida Feb 16, 2026
6ca5dc7
chore: update lockfile for ghes-exchange package
balcsida Feb 16, 2026
95db987
feat(ghes-exchange): add manifest flow setup module with HTML renderers
balcsida Feb 17, 2026
90330d3
feat(ghes-exchange): add setup routes and optional config for manifes…
balcsida Feb 17, 2026
1959e30
fix(github): resolve circular type inference in manifest server
balcsida Feb 17, 2026
426c76e
refactor: migrate cli/cmd/github.ts from Bun.write() to Filesystem mo…
thdxr Feb 18, 2026
16c2a6f
refactor: migrate uninstall.ts from Bun.file()/Bun.write() to Filesys…
thdxr Feb 18, 2026
705f1f5
fix(ghes): build exchange image from workspace root
balcsida May 19, 2026
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
17 changes: 17 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions github/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,4 @@ runs:
MENTIONS: ${{ inputs.mentions }}
VARIANT: ${{ inputs.variant }}
OIDC_BASE_URL: ${{ inputs.oidc_base_url }}
GHES_APP_TOKEN: ${{ env.GHES_APP_TOKEN }}
37 changes: 27 additions & 10 deletions github/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,18 @@ type IssueQueryResponse = {
}
}

function getGitHubURLs() {
const serverUrl = (process.env.GITHUB_SERVER_URL || "https://github.com").replace(/\/+$/, "")
const apiUrl = (process.env.GITHUB_API_URL || "https://api.github.com").replace(/\/+$/, "")
const host = new URL(serverUrl).host
return { serverUrl, apiUrl, host }
}

function getNoreplyEmail(username: string, host: string) {
return `${username}@users.noreply.${host}`
}

const ghUrls = getGitHubURLs()
const { client, server } = createOpencode()
let accessToken: string
let octoRest: Octokit
Expand All @@ -130,8 +142,9 @@ try {
await assertOpencodeConnected()

accessToken = await getAccessToken()
octoRest = new Octokit({ auth: accessToken })
octoRest = new Octokit({ auth: accessToken, baseUrl: ghUrls.apiUrl })
octoGraph = graphql.defaults({
baseUrl: ghUrls.apiUrl,
headers: { authorization: `token ${accessToken}` },
})

Expand Down Expand Up @@ -369,6 +382,9 @@ function useShareUrl() {
async function getAccessToken() {
const { repo } = useContext()

const ghesAppToken = process.env["GHES_APP_TOKEN"]
if (ghesAppToken) return ghesAppToken

const envToken = useEnvGithubToken()
if (envToken) return envToken

Expand Down Expand Up @@ -447,8 +463,9 @@ async function getUserPrompt() {
// ie. <img alt="Image" src="https://github.com/user-attachments/assets/xxxx" />
// ie. [api.json](https://github.com/user-attachments/files/21433810/api.json)
// ie. ![Image](https://github.com/user-attachments/assets/xxxx)
const mdMatches = prompt.matchAll(/!?\[.*?\]\((https:\/\/github\.com\/user-attachments\/[^)]+)\)/gi)
const tagMatches = prompt.matchAll(/<img .*?src="(https:\/\/github\.com\/user-attachments\/[^"]+)" \/>/gi)
const hostPattern = ghUrls.host.replace(/\./g, "\\.")
const mdMatches = prompt.matchAll(new RegExp(`!?\\[.*?\\]\\((https:\\/\\/${hostPattern}\\/user-attachments\\/[^)]+)\\)`, "gi"))
const tagMatches = prompt.matchAll(new RegExp(`<img .*?src="(https:\\/\\/${hostPattern}\\/user-attachments\\/[^"]+)" \\/>`, "gi"))
const matches = [...mdMatches, ...tagMatches].sort((a, b) => a.index - b.index)
console.log("Images", JSON.stringify(matches, null, 2))

Expand Down Expand Up @@ -655,7 +672,7 @@ async function configureGit(appToken: string) {
if (isMock()) return

console.log("Configuring git...")
const config = "http.https://github.com/.extraheader"
const config = `http.${ghUrls.serverUrl}/.extraheader`
const ret = await $`git config --local --get ${config}`
gitConfig = ret.stdout.toString().trim()

Expand All @@ -677,7 +694,7 @@ async function assertGitIdentityConfigured() {
async function restoreGitConfig() {
if (gitConfig === undefined) return
console.log("Restoring git config...")
const config = "http.https://github.com/.extraheader"
const config = `http.${ghUrls.serverUrl}/.extraheader`
await $`git config --local ${config} "${gitConfig}"`
}

Expand Down Expand Up @@ -705,7 +722,7 @@ async function checkoutForkBranch(pr: GitHubPullRequest) {
const localBranch = generateBranchName("pr")
const depth = Math.max(pr.commits.totalCount, 20)

await $`git remote add fork https://github.com/${pr.headRepository.nameWithOwner}.git`
await $`git remote add fork ${ghUrls.serverUrl}/${pr.headRepository.nameWithOwner}.git`
await $`git fetch fork --depth=${depth} ${remoteBranch}`
await $`git checkout -b ${localBranch} fork/${remoteBranch}`
}
Expand All @@ -728,7 +745,7 @@ async function pushToNewBranch(summary: string, branch: string) {
await $`git add .`
await $`git commit -m "${summary}

Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
Co-authored-by: ${actor} <${getNoreplyEmail(actor, ghUrls.host)}>"`
await $`git push -u origin ${branch}`
}

Expand All @@ -740,7 +757,7 @@ async function pushToLocalBranch(summary: string) {
await $`git add .`
await $`git commit -m "${summary}

Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
Co-authored-by: ${actor} <${getNoreplyEmail(actor, ghUrls.host)}>"`
await $`git push`
}

Expand All @@ -754,7 +771,7 @@ async function pushToForkBranch(summary: string, pr: GitHubPullRequest) {
await $`git add .`
await $`git commit -m "${summary}

Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
Co-authored-by: ${actor} <${getNoreplyEmail(actor, ghUrls.host)}>"`
await $`git push fork HEAD:${remoteBranch}`
}

Expand Down Expand Up @@ -1061,7 +1078,7 @@ async function revokeAppToken() {
if (!accessToken) return
console.log("Revoking app token...")

await fetch("https://api.github.com/installation/token", {
await fetch(`${ghUrls.apiUrl}/installation/token`, {
method: "DELETE",
headers: {
Authorization: `Bearer ${accessToken}`,
Expand Down
20 changes: 20 additions & 0 deletions packages/ghes-exchange/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
FROM oven/bun:1.3 AS base
WORKDIR /app

FROM base AS install
COPY package.json bun.lock ./
COPY patches patches
COPY packages packages
RUN bun install --frozen-lockfile --production --ignore-scripts --filter @opencode-ai/ghes-exchange

FROM base AS release
WORKDIR /app/packages/ghes-exchange
COPY --from=install /app/node_modules /app/node_modules
COPY packages/ghes-exchange/src src
COPY packages/ghes-exchange/package.json .

ENV PORT=3000
EXPOSE 3000

USER bun
ENTRYPOINT ["bun", "run", "src/index.ts"]
23 changes: 23 additions & 0 deletions packages/ghes-exchange/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"name": "@opencode-ai/ghes-exchange",
"version": "0.0.1",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
"license": "MIT",
"scripts": {
"dev": "bun run src/index.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
"hono": "catalog:",
"jose": "6.0.11"
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"typescript": "catalog:"
}
}
57 changes: 57 additions & 0 deletions packages/ghes-exchange/src/exchange.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { createRemoteJWKSet, jwtVerify } from "jose"
import { createAppAuth } from "@octokit/auth-app"
import { Octokit } from "@octokit/rest"

export interface ExchangeConfig {
ghesHost: string
appId: string
appPrivateKey: string
}

export async function exchangeToken(oidcToken: string, config: ExchangeConfig): Promise<string> {
const ghesUrl = `https://${config.ghesHost}`
const issuer = `${ghesUrl}/_services/token`
const jwksUrl = `${issuer}/.well-known/jwks`
const audience = "opencode-github-action"

// Verify OIDC token against GHES JWKS
const JWKS = createRemoteJWKSet(new URL(jwksUrl))
let owner: string
let repo: string

try {
const { payload } = await jwtVerify(oidcToken, JWKS, {
issuer,
audience,
})
// sub format: 'repo:my-org/my-repo:ref:refs/heads/main'
const sub = payload.sub
if (!sub) throw new Error("Token missing sub claim")
const repoPart = sub.split(":")[1]
if (!repoPart) throw new Error("Token sub claim has unexpected format")
const parts = repoPart.split("/")
owner = parts[0]!
repo = parts[1]!
} catch (err) {
console.error("Token verification failed:", err)
throw new Error("Invalid or expired token")
}

// Create app JWT and get installation token
const auth = createAppAuth({
appId: config.appId,
privateKey: config.appPrivateKey,
})
const appAuth = await auth({ type: "app" })

const apiUrl = `${ghesUrl}/api/v3`
const octokit = new Octokit({ auth: appAuth.token, baseUrl: apiUrl })
const { data: installation } = await octokit.apps.getRepoInstallation({ owner, repo })

const installationAuth = await auth({
type: "installation",
installationId: installation.id,
})

return installationAuth.token
}
113 changes: 113 additions & 0 deletions packages/ghes-exchange/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { Hono } from "hono"
import { exchangeToken, type ExchangeConfig } from "./exchange"
import {
getStoredConfig,
setStoredConfig,
exchangeManifestCode,
renderLandingPage,
renderSuccessPage,
renderErrorPage,
} from "./setup"

const app = new Hono()

function getGhesHost(): string {
const ghesHost = process.env["GHES_HOST"]
if (!ghesHost) throw new Error("GHES_HOST environment variable is required")
return ghesHost
}

function getConfig(): ExchangeConfig | null {
const ghesHost = process.env["GHES_HOST"]
const appId = process.env["GHES_APP_ID"]
const appPrivateKey = process.env["GHES_APP_PRIVATE_KEY"]

if (ghesHost && appId && appPrivateKey) {
return { ghesHost, appId, appPrivateKey }
}

// Fall back to in-memory config from manifest flow
return getStoredConfig()
}

function getRouteHost(c: { req: { header: (name: string) => string | undefined } }): string {
return c.req.header("X-Forwarded-Host") || c.req.header("Host") || "localhost:3000"
}

app.get("/", (c) => {
const ghesHost = getGhesHost()
const routeHost = getRouteHost(c)
const config = getConfig()
const configured = config !== null
const appId = config?.appId || process.env["GHES_APP_ID"]

return c.html(renderLandingPage(ghesHost, routeHost, configured, appId))
})

app.get("/setup/callback", async (c) => {
const code = c.req.query("code")
if (!code) {
return c.html(renderErrorPage("No code parameter received from GitHub."), 400)
}

try {
const ghesHost = getGhesHost()
const result = await exchangeManifestCode(ghesHost, code)

// Store config in-memory for immediate use
setStoredConfig({
ghesHost,
appId: String(result.id),
appPrivateKey: result.pem,
})

return c.html(renderSuccessPage(result, ghesHost))
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error"
console.error("Manifest code exchange failed:", message)
return c.html(renderErrorPage(message), 500)
}
})

app.get("/health", (c) => {
const config = getConfig()
return c.json({ status: "ok", configured: config !== null })
})

app.post("/exchange_github_app_token", async (c) => {
const token = c.req.header("Authorization")?.replace(/^Bearer /, "")
if (!token) {
return c.json({ error: "Authorization header is required" }, { status: 401 })
}

const config = getConfig()
if (!config) {
return c.json(
{ error: "Exchange server is not configured. Visit the root URL to set up a GitHub App." },
{ status: 503 },
)
}

try {
const installationToken = await exchangeToken(token, config)
return c.json({ token: installationToken })
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error"
console.error("Token exchange failed:", message)
return c.json({ error: message }, { status: 403 })
}
})

const port = parseInt(process.env["PORT"] || "3000", 10)

export default {
port,
fetch: app.fetch,
}

const config = getConfig()
if (config) {
console.log(`GHES exchange server listening on port ${port}`)
} else {
console.log(`GHES exchange server listening on port ${port} (setup mode — visit / to configure)`)
}
Loading
Loading