Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,49 @@ env:
HTTPX_VERSION: 0.28.1

jobs:
classify:
name: Classify deploy relevance
runs-on: ubuntu-latest
outputs:
should_deploy: ${{ steps.classify.outputs.should_deploy }}
decision: ${{ steps.classify.outputs.decision }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2

- name: Set up Node 24
uses: actions/setup-node@v4
with:
node-version: '24'

- name: Classify exact push diff
id: classify
env:
BASE_SHA: ${{ github.event.before }}
run: |
set -euo pipefail
decision="$(node scripts/vercel-ignore-build.mjs --decision --base "${BASE_SHA:-}" --head "$GITHUB_SHA")"
case "$decision" in
SKIP)
should_deploy=false
;;
BUILD)
should_deploy=true
;;
*)
echo "Unexpected deploy decision: $decision" >&2
exit 1
;;
esac
echo "decision=$decision" >> "$GITHUB_OUTPUT"
echo "should_deploy=$should_deploy" >> "$GITHUB_OUTPUT"
echo "Deploy relevance: $decision"

deploy:
name: Vercel Production Deploy
needs: classify
if: needs.classify.outputs.should_deploy == 'true'
runs-on: ubuntu-latest
outputs:
deployment_url: ${{ steps.deploy.outputs.deployment_url }}
Expand Down
158 changes: 158 additions & 0 deletions app/lib/vercel-deploy-quota-guard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import assert from 'node:assert/strict'
import { execFileSync, spawnSync } from 'node:child_process'
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import test from 'node:test'
import { fileURLToPath } from 'node:url'

const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
const HELPER = path.join(ROOT, 'scripts', 'vercel-ignore-build.mjs')

function runDecision(paths: string[]) {
return spawnSync(process.execPath, [HELPER, '--decision', '--changed', ...paths], {
cwd: ROOT,
encoding: 'utf8',
})
}

function runVercelIgnore(paths: string[]) {
return spawnSync(process.execPath, [HELPER, '--vercel-ignore', '--changed', ...paths], {
cwd: ROOT,
encoding: 'utf8',
})
}

function git(cwd: string, ...args: string[]) {
return execFileSync('git', args, { cwd, encoding: 'utf8' }).trim()
}

function createRepoWithChange(relativePath: string) {
const repo = mkdtempSync(path.join(tmpdir(), 'dgaf-vercel-quota-'))
git(repo, 'init')
git(repo, 'config', 'user.email', 'test@example.invalid')
git(repo, 'config', 'user.name', 'DGAF test')
writeFileSync(path.join(repo, 'README.md'), 'baseline\n')
git(repo, 'add', 'README.md')
git(repo, 'commit', '-m', 'baseline')
const previous = git(repo, 'rev-parse', 'HEAD')

const target = path.join(repo, relativePath)
mkdirSync(path.dirname(target), { recursive: true })
writeFileSync(target, 'changed\n')
git(repo, 'add', relativePath)
git(repo, 'commit', '-m', 'change')
const current = git(repo, 'rev-parse', 'HEAD')
return { repo, previous, current }
}

function createRepoWithRuntimeRename() {
const repo = mkdtempSync(path.join(tmpdir(), 'dgaf-vercel-quota-rename-'))
git(repo, 'init')
git(repo, 'config', 'user.email', 'test@example.invalid')
git(repo, 'config', 'user.name', 'DGAF test')
mkdirSync(path.join(repo, 'app'), { recursive: true })
writeFileSync(path.join(repo, 'app/page.tsx'), 'export default function Page() { return null }\n')
git(repo, 'add', 'app/page.tsx')
git(repo, 'commit', '-m', 'runtime baseline')
const previous = git(repo, 'rev-parse', 'HEAD')

mkdirSync(path.join(repo, 'docs'), { recursive: true })
git(repo, 'mv', 'app/page.tsx', 'docs/page.tsx')
git(repo, 'commit', '-m', 'move runtime file')
const current = git(repo, 'rev-parse', 'HEAD')
return { repo, previous, current }
}

function runGitDecision(cwd: string, previous?: string, current?: string) {
const args = [HELPER, '--decision', '--base', previous ?? '', '--head', current ?? '']
return spawnSync(process.execPath, args, { cwd, encoding: 'utf8' })
}

test('only proven inert surfaces are classified SKIP', () => {
for (const changed of [
['docs/experiment/status.md'],
['schemas/example.schema.json'],
['tests/test_example.py'],
['.github/workflows/claim-hygiene.yml'],
['docs/status.md', 'schemas/example.json', 'tests/test_example.py'],
]) {
const result = runDecision(changed)
assert.equal(result.status, 0, result.stderr || result.stdout)
assert.equal(result.stdout.trim(), 'SKIP')
}
})

test('runtime, scripts, deploy control, config, unknown, mixed, and empty changes classify BUILD', () => {
const cases = [
['scripts/validate_example.py'],
['scripts/vercel-ignore-build.mjs'],
['.github/workflows/deploy.yml'],
['app/page.tsx'],
['api/health.py'],
['public/logo.svg'],
['vercel.json'],
['package.json'],
['package-lock.json'],
['next.config.ts'],
['tsconfig.json'],
['new-runtime-surface/example.ts'],
['docs/status.md', 'app/page.tsx'],
[],
]

for (const changed of cases) {
const result = runDecision(changed)
assert.equal(result.status, 0, result.stderr || result.stdout)
assert.equal(result.stdout.trim(), 'BUILD')
}
})

test('Vercel ignore exit semantics are SKIP=0 and BUILD=1', () => {
assert.equal(runVercelIgnore(['docs/status.md']).status, 0)
assert.equal(runVercelIgnore(['app/page.tsx']).status, 1)
})

test('Git comparison classifies a documentation-only commit SKIP', () => {
const fixture = createRepoWithChange('docs/status.md')
try {
const result = runGitDecision(fixture.repo, fixture.previous, fixture.current)
assert.equal(result.status, 0, result.stderr || result.stdout)
assert.equal(result.stdout.trim(), 'SKIP')
} finally {
rmSync(fixture.repo, { recursive: true, force: true })
}
})

test('Git comparison fails closed to BUILD for invalid evidence', () => {
const fixture = createRepoWithChange('docs/status.md')
try {
assert.equal(runGitDecision(fixture.repo, undefined, fixture.current).stdout.trim(), 'BUILD')
assert.equal(runGitDecision(fixture.repo, 'not-a-commit', fixture.current).stdout.trim(), 'BUILD')
} finally {
rmSync(fixture.repo, { recursive: true, force: true })
}
})

test('runtime rename into an inert surface still classifies BUILD', () => {
const fixture = createRepoWithRuntimeRename()
try {
const result = runGitDecision(fixture.repo, fixture.previous, fixture.current)
assert.equal(result.status, 0, result.stderr || result.stdout)
assert.equal(result.stdout.trim(), 'BUILD')
} finally {
rmSync(fixture.repo, { recursive: true, force: true })
}
})

test('Vercel and GitHub deploy paths are wired to the same classifier', () => {
const config = JSON.parse(readFileSync(path.join(ROOT, 'vercel.json'), 'utf8'))
assert.equal(config.ignoreCommand, 'node scripts/vercel-ignore-build.mjs')
assert.equal(config.git?.deploymentEnabled?.main, false)

const workflow = readFileSync(path.join(ROOT, '.github/workflows/deploy.yml'), 'utf8')
assert.match(workflow, /name: Classify deploy relevance/)
assert.match(workflow, /node scripts\/vercel-ignore-build\.mjs --decision --base/)
assert.match(workflow, /needs: classify/)
assert.match(workflow, /needs\.classify\.outputs\.should_deploy == 'true'/)
})
60 changes: 60 additions & 0 deletions scripts/vercel-ignore-build.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
#!/usr/bin/env node

import { spawnSync } from 'node:child_process'

const SAFE_SKIP_PREFIXES = ['.github/', 'docs/', 'schemas/', 'tests/']
const FORCE_BUILD_PATHS = new Set(['.github/workflows/deploy.yml'])

function canSafelySkip(changedPaths) {
return changedPaths.length > 0 && changedPaths.every(path => {
if (FORCE_BUILD_PATHS.has(path)) return false
return SAFE_SKIP_PREFIXES.some(prefix => path.startsWith(prefix))
})
}

function isAvailableCommit(sha) {
if (!sha) return false
return spawnSync('git', ['cat-file', '-e', `${sha}^{commit}`], { stdio: 'ignore' }).status === 0
}

function changedPathsFromGit(base, head) {
if (!isAvailableCommit(base) || !isAvailableCommit(head)) return null

const diff = spawnSync(
'git',
['diff', '--name-only', '-z', '--no-renames', base, head, '--'],
{ encoding: 'utf8' },
)
if (diff.status !== 0 || diff.error) return null
return diff.stdout.split('\0').filter(Boolean)
}

function readOption(args, name) {
const index = args.indexOf(name)
return index >= 0 ? args[index + 1] ?? '' : ''
}

const args = process.argv.slice(2)
const decisionMode = args.includes('--decision')
const explicitIgnoreMode = args.includes('--vercel-ignore')
const changedIndex = args.indexOf('--changed')

let changedPaths
if (changedIndex >= 0) {
changedPaths = args.slice(changedIndex + 1)
} else {
const base = readOption(args, '--base') || process.env.VERCEL_GIT_PREVIOUS_SHA || ''
const head = readOption(args, '--head') || process.env.VERCEL_GIT_COMMIT_SHA || ''
changedPaths = changedPathsFromGit(base, head)
}

const decision = changedPaths && canSafelySkip(changedPaths) ? 'SKIP' : 'BUILD'

if (decisionMode) {
process.stdout.write(`${decision}\n`)
process.exit(0)
}

if (explicitIgnoreMode || !decisionMode) {
process.exit(decision === 'SKIP' ? 0 : 1)
}
1 change: 1 addition & 0 deletions vercel.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"framework": "nextjs",
"buildCommand": "npm run build",
"outputDirectory": ".next",
"ignoreCommand": "node scripts/vercel-ignore-build.mjs",
"git": {
"deploymentEnabled": {
"main": false
Expand Down
Loading