Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
bf83006
feat(github): add helper functions for GHES URL resolution
balcsida Feb 16, 2026
7164e12
feat(github): add parseGitRemote for any GitHub-compatible host
balcsida Feb 16, 2026
ff0ffd0
feat(github): parameterize Octokit and GraphQL client for GHES
balcsida Feb 16, 2026
9022062
feat(github): parameterize git config and emails for GHES
balcsida Feb 16, 2026
59e6e22
feat(github): parameterize fork remote and image URLs for GHES
balcsida Feb 16, 2026
92a1907
feat(github): update install command for GHES detection
balcsida Feb 16, 2026
e1a4962
feat(github): parameterize token revocation URL for GHES
balcsida Feb 16, 2026
9c0a505
test(github): add parseGitRemote tests for GHES URLs
balcsida Feb 16, 2026
cf646df
feat(github): add host to getAppInfo return for GHES URLs
balcsida Feb 16, 2026
c03c19c
feat(github): add GHES types and HTML helpers for manifest flow
balcsida Feb 16, 2026
fe38aec
feat(github): implement createGHESApp manifest flow
balcsida Feb 16, 2026
2ca5163
feat(github): rewrite installGitHubApp with GHES auth strategy selection
balcsida Feb 16, 2026
5138b2c
feat(github): generate GHES workflow variants for Option A and B
balcsida Feb 16, 2026
ef07188
feat(github): update printNextSteps with GHES-specific guidance
balcsida Feb 16, 2026
229cf07
feat(github): add GHES_APP_TOKEN auth path in GithubRunCommand
balcsida Feb 16, 2026
60033bd
feat(github): add GHES_APP_TOKEN to legacy getAccessToken
balcsida Feb 16, 2026
8802d33
feat(github): pass GHES_APP_TOKEN env var in action.yml
balcsida Feb 16, 2026
8a46297
feat(ghes-exchange): add self-hosted OIDC exchange server package
balcsida Feb 16, 2026
cf46adf
chore: update lockfile for ghes-exchange package
balcsida Feb 16, 2026
ad78969
feat(ghes-exchange): add manifest flow setup module with HTML renderers
balcsida Feb 17, 2026
e5e526b
feat(ghes-exchange): add setup routes and optional config for manifes…
balcsida Feb 17, 2026
ed86139
fix(github): resolve circular type inference in manifest server
balcsida Feb 17, 2026
64255a0
fix: resolve merge conflict with upstream/dev in github.ts
balcsida Feb 18, 2026
ccda103
Merge branch 'dev' into feat/ghes-support
balcsida Feb 18, 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 @@ -72,3 +72,4 @@ runs:
USE_GITHUB_TOKEN: ${{ inputs.use_github_token }}
MENTIONS: ${{ inputs.mentions }}
OIDC_BASE_URL: ${{ inputs.oidc_base_url }}
GHES_APP_TOKEN: ${{ env.GHES_APP_TOKEN }}
39 changes: 28 additions & 11 deletions github/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,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 @@ -129,8 +141,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 @@ -368,6 +381,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 @@ -446,8 +462,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 @@ -664,13 +681,13 @@ async function configureGit(appToken: string) {
await $`git config --local --unset-all ${config}`
await $`git config --local ${config} "AUTHORIZATION: basic ${newCredentials}"`
await $`git config --global user.name "opencode-agent[bot]"`
await $`git config --global user.email "opencode-agent[bot]@users.noreply.github.com"`
await $`git config --global user.email "${getNoreplyEmail("opencode-agent[bot]", ghUrls.host)}"`
}

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 @@ -698,7 +715,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 @@ -720,7 +737,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 @@ -731,7 +748,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 @@ -744,7 +761,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 @@ -1041,7 +1058,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
17 changes: 17 additions & 0 deletions packages/ghes-exchange/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
FROM oven/bun:1.3 AS base
WORKDIR /app

FROM base AS install
COPY package.json bun.lock* ./
RUN bun install --frozen-lockfile --production

FROM base AS release
COPY --from=install /app/node_modules node_modules
COPY src src
COPY 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