mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
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:
parent
73f256e227
commit
cbddd8c796
4 changed files with 349 additions and 10 deletions
59
.github/workflows/ci.yml
vendored
59
.github/workflows/ci.yml
vendored
|
|
@ -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"
|
||||
|
|
|
|||
122
scripts/lib/vercel-deploy-cooldown.mjs
Normal file
122
scripts/lib/vercel-deploy-cooldown.mjs
Normal 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,
|
||||
}
|
||||
}
|
||||
71
scripts/vercel-deploy-cooldown.mjs
Normal file
71
scripts/vercel-deploy-cooldown.mjs
Normal 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))
|
||||
})
|
||||
107
tests/unit/vercelDeployCooldown.unit.spec.ts
Normal file
107
tests/unit/vercelDeployCooldown.unit.spec.ts
Normal 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/)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue