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:
parent
9d99bfa4e4
commit
7efd29a214
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
Loading…
Reference in New Issue