166 lines
5.3 KiB
JavaScript
166 lines
5.3 KiB
JavaScript
#!/usr/bin/env node
|
|
import { execFileSync } from 'node:child_process';
|
|
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
import { dirname, join, relative } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
const failures = [];
|
|
const warnings = [];
|
|
const ignoredArtifacts = [];
|
|
const showLocalArtifacts =
|
|
process.env.PROJECTBRAIN_AUDIT_VERBOSE_LOCAL_ARTIFACTS === '1';
|
|
|
|
const gitLines = (args) => {
|
|
try {
|
|
return execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.filter(Boolean);
|
|
} catch {
|
|
return [];
|
|
}
|
|
};
|
|
|
|
const isTracked = (path) => gitLines(['ls-files', '--', path]).length > 0;
|
|
const isIgnored = (path) => gitLines(['check-ignore', '--', path]).length > 0;
|
|
const reportArtifact = (path, kind = 'path') => {
|
|
const absolute = join(root, path);
|
|
if (!existsSync(absolute)) return;
|
|
if (isTracked(path)) {
|
|
failures.push(`Forbidden tracked release artifact/${kind} exists: ${path}`);
|
|
} else if (isIgnored(path)) {
|
|
ignoredArtifacts.push(`Ignored local artifact/${kind} present but not release-blocking: ${path}`);
|
|
} else {
|
|
failures.push(`Forbidden unignored release artifact/${kind} exists: ${path}`);
|
|
}
|
|
};
|
|
|
|
const forbiddenPaths = [
|
|
'node_modules',
|
|
'backend/node_modules',
|
|
'dist',
|
|
'backend/dist',
|
|
'checkpoints',
|
|
'src/App.css',
|
|
'src/globals.css',
|
|
];
|
|
|
|
for (const path of forbiddenPaths) {
|
|
reportArtifact(path);
|
|
}
|
|
|
|
const forbiddenFilePatterns = [/^vite-.*\.log$/, /\.zip$/i, /\.log$/i];
|
|
const rootFiles = existsSync(root) ? readdirSync(root) : [];
|
|
for (const file of rootFiles) {
|
|
if (forbiddenFilePatterns.some((pattern) => pattern.test(file))) {
|
|
reportArtifact(file, 'file');
|
|
}
|
|
}
|
|
|
|
const textFilesToScan = [
|
|
'package.json',
|
|
'vite.config.ts',
|
|
'index.html',
|
|
'README.md',
|
|
'backend/README.md',
|
|
];
|
|
const forbiddenText = [/componentTagger/i, /component-tagger/i];
|
|
for (const path of textFilesToScan) {
|
|
const absolute = join(root, path);
|
|
if (!existsSync(absolute)) {
|
|
warnings.push(`Expected scan file missing: ${path}`);
|
|
continue;
|
|
}
|
|
const content = readFileSync(absolute, 'utf8');
|
|
for (const pattern of forbiddenText) {
|
|
if (pattern.test(content)) {
|
|
failures.push(`Forbidden branding/tooling text ${pattern} found in ${path}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
const expectedFiles = [
|
|
'.env.example',
|
|
'backend/.env.example',
|
|
'Dockerfile.frontend',
|
|
'backend/Dockerfile',
|
|
'docker-compose.yml',
|
|
'docker-compose.dev.yml',
|
|
'docker-compose.release.yml',
|
|
'scripts/stage-release-oci.sh',
|
|
'docs/PROJECTBRAIN_DOCUMENTATION_INDEX.md',
|
|
'docs/PROJECTBRAIN_OPERATOR_RELEASE_GUIDE.md',
|
|
'docs/FULL_VERSION_ROADMAP.md',
|
|
'docs/VERTICAL_SLICE_CORE_FLOW_PLAN.md',
|
|
'public/site.webmanifest',
|
|
'public/favicon.svg',
|
|
'public/favicon.ico',
|
|
];
|
|
for (const path of expectedFiles) {
|
|
if (!existsSync(join(root, path))) {
|
|
warnings.push(`Expected project hygiene file missing: ${path}`);
|
|
}
|
|
}
|
|
|
|
const workflowPaths = ['.gitea/workflows/ci.yml', '.gitea/workflows/release.yml'];
|
|
const workflow = workflowPaths
|
|
.map((path) => readFileSync(join(root, path), 'utf8'))
|
|
.join('\n');
|
|
for (const marker of [
|
|
'file: Dockerfile.unraid',
|
|
'Record immutable archive digests',
|
|
'Scan Unraid release image',
|
|
'archive-sha256.txt',
|
|
'trivy-unraid.json',
|
|
'VITE_PROJECTBRAIN_COMMIT=${{ github.sha }}',
|
|
'org.opencontainers.image.revision=${{ github.sha }}',
|
|
]) {
|
|
if (!workflow.includes(marker)) failures.push(`Release workflow is missing required artifact evidence marker: ${marker}`);
|
|
}
|
|
if (workflow.includes('Dockerfile.backend')) {
|
|
failures.push('Release workflow references nonexistent Dockerfile.backend; use backend/Dockerfile.');
|
|
}
|
|
if (/aquasec\/trivy(?::[\w.-]+)(?!@sha256:)/.test(workflow) || !workflow.includes('aquasec/trivy@sha256:')) {
|
|
failures.push('Trivy containers must be pinned to an immutable sha256 digest.');
|
|
}
|
|
if (/uses:\s+(?!docker:\/\/)[^\s@]+@(?![a-f0-9]{40}(?:\s|$))/.test(workflow)) {
|
|
failures.push('External workflow actions must be pinned to immutable 40-character commit SHAs.');
|
|
}
|
|
|
|
const maxLargeFileBytes = 5 * 1024 * 1024;
|
|
const ignoredDirs = new Set(['.git', 'node_modules', 'backend/node_modules']);
|
|
function walk(dir) {
|
|
for (const entry of readdirSync(dir)) {
|
|
const absolute = join(dir, entry);
|
|
const rel = relative(root, absolute).replaceAll('\\\\', '/');
|
|
if (ignoredDirs.has(rel) || ignoredDirs.has(entry)) continue;
|
|
const stat = statSync(absolute);
|
|
if (stat.isDirectory()) {
|
|
walk(absolute);
|
|
} else if (stat.size > maxLargeFileBytes) {
|
|
warnings.push(`Large file in source tree (${Math.round(stat.size / 1024 / 1024)}MB): ${rel}`);
|
|
}
|
|
}
|
|
}
|
|
walk(root);
|
|
|
|
console.log('ProjectBrain release hygiene check');
|
|
console.log('====================================');
|
|
if (ignoredArtifacts.length) {
|
|
if (showLocalArtifacts) {
|
|
console.log('\nLocal ignored artifacts:');
|
|
for (const artifact of ignoredArtifacts) console.log(`- ${artifact}`);
|
|
}
|
|
}
|
|
if (warnings.length) {
|
|
console.log('\nWarnings:');
|
|
for (const warning of warnings) console.log(`- ${warning}`);
|
|
}
|
|
if (failures.length) {
|
|
console.log('\nFailures:');
|
|
for (const failure of failures) console.log(`- ${failure}`);
|
|
process.exit(1);
|
|
}
|
|
console.log('\nPASS: no blocking release hygiene issues found.');
|