From 3120d0b8fa3e20a5816ef644823b90934474fc56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Thu, 30 Jul 2026 05:04:14 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20db:refresh:stage=20=E2=80=94=20weekly?= =?UTF-8?q?=20Neon=20branch=20refresh=20+=20secret=20swap=20(human=20only)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + scripts/refresh-stage.mjs | 106 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 scripts/refresh-stage.mjs diff --git a/package.json b/package.json index 9513aa4a..32d56ada 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "db:seed:consent": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-consent.mjs", "db:seed:projecao": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/import-projecao.mjs", "db:seed:minimal": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-minimal.mjs", + "db:refresh:stage": "node scripts/refresh-stage.mjs", "build:demographics": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-bahia-demographics.mjs", "build:election-aggregates": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/build-election-aggregates.mjs", "build:geometries": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-bahia-geometries.mjs", diff --git a/scripts/refresh-stage.mjs b/scripts/refresh-stage.mjs new file mode 100644 index 00000000..b8192dd3 --- /dev/null +++ b/scripts/refresh-stage.mjs @@ -0,0 +1,106 @@ +/** + * 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 pooled connection string. +const { uri } = await request( + 'GET', + `/projects/${PROJECT_ID}/connection_uri?branch_id=${stageBranch.id}` + + `&database_name=${DATABASE_NAME}&role_name=${ROLE_NAME}&pooled=true`, +) +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)