diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index b59dcc3d0..ee5702fd8 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -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 }} diff --git a/app/lib/vercel-deploy-quota-guard.test.ts b/app/lib/vercel-deploy-quota-guard.test.ts new file mode 100644 index 000000000..6e9d6b988 --- /dev/null +++ b/app/lib/vercel-deploy-quota-guard.test.ts @@ -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'/) +}) diff --git a/scripts/vercel-ignore-build.mjs b/scripts/vercel-ignore-build.mjs new file mode 100644 index 000000000..b7f97a73d --- /dev/null +++ b/scripts/vercel-ignore-build.mjs @@ -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) +} diff --git a/vercel.json b/vercel.json index 60f8e604a..edaf72f27 100644 --- a/vercel.json +++ b/vercel.json @@ -4,6 +4,7 @@ "framework": "nextjs", "buildCommand": "npm run build", "outputDirectory": ".next", + "ignoreCommand": "node scripts/vercel-ignore-build.mjs", "git": { "deploymentEnabled": { "main": false