diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index da6d8b2b..335ad126 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -169,6 +169,10 @@ jobs: if: needs.checks.result == 'success' # pull → local `vercel build` (pnpm install + Next/Payload build against prod env) → upload. timeout-minutes: 35 + outputs: + stale: ${{ steps.tip.outputs.stale || 'false' }} + cooldown: ${{ steps.cooldown.outputs.active || 'false' }} + wait_seconds: ${{ steps.cooldown.outputs.wait_seconds || '0' }} env: VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} @@ -189,6 +193,10 @@ jobs: exit 0 fi echo "stale=false" >> "$GITHUB_OUTPUT" + - uses: actions/setup-node@v5 + if: steps.tip.outputs.stale != 'true' + with: + node-version: 24 - name: Require Vercel credentials if: steps.tip.outputs.stale != 'true' run: | @@ -212,20 +220,29 @@ jobs: exit 1 ;; esac + - name: Check deploy cooldown + id: cooldown + if: steps.tip.outputs.stale != 'true' + run: node scripts/vercel-deploy-cooldown.mjs + - name: Log deploy deferral + if: steps.tip.outputs.stale != 'true' && steps.cooldown.outputs.active == 'true' + run: | + echo "Deploy deferred: last production deploy is younger than 30 minutes." + echo "Requeue will retry in ${{ steps.cooldown.outputs.wait_seconds }}s." # `vercel build` shells out to pnpm (packageManager in package.json); without # these steps the runner only has npm → spawn pnpm ENOENT. - uses: pnpm/action-setup@v6 - if: steps.tip.outputs.stale != 'true' + if: steps.tip.outputs.stale != 'true' && steps.cooldown.outputs.active != 'true' - uses: actions/setup-node@v5 - if: steps.tip.outputs.stale != 'true' + if: steps.tip.outputs.stale != 'true' && steps.cooldown.outputs.active != 'true' with: node-version: 24 cache: pnpm - name: Install Vercel CLI - if: steps.tip.outputs.stale != 'true' + if: steps.tip.outputs.stale != 'true' && steps.cooldown.outputs.active != 'true' run: npm install --global vercel@55 - name: Deploy production - if: steps.tip.outputs.stale != 'true' + if: steps.tip.outputs.stale != 'true' && steps.cooldown.outputs.active != 'true' run: | set -euo pipefail # Workflow-level env points at the CI teqo_test service. Drop it so @@ -236,12 +253,13 @@ jobs: # "Could not retrieve Project Settings" usually means bad/expired token or wrong IDs (403). vercel pull --yes --environment=production --token="$VERCEL_TOKEN" vercel build --prod --token="$VERCEL_TOKEN" - vercel deploy --prebuilt --prod --token="$VERCEL_TOKEN" + vercel deploy --prebuilt --prod --archive=tgz --token="$VERCEL_TOKEN" requeue: runs-on: ubuntu-latest needs: [checks, deploy] if: always() + timeout-minutes: 40 permissions: actions: write contents: read @@ -249,17 +267,38 @@ jobs: - uses: actions/checkout@v5 with: fetch-depth: 0 - - name: Re-dispatch if main advanced + - name: Re-dispatch if main advanced or deploy deferred env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} + DEPLOY_RESULT: ${{ needs.deploy.result }} + DEPLOY_COOLDOWN: ${{ needs.deploy.outputs.cooldown }} + DEPLOY_WAIT_SECONDS: ${{ needs.deploy.outputs.wait_seconds }} run: | set -euo pipefail git fetch origin main --quiet tip=$(git rev-parse origin/main) - if [ "$tip" = "$GITHUB_SHA" ]; then - echo "HEAD still $GITHUB_SHA — idle" + if [ "$tip" != "$GITHUB_SHA" ]; then + echo "origin/main moved to $tip (verified $GITHUB_SHA) — requeue ci.yml" + gh workflow run ci.yml --ref main exit 0 fi - echo "origin/main moved to $tip (verified $GITHUB_SHA) — requeue ci.yml" - gh workflow run ci.yml --ref main + if [ "${DEPLOY_RESULT:-}" != "success" ]; then + echo "Deploy job did not succeed (${DEPLOY_RESULT:-skipped}) — idle" + exit 0 + fi + if [ "${DEPLOY_COOLDOWN:-}" = "true" ]; then + wait="${DEPLOY_WAIT_SECONDS:-0}" + echo "Deploy deferred by cooldown — waiting ${wait}s then requeue ci.yml" + sleep "$wait" + git fetch origin main --quiet + tip=$(git rev-parse origin/main) + if [ "$tip" != "$GITHUB_SHA" ]; then + echo "main advanced during cooldown wait ($tip) — requeue for new tip" + else + echo "Cooldown elapsed for $GITHUB_SHA — requeue ci.yml" + fi + gh workflow run ci.yml --ref main + exit 0 + fi + echo "HEAD still $GITHUB_SHA — idle" diff --git a/scripts/lib/vercel-deploy-cooldown.mjs b/scripts/lib/vercel-deploy-cooldown.mjs new file mode 100644 index 00000000..86199037 --- /dev/null +++ b/scripts/lib/vercel-deploy-cooldown.mjs @@ -0,0 +1,122 @@ +/** + * Production deploy cooldown — queries Vercel for the last READY production + * deployment and computes whether CI should defer another upload (OPS11). + */ + +export const DEPLOY_COOLDOWN_MS = 30 * 60 * 1000 + +const VERCEL_API_BASE = 'https://api.vercel.com' + +/** + * @param {number | null | undefined} lastReadyAtMs + * @param {number} [nowMs] + */ +export const computeCooldownWait = (lastReadyAtMs, nowMs = Date.now()) => { + if (lastReadyAtMs == null || !Number.isFinite(lastReadyAtMs)) { + return { active: false, waitSeconds: 0, ageMs: null } + } + + const ageMs = nowMs - lastReadyAtMs + const remainingMs = DEPLOY_COOLDOWN_MS - ageMs + + if (remainingMs <= 0) { + return { active: false, waitSeconds: 0, ageMs } + } + + return { + active: true, + waitSeconds: Math.ceil(remainingMs / 1000), + ageMs, + } +} + +/** + * @param {unknown} deployment + * @returns {number | null} + */ +export const lastProductionReadyAtFromDeployment = (deployment) => { + if (!deployment || typeof deployment !== 'object') return null + const ready = /** @type {{ ready?: unknown; createdAt?: unknown }} */ (deployment).ready + const createdAt = /** @type {{ ready?: unknown; createdAt?: unknown }} */ (deployment).createdAt + const timestamp = ready ?? createdAt + return typeof timestamp === 'number' && Number.isFinite(timestamp) ? timestamp : null +} + +/** + * @param {{ + * token: string + * projectId: string + * teamId: string + * fetchImpl?: typeof fetch + * }} options + * @returns {Promise} + */ +export const fetchLastProductionReadyAt = async ({ + token, + projectId, + teamId, + fetchImpl = fetch, +}) => { + const params = new URLSearchParams({ + projectId, + teamId, + target: 'production', + state: 'READY', + limit: '1', + }) + + const response = await fetchImpl(`${VERCEL_API_BASE}/v6/deployments?${params}`, { + headers: { authorization: `Bearer ${token}` }, + }) + + const bodyText = await response.text() + let body + try { + body = bodyText ? JSON.parse(bodyText) : {} + } catch { + throw new Error(`Vercel API returned non-JSON (${response.status})`) + } + + if (!response.ok) { + const message = + typeof body?.error?.message === 'string' + ? body.error.message + : bodyText || `HTTP ${response.status}` + throw new Error(`Vercel deployments API ${response.status}: ${message}`) + } + + const deployments = Array.isArray(body.deployments) ? body.deployments : [] + return lastProductionReadyAtFromDeployment(deployments[0]) +} + +/** + * @param {{ + * token: string + * projectId: string + * teamId: string + * fetchImpl?: typeof fetch + * nowMs?: number + * }} options + */ +export const evaluateDeployCooldown = async ({ + token, + projectId, + teamId, + fetchImpl = fetch, + nowMs = Date.now(), +}) => { + const lastProductionDeployAt = await fetchLastProductionReadyAt({ + token, + projectId, + teamId, + fetchImpl, + }) + const { active, waitSeconds, ageMs } = computeCooldownWait(lastProductionDeployAt, nowMs) + + return { + active, + waitSeconds, + ageMs, + lastProductionDeployAt, + } +} diff --git a/scripts/vercel-deploy-cooldown.mjs b/scripts/vercel-deploy-cooldown.mjs new file mode 100644 index 00000000..fd99282f --- /dev/null +++ b/scripts/vercel-deploy-cooldown.mjs @@ -0,0 +1,71 @@ +#!/usr/bin/env node +/** + * vercel-deploy-cooldown — CI helper for OPS11. Queries the last READY + * production deployment on Vercel and writes GitHub Actions outputs: + * active=true|false + * wait_seconds= + * last_production_deploy_at= + */ +import { appendFileSync } from 'node:fs' + +import { dieWithLabel } from './lib/cli.mjs' +import { + DEPLOY_COOLDOWN_MS, + evaluateDeployCooldown, +} from './lib/vercel-deploy-cooldown.mjs' + +const die = dieWithLabel('vercel-deploy-cooldown') + +const writeGithubOutput = (pairs) => { + const outputPath = process.env.GITHUB_OUTPUT + const lines = Object.entries(pairs) + .map(([key, value]) => `${key}=${value}`) + .join('\n') + .concat('\n') + + if (outputPath) { + appendFileSync(outputPath, lines) + return + } + + process.stdout.write(lines) +} + +const main = async () => { + const token = process.env.VERCEL_TOKEN + const projectId = process.env.VERCEL_PROJECT_ID + const teamId = process.env.VERCEL_ORG_ID + + if (!token) die('VERCEL_TOKEN is required') + if (!projectId) die('VERCEL_PROJECT_ID is required') + if (!teamId) die('VERCEL_ORG_ID is required') + + const result = await evaluateDeployCooldown({ token, projectId, teamId }) + + if (result.lastProductionDeployAt == null) { + console.log( + `[vercel-deploy-cooldown] no READY production deployment found — proceed (${DEPLOY_COOLDOWN_MS / 60_000} min cooldown N/A)`, + ) + } else if (result.active) { + const ageMinutes = Math.floor((result.ageMs ?? 0) / 60_000) + console.log( + `[vercel-deploy-cooldown] defer deploy — last production deploy ${ageMinutes} min ago; wait ${result.waitSeconds}s`, + ) + } else { + const ageMinutes = Math.floor((result.ageMs ?? 0) / 60_000) + console.log( + `[vercel-deploy-cooldown] cooldown clear — last production deploy ${ageMinutes} min ago`, + ) + } + + writeGithubOutput({ + active: result.active ? 'true' : 'false', + wait_seconds: String(result.waitSeconds), + last_production_deploy_at: + result.lastProductionDeployAt == null ? '' : String(result.lastProductionDeployAt), + }) +} + +main().catch((error) => { + die(error instanceof Error ? error.message : String(error)) +}) diff --git a/tests/unit/vercelDeployCooldown.unit.spec.ts b/tests/unit/vercelDeployCooldown.unit.spec.ts new file mode 100644 index 00000000..e8e1bd11 --- /dev/null +++ b/tests/unit/vercelDeployCooldown.unit.spec.ts @@ -0,0 +1,107 @@ +// @vitest-environment node + +import { describe, expect, it } from 'vitest' + +import { + computeCooldownWait, + DEPLOY_COOLDOWN_MS, + evaluateDeployCooldown, + fetchLastProductionReadyAt, + lastProductionReadyAtFromDeployment, +} from '../../scripts/lib/vercel-deploy-cooldown.mjs' + +const fakeResponse = (ok: boolean, status: number, payload: unknown) => + ({ + ok, + status, + text: async () => (payload === null ? '' : JSON.stringify(payload)), + }) as Response + +const captureFetch = (response: Response) => { + const calls: Array<{ url: string; init: RequestInit }> = [] + const fetchImpl = async (url: string | URL | Request, init?: RequestInit) => { + calls.push({ url: String(url), init: init ?? {} }) + return response + } + return { calls, fetchImpl: fetchImpl as typeof fetch } +} + +describe('vercelDeployCooldown', () => { + it('uses a 30-minute cooldown constant', () => { + expect(DEPLOY_COOLDOWN_MS).toBe(30 * 60 * 1000) + }) + + it('lastProductionReadyAtFromDeployment prefers ready over createdAt', () => { + expect(lastProductionReadyAtFromDeployment({ ready: 100, createdAt: 50 })).toBe(100) + expect(lastProductionReadyAtFromDeployment({ createdAt: 50 })).toBe(50) + expect(lastProductionReadyAtFromDeployment(null)).toBeNull() + }) + + it('computeCooldownWait is inactive when no prior deploy exists', () => { + expect(computeCooldownWait(null, 1_000_000)).toEqual({ + active: false, + waitSeconds: 0, + ageMs: null, + }) + }) + + it('computeCooldownWait defers when the last deploy is younger than 30 minutes', () => { + const now = 1_700_000_000_000 + const last = now - 10 * 60 * 1000 + + expect(computeCooldownWait(last, now)).toEqual({ + active: true, + waitSeconds: 20 * 60, + ageMs: 10 * 60 * 1000, + }) + }) + + it('computeCooldownWait clears when the last deploy is older than 30 minutes', () => { + const now = 1_700_000_000_000 + const last = now - 31 * 60 * 1000 + + expect(computeCooldownWait(last, now)).toEqual({ + active: false, + waitSeconds: 0, + ageMs: 31 * 60 * 1000, + }) + }) + + it('fetchLastProductionReadyAt reads the newest READY production deployment', async () => { + const { calls, fetchImpl } = captureFetch( + fakeResponse(true, 200, { + deployments: [{ ready: 1_700_000_000_000, createdAt: 1_699_999_000_000 }], + }), + ) + + await expect( + fetchLastProductionReadyAt({ + token: 'token', + projectId: 'prj_test', + teamId: 'team_test', + fetchImpl, + }), + ).resolves.toBe(1_700_000_000_000) + + expect(calls[0]!.url).toContain('/v6/deployments?') + expect(calls[0]!.url).toContain('projectId=prj_test') + expect(calls[0]!.url).toContain('teamId=team_test') + expect(calls[0]!.url).toContain('target=production') + expect(calls[0]!.url).toContain('state=READY') + }) + + it('evaluateDeployCooldown surfaces API failures', async () => { + const { fetchImpl } = captureFetch( + fakeResponse(false, 403, { error: { message: 'forbidden' } }), + ) + + await expect( + evaluateDeployCooldown({ + token: 'token', + projectId: 'prj_test', + teamId: 'team_test', + fetchImpl, + }), + ).rejects.toThrow(/403/) + }) +})