feat(website): expose and verify deployed Git revision (#13)

* feat(website): embed deployment revision

* feat(website): expose deployment revision endpoint

* fix(website): normalize revision variable name

* fix(website): normalize revision variable name

* feat(deploy): verify exact website revision

* test(website): cover deployment revision contract

* ci(website): validate deployment revision contract

* fix(deploy): resolve package paths from repository root

* fix(deploy): reject dirty website sources before assigning deployment revision

- Check git status --porcelain on website/ and refuse deployment if uncommitted changes exist.

- Add assertions in deployment-revision.test.cjs to enforce dirty working tree rejection.

---------

Co-authored-by: Developer <dev@example.com>
This commit is contained in:
Billie Ponder 2026-09-20 18:59:17 -04:00 committed by GitHub
parent 9d99bfa4e4
commit 7efd29a214
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 96 additions and 8 deletions

View File

@ -36,6 +36,8 @@ jobs:
run: npm ci run: npm ci
- name: Build Next.js application - name: Build Next.js application
env:
MOTORSIGNAL_GIT_SHA: ${{ github.sha }}
run: npm run build run: npm run build
- name: Run Founder Portal tests - name: Run Founder Portal tests
@ -49,3 +51,6 @@ jobs:
- name: Run Navigation and Auth tests - name: Run Navigation and Auth tests
run: node tests/navigation-auth.cjs run: node tests/navigation-auth.cjs
- name: Verify deployment revision contract
run: node tests/deployment-revision.test.cjs

View File

@ -21,7 +21,22 @@ if ($Key) {
$SshKeyArgs = @("-i", $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" $Timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
Write-Host ">>> Deploying website revision $GitSha" -ForegroundColor Cyan
if ($BuildRemote) { if ($BuildRemote) {
# Ultra-lean mode: Upload only source files (~100 KB total) # Ultra-lean mode: Upload only source files (~100 KB total)
@ -29,7 +44,7 @@ if ($BuildRemote) {
$TempTar = "$PSScriptRoot\$TarName" $TempTar = "$PSScriptRoot\$TarName"
Write-Host ">>> 1. Packaging source code (~100 KB)..." -ForegroundColor Cyan 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 Write-Host ">>> 2. Uploading source code to server..." -ForegroundColor Cyan
scp @SshKeyArgs "$TempTar" "${User}@${HostIp}:/tmp/$TarName" scp @SshKeyArgs "$TempTar" "${User}@${HostIp}:/tmp/$TarName"
@ -48,7 +63,7 @@ rm -f "`$TAR"
cd "`$TARGET" cd "`$TARGET"
echo "Running Next.js production build on server..." echo "Running Next.js production build on server..."
npm run build MOTORSIGNAL_GIT_SHA="$GitSha" npm run build
echo "Restarting motorsignal-website service..." echo "Restarting motorsignal-website service..."
sudo systemctl restart motorsignal-website 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) # 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 Write-Host ">>> 1. Building Next.js application locally..." -ForegroundColor Cyan
Set-Location -Path "$PSScriptRoot\..\website" Set-Location -Path "$PSScriptRoot\..\website"
npm run build $PreviousDeploySha = $env:MOTORSIGNAL_GIT_SHA
Set-Location -Path "$PSScriptRoot\.." 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" $TarName = "website-update-$Timestamp.tar.gz"
$TempTar = "$PSScriptRoot\$TarName" $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) $SizeMb = [math]::Round(((Get-Item "$TempTar").Length / 1MB), 2)
Write-Host ">>> 3. Uploading $SizeMb MB to server..." -ForegroundColor Cyan Write-Host ">>> 3. Uploading $SizeMb MB to server..." -ForegroundColor Cyan
@ -92,7 +117,15 @@ echo "Service restarted."
ssh -t @SshKeyArgs "${User}@${HostIp}" $RemoteCmd 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 Start-Sleep -Seconds 2
$StatusCode = (Invoke-WebRequest -Uri "https://motorsignal.app/account" -Method Head -UseBasicParsing).StatusCode $Version = Invoke-RestMethod -Uri "https://motorsignal.app/api/version" -Method Get
Write-Host ">>> Deployed successfully! HTTP Status: $StatusCode" -ForegroundColor Green 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

View File

@ -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',
},
},
);
}

View File

@ -1,9 +1,15 @@
import type { NextConfig } from 'next'; import type { NextConfig } from 'next';
const deployRevision = process.env.MOTORSIGNAL_GIT_SHA?.trim() || 'unknown';
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: 'standalone', output: 'standalone',
poweredByHeader: false, poweredByHeader: false,
reactStrictMode: true, reactStrictMode: true,
env: {
// Embedded at build time so /api/version identifies the exact deployed source.
MOTORSIGNAL_DEPLOY_REVISION: deployRevision,
},
}; };
export default nextConfig; export default nextConfig;

View File

@ -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.');