OPS11 — cooldown de 30 min entre deploys de produção (Vercel)

- Script vercel-deploy-cooldown consulta último deploy READY via API Vercel
- Job deploy em ci.yml adia upload quando idade < 30 min (sucesso, não falha)
- Job requeue espera o residual e re-dispara ci.yml
- vercel deploy passa --archive=tgz para reduzir pressão de upload
- Testes unitários para computeCooldownWait e fetch da API

Co-authored-by: Francisco Solla <fsolla@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-08-01 10:54:55 +00:00
parent 73f256e227
commit cbddd8c796
No known key found for this signature in database
4 changed files with 349 additions and 10 deletions

View file

@ -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"

View file

@ -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<number | null>}
*/
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,
}
}

View file

@ -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=<residual>
* last_production_deploy_at=<ms epoch or empty>
*/
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))
})

View file

@ -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/)
})
})