teqo-backup/scripts/refresh-stage.mjs

109 lines
4.4 KiB
JavaScript

/**
* Refresh the stage database: create a NEW Neon branch from the current prod
* snapshot and swap the `STAGE_DATABASE_URL` secret in the GitHub `stage`
* Environment. Human / `workflow_dispatch` only — agents must never run this
* (it touches the prod Neon project).
*
* Refresh is "new branch + swap secret", never DELETE/INSERT of real data:
* Neon branches are copy-on-write snapshots of prod at creation time.
*
* Requirements:
* NEON_API_KEY — Neon API key with access to the prod project (get one at
* https://console.neon.tech/app/settings/api-keys). Never
* committed; export it in your shell or pass via env.
* gh — authenticated GitHub CLI (updates the secret).
*
* Usage:
* NEON_API_KEY=… pnpm db:refresh:stage # refresh + swap secret
* NEON_API_KEY=… pnpm db:refresh:stage -- --delete-old # also delete the previous stage branch
* NEON_API_KEY=… node scripts/refresh-stage.mjs --dry-run
*/
import { dieWithLabel } from './lib/cli.mjs'
const LABEL = 'refresh:stage'
const die = dieWithLabel(LABEL)
const PROJECT_ID = process.env.NEON_PROJECT_ID ?? 'lively-math-34249863'
const DATABASE_NAME = 'neondb'
const ROLE_NAME = 'neondb_owner'
const API = 'https://console.neon.tech/api/v2'
const args = new Set(process.argv.slice(2))
const DRY_RUN = args.has('--dry-run')
const DELETE_OLD = args.has('--delete-old')
const apiKey = process.env.NEON_API_KEY
if (!apiKey) {
die(
'NEON_API_KEY is not set. This script is human-only: it creates a branch on the PROD Neon ' +
'project and swaps the stage secret. Export a Neon API key and re-run.',
)
}
const request = async (method, path, body) => {
const response = await fetch(`${API}${path}`, {
method,
headers: { authorization: `Bearer ${apiKey}`, 'content-type': 'application/json' },
...(body ? { body: JSON.stringify(body) } : {}),
})
if (!response.ok) {
die(`Neon API ${method} ${path} → ${response.status}: ${await response.text()}`)
}
return response.json()
}
const log = (message) => console.log(`[${LABEL}] ${message}`)
// 1. Find the prod default branch (the snapshot source) and the current stage branch.
const { branches } = await request('GET', `/projects/${PROJECT_ID}/branches`)
const prodBranch = branches.find((branch) => branch.default)
if (!prodBranch) die(`No default branch found on project ${PROJECT_ID}.`)
const oldStage = branches.find((branch) => branch.name === 'stage')
log(`prod branch: ${prodBranch.id} (${prodBranch.name})`)
if (oldStage) log(`current stage branch: ${oldStage.id}`)
if (DRY_RUN) {
log('dry-run — would create a new `stage` branch from prod and swap STAGE_DATABASE_URL.')
process.exit(0)
}
// 2. Replace the old stage branch with a fresh one from prod (delete first so
// the name is free; the old branch is a stale snapshot, not data we keep).
if (oldStage) {
await request('DELETE', `/projects/${PROJECT_ID}/branches/${oldStage.id}`)
log(`deleted old stage branch ${oldStage.id}`)
}
const timestamp = new Date().toISOString().slice(0, 10)
const created = await request('POST', `/projects/${PROJECT_ID}/branches`, {
branch: { name: 'stage', parent_id: prodBranch.id },
endpoints: [{ type: 'read_write' }],
})
const stageBranch = created.branch
log(`created stage branch ${stageBranch.id} from prod snapshot (${timestamp})`)
// 3. Resolve the new connection string. UNPOOLED on purpose: the int suite
// (and Payload's own advisory locks) need real session state, which the
// transaction-mode pooler does not guarantee — the suite hung and GitHub
// cancelled the run at the 1-hour mark on 2026-07-30 with the pooled URL.
const { uri } = await request(
'GET',
`/projects/${PROJECT_ID}/connection_uri?branch_id=${stageBranch.id}` +
`&database_name=${DATABASE_NAME}&role_name=${ROLE_NAME}&pooled=false`,
)
log(`new stage connection string: ${uri.replace(/:\/\/[^@]*@/, '://***@')}`)
// 4. Swap the GitHub secret in the `stage` Environment.
const { execFileSync } = await import('node:child_process')
execFileSync('gh', ['secret', 'set', 'STAGE_DATABASE_URL', '--env', 'stage', '--body', uri], {
stdio: 'inherit',
})
log('STAGE_DATABASE_URL updated in the GitHub `stage` environment.')
if (DELETE_OLD && oldStage) {
log('note: --delete-old is a no-op now — the old branch was already replaced in step 2.')
}
log('OK — stage database refreshed. Next ci-stage run uses the new snapshot.')
process.exit(0)