diff --git a/.github/workflows/website-ci.yml b/.github/workflows/website-ci.yml index e814a04..ac5f14b 100644 --- a/.github/workflows/website-ci.yml +++ b/.github/workflows/website-ci.yml @@ -36,6 +36,8 @@ jobs: run: npm ci - name: Build Next.js application + env: + MOTORSIGNAL_GIT_SHA: ${{ github.sha }} run: npm run build - name: Run Founder Portal tests @@ -49,3 +51,6 @@ jobs: - name: Run Navigation and Auth tests run: node tests/navigation-auth.cjs + + - name: Verify deployment revision contract + run: node tests/deployment-revision.test.cjs diff --git a/scripts/deploy-website.ps1 b/scripts/deploy-website.ps1 index 5dbf9e2..0c9b117 100644 --- a/scripts/deploy-website.ps1 +++ b/scripts/deploy-website.ps1 @@ -21,7 +21,22 @@ if ($Key) { $SshKeyArgs = @("-i", $Key) } +$RepositoryRoot = (Resolve-Path "$PSScriptRoot\..").Path +$GitSha = (git -C $RepositoryRoot rev-parse HEAD).Trim() +if ($GitSha -notmatch '^[0-9a-f]{40}$') { + Write-Error "Unable to resolve a valid 40-character Git revision." + exit 1 +} + +# Refuse dirty website inputs before assigning the HEAD revision +$DirtyWebsite = (git -C $RepositoryRoot status --porcelain -- website).Trim() +if ($DirtyWebsite) { + Write-Error "Cannot deploy website: working tree contains uncommitted changes under website/:`n$DirtyWebsite" + exit 1 +} + $Timestamp = Get-Date -Format "yyyyMMdd-HHmmss" +Write-Host ">>> Deploying website revision $GitSha" -ForegroundColor Cyan if ($BuildRemote) { # Ultra-lean mode: Upload only source files (~100 KB total) @@ -29,7 +44,7 @@ if ($BuildRemote) { $TempTar = "$PSScriptRoot\$TarName" Write-Host ">>> 1. Packaging source code (~100 KB)..." -ForegroundColor Cyan - tar -czf "$TempTar" -C website app components lib public package.json tsconfig.json next.config.ts + tar -czf "$TempTar" -C "$RepositoryRoot\website" app components lib public package.json tsconfig.json next.config.ts Write-Host ">>> 2. Uploading source code to server..." -ForegroundColor Cyan scp @SshKeyArgs "$TempTar" "${User}@${HostIp}:/tmp/$TarName" @@ -48,7 +63,7 @@ rm -f "`$TAR" cd "`$TARGET" echo "Running Next.js production build on server..." -npm run build +MOTORSIGNAL_GIT_SHA="$GitSha" npm run build echo "Restarting motorsignal-website service..." sudo systemctl restart motorsignal-website @@ -60,13 +75,23 @@ echo "Done!" # Fast local build: Strip Next.js cache & dev artifacts (~2 MB total instead of 500 MB) Write-Host ">>> 1. Building Next.js application locally..." -ForegroundColor Cyan Set-Location -Path "$PSScriptRoot\..\website" - npm run build - Set-Location -Path "$PSScriptRoot\.." + $PreviousDeploySha = $env:MOTORSIGNAL_GIT_SHA + try { + $env:MOTORSIGNAL_GIT_SHA = $GitSha + npm run build + } finally { + if ($null -eq $PreviousDeploySha) { + Remove-Item Env:MOTORSIGNAL_GIT_SHA -ErrorAction SilentlyContinue + } else { + $env:MOTORSIGNAL_GIT_SHA = $PreviousDeploySha + } + Set-Location -Path $RepositoryRoot + } $TarName = "website-update-$Timestamp.tar.gz" $TempTar = "$PSScriptRoot\$TarName" - tar -czf "$TempTar" --exclude="cache" --exclude="dev" --exclude="standalone" -C website .next/server .next/static .next/types .next/BUILD_ID .next/*.json public package.json app components lib + tar -czf "$TempTar" --exclude="cache" --exclude="dev" --exclude="standalone" -C "$RepositoryRoot\website" .next/server .next/static .next/types .next/BUILD_ID .next/*.json public package.json app components lib $SizeMb = [math]::Round(((Get-Item "$TempTar").Length / 1MB), 2) Write-Host ">>> 3. Uploading $SizeMb MB to server..." -ForegroundColor Cyan @@ -92,7 +117,15 @@ echo "Service restarted." ssh -t @SshKeyArgs "${User}@${HostIp}" $RemoteCmd } -Write-Host ">>> 5. Checking website response..." -ForegroundColor Green +Write-Host ">>> 5. Checking deployed revision..." -ForegroundColor Green Start-Sleep -Seconds 2 -$StatusCode = (Invoke-WebRequest -Uri "https://motorsignal.app/account" -Method Head -UseBasicParsing).StatusCode -Write-Host ">>> Deployed successfully! HTTP Status: $StatusCode" -ForegroundColor Green +$Version = Invoke-RestMethod -Uri "https://motorsignal.app/api/version" -Method Get +if ($Version.status -ne "ok" -or $Version.service -ne "motorsignal-website") { + Write-Error "Website version endpoint returned an unhealthy response." + exit 1 +} +if ($Version.revision -ne $GitSha) { + Write-Error "Deployment revision mismatch. Expected $GitSha but production reports $($Version.revision)." + exit 1 +} +Write-Host ">>> Deployed successfully! Verified revision: $($Version.shortRevision)" -ForegroundColor Green diff --git a/website/app/api/version/route.ts b/website/app/api/version/route.ts new file mode 100644 index 0000000..b9e19e5 --- /dev/null +++ b/website/app/api/version/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from 'next/server'; + +export const dynamic = 'force-dynamic'; + +export function GET() { + const revision = process.env.MOTORSIGNAL_DEPLOY_REVISION || 'unknown'; + + return NextResponse.json( + { + status: 'ok', + service: 'motorsignal-website', + revision, + shortRevision: revision === 'unknown' ? 'unknown' : revision.slice(0, 12), + }, + { + headers: { + 'Cache-Control': 'no-store, max-age=0', + }, + }, + ); +} diff --git a/website/next.config.ts b/website/next.config.ts index fa1e26a..307fce8 100644 --- a/website/next.config.ts +++ b/website/next.config.ts @@ -1,9 +1,15 @@ import type { NextConfig } from 'next'; +const deployRevision = process.env.MOTORSIGNAL_GIT_SHA?.trim() || 'unknown'; + const nextConfig: NextConfig = { output: 'standalone', poweredByHeader: false, reactStrictMode: true, + env: { + // Embedded at build time so /api/version identifies the exact deployed source. + MOTORSIGNAL_DEPLOY_REVISION: deployRevision, + }, }; export default nextConfig; diff --git a/website/tests/deployment-revision.test.cjs b/website/tests/deployment-revision.test.cjs new file mode 100644 index 0000000..6d63223 --- /dev/null +++ b/website/tests/deployment-revision.test.cjs @@ -0,0 +1,23 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const root = path.resolve(__dirname, '..', '..'); +const route = fs.readFileSync(path.join(root, 'website/app/api/version/route.ts'), 'utf8'); +const config = fs.readFileSync(path.join(root, 'website/next.config.ts'), 'utf8'); +const deploy = fs.readFileSync(path.join(root, 'scripts/deploy-website.ps1'), 'utf8'); + +assert.match(config, /MOTORSIGNAL_GIT_SHA/); +assert.match(config, /MOTORSIGNAL_DEPLOY_REVISION/); +assert.match(route, /service: 'motorsignal-website'/); +assert.match(route, /revision/); +assert.match(route, /Cache-Control/); +assert.match(route, /no-store/); +assert.match(deploy, /git -C \$RepositoryRoot rev-parse HEAD/); +assert.match(deploy, /MOTORSIGNAL_GIT_SHA/); +assert.match(deploy, /https:\/\/motorsignal\.app\/api\/version/); +assert.match(deploy, /Deployment revision mismatch/); +assert.match(deploy, /status --porcelain -- website/); +assert.match(deploy, /working tree contains uncommitted changes/); + +console.log('PASS: website deployment revision contract verified.');