feat(deploy): add Contabo release workflow and portal payload gate
This commit is contained in:
1 parent
5fa54719b2
commit
ba93a54942
18 files changed
+697
-28
No files matched your search
@@ -2,6 +2,7 @@ import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import fg from 'fast-glob';
|
||||
import { validatePortalPayload } from '../src/utils/portal-crypto.mjs';
|
||||
|
||||
const readText = async (file) => fs.readFile(file, 'utf8');
|
||||
|
||||
@@ -36,6 +37,12 @@ function hasTerminalSlash(url) {
|
||||
return pathname !== '/' && pathname.endsWith('/');
|
||||
}
|
||||
|
||||
function portalPayload(html) {
|
||||
const match = html.match(/<script[^>]*data-portal-payload[^>]*>([\s\S]*?)<\/script>/i);
|
||||
if (!match || match[1].trim() === 'null') return undefined;
|
||||
try { return validatePortalPayload(JSON.parse(match[1])); } catch { return null; }
|
||||
}
|
||||
|
||||
const formAContactLandingUrls = new Map([
|
||||
['/contact', 'https://form.jotform.com/262946078314059?formContext=contact&language=en'],
|
||||
['/es/contact', 'https://form.jotform.com/262946078314059?formContext=contact&language=es'],
|
||||
@@ -89,9 +96,11 @@ function unsafeSignals(html, allowApprovedFormA = false) {
|
||||
return signals;
|
||||
}
|
||||
|
||||
export async function verifyArtifact(root, mode = 'staging', forms = 'live') {
|
||||
export async function verifyArtifact(root, mode = 'staging', forms = 'live', portal = { mode: 'none' }) {
|
||||
if (!['staging', 'indexing'].includes(mode)) throw new Error('Artifact mode must be staging or indexing; form availability is selected separately with --forms=live|off|mock.');
|
||||
if (!['live', 'off', 'mock'].includes(forms)) throw new Error('Artifact forms must be live, off, or mock.');
|
||||
if (!portal || !['none', 'synthetic', 'production'].includes(portal.mode)) throw new Error('Portal artifact mode must be none, synthetic, or production.');
|
||||
if (portal.mode === 'production' && (!/^[0-9a-f]{64}$/.test(portal.sha256 ?? '') || mode !== 'indexing')) throw new Error('Production portal verification requires indexing mode and its expected payload SHA-256.');
|
||||
const formAEnabled = forms === 'live';
|
||||
const stagingPolicy = mode === 'staging';
|
||||
const staticFormAUrls = formAEnabled ? formAStaticUrls : new Map();
|
||||
@@ -142,7 +151,18 @@ export async function verifyArtifact(root, mode = 'staging', forms = 'live') {
|
||||
}
|
||||
if (expectedFormAUrl && deferredIframeSources(html).length) errors.push(`${route}: unexpected deferred frame`);
|
||||
if (expectedDeferredFormAUrl && iframeSources(html).length) errors.push(`${route}: drawer must not load before activation`);
|
||||
if (/<script[^>]*data-portal-payload[^>]*>\s*(?!null\s*<)[\s\S]*?\"streams\"[\s\S]*?<\/script>/i.test(html)) errors.push(`${route}: private portal ciphertext is not an approved release artifact`);
|
||||
const payload = portalPayload(html);
|
||||
if (payload === null) errors.push(`${route}: malformed portal ciphertext`);
|
||||
if (payload && route !== '/employee-portal') errors.push(`${route}: portal ciphertext appears outside /employee-portal`);
|
||||
if (route === '/employee-portal') {
|
||||
if (portal.mode === 'none' && payload) errors.push(`${route}: private portal ciphertext is not approved for this artifact`);
|
||||
if (portal.mode === 'production' && !payload) errors.push(`${route}: approved production portal ciphertext is missing`);
|
||||
if (portal.mode === 'synthetic' && !payload) errors.push(`${route}: synthetic portal ciphertext is missing`);
|
||||
if (payload && portal.mode === 'production') {
|
||||
const actual = createHash('sha256').update(JSON.stringify(payload)).digest('hex');
|
||||
if (actual !== portal.sha256) errors.push(`${route}: portal ciphertext does not match the approved build input`);
|
||||
}
|
||||
}
|
||||
if (!title) errors.push(`${route}: missing title`);
|
||||
if (!canonical) errors.push(`${route}: missing canonical`);
|
||||
if (!robots) errors.push(`${route}: missing robots directive`);
|
||||
@@ -199,7 +219,7 @@ export async function verifyArtifact(root, mode = 'staging', forms = 'live') {
|
||||
const digest = createHash('sha256');
|
||||
for (const file of files) {
|
||||
const relative = path.relative(root, file).replaceAll(path.sep, '/');
|
||||
if (/(?:^|\/)(?:private|hubfs)(?:\/|$)/i.test(relative)) errors.push('private/legacy portal resource directory in artifact');
|
||||
if (/(?:^|\/)(?:private|hubfs|staff-resources|management-resources)(?:\/|$)/i.test(relative)) errors.push('private/legacy portal resource directory in artifact');
|
||||
if (/\.(?:html?|js|mjs|css|json|xml|txt)$/i.test(relative)) {
|
||||
const contents = await fs.readFile(file, 'utf8');
|
||||
if (/262946078314059/i.test(contents)) formAResourceFiles.push(relative);
|
||||
@@ -221,6 +241,7 @@ export async function verifyArtifact(root, mode = 'staging', forms = 'live') {
|
||||
return {
|
||||
mode,
|
||||
forms,
|
||||
portal: portal.mode,
|
||||
htmlRoutes: htmlFiles.length,
|
||||
sitemapUrls: sitemapRoutes.length,
|
||||
hreflangLinks,
|
||||
@@ -241,7 +262,9 @@ if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(new URL(im
|
||||
const mode = process.argv.find((arg) => arg.startsWith('--mode='))?.slice('--mode='.length) ?? 'staging';
|
||||
const root = process.argv.find((arg) => arg.startsWith('--root='))?.slice('--root='.length) ?? 'dist';
|
||||
const forms = process.argv.find((arg) => arg.startsWith('--forms='))?.slice('--forms='.length) ?? 'live';
|
||||
const result = await verifyArtifact(path.resolve(root), mode, forms);
|
||||
const portalMode = process.argv.find((arg) => arg.startsWith('--portal='))?.slice('--portal='.length) ?? 'none';
|
||||
const portalSha256 = process.argv.find((arg) => arg.startsWith('--portal-sha256='))?.slice('--portal-sha256='.length);
|
||||
const result = await verifyArtifact(path.resolve(root), mode, forms, { mode: portalMode, sha256: portalSha256 });
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
if (!result.passed) process.exitCode = 1;
|
||||
}
|
||||
Reference in new issue
Block a user