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