#!/usr/bin/env node // // GitHub Actions `post:` entry point. Runs after the main step regardless of // exit status (cancellation, timeout, unhandled error) — that's the contract // we need for credential persistence: if OpenCode refreshed the Codex // auth.json during the run, the refreshed token must land back in Pullfrog // even when the main step died unexpectedly. // // THIS IS WHY `CODEX_AUTH_JSON` HAS TO LIVE IN PULLFROG'S OWN SECRET STORE, // NOT IN GITHUB ACTIONS SECRETS. The refresh chain rotates on every use; this // hook PUTs the rotated chain back to Pullfrog Postgres so the next run starts // from a fresh token. GH Actions secrets are read-only at runtime — there is // no API to write them back from inside a job — so a token stashed there // silently goes stale on the first refresh and the next run fails. See // wiki/codex-auth.md. // // Today's only job: detect a Codex auth refresh by diffing the on-disk // auth.json against the original refresh token (saved to GH Actions state // by action/agents/opencode_v2.ts — see also the legacy v1 file kept as // reference at action/agents/opencode.ts), convert OpenCode's auth shape // back to Codex CLI shape, and PUT it to /api/runtime/secret. // // Silent no-op when the main step didn't materialize Codex auth (no state // saved). Best-effort: failures are logged but never throw — the workflow // is already done, and a missed refresh write-back means the user re-runs // `pullfrog auth codex` next time the chain breaks. import { existsSync, readFileSync } from "node:fs"; import * as core from "@actions/core"; import { apiFetch } from "./utils/apiFetch.ts"; import { detectCodexRefresh } from "./utils/codexHome.ts"; async function main(): Promise { const raw = core.getState("codex_writeback"); if (!raw) { core.info("codex post-hook: no writeback state — skipping"); return; } let state: { apiToken: string; authPath: string; originalRefresh: string }; try { state = JSON.parse(raw) as typeof state; } catch (err) { core.warning(`codex post-hook: malformed writeback state — ${err}`); return; } if (!state.apiToken || !state.authPath || !state.originalRefresh) { core.warning("codex post-hook: incomplete writeback state — skipping"); return; } if (!existsSync(state.authPath)) { core.info(`codex post-hook: ${state.authPath} not found — nothing to write back`); return; } let authFileContent: string; try { authFileContent = readFileSync(state.authPath, "utf8"); } catch (err) { core.warning(`codex post-hook: cannot read ${state.authPath} — ${err}`); return; } const refreshedCodexJson = detectCodexRefresh({ authFileContent, originalRefresh: state.originalRefresh, }); if (!refreshedCodexJson) { core.info("codex post-hook: refresh chain unchanged — no writeback needed"); return; } try { // route through apiFetch so the Vercel preview-deployment SSO gate gets // the `x-vercel-protection-bypass` header/query (raw fetch silently 401s // against preview envs — production is unaffected but every preview-run // refresh would be lost). see action/utils/apiFetch.ts. const response = await apiFetch({ path: "/api/runtime/secret", method: "PUT", headers: { authorization: `Bearer ${state.apiToken}`, "content-type": "application/json", }, body: JSON.stringify({ name: "CODEX_AUTH_JSON", value: refreshedCodexJson }), }); if (!response.ok) { const body = await response.text().catch(() => ""); core.warning(`codex post-hook: writeback returned ${response.status}: ${body}`); return; } core.info("codex post-hook: refreshed CODEX_AUTH_JSON persisted to Pullfrog"); } catch (err) { core.warning(`codex post-hook: writeback failed — ${err}`); } } main().catch((err) => { // never throw — post-hook failure must not fail the workflow core.warning(`codex post-hook: unexpected error — ${err}`); });