feat: implement TASK-WP0-09 packaging
Dada P0-A isolated Windows CI / validate-and-package (push) Failing after 42s
Dada P0-A isolated Windows CI / validate-and-package (push) Failing after 42s
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
name: Dada P0-A isolated Windows CI
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
validate-and-package:
|
||||
runs-on: [self-hosted, windows, x64, dada-isolated]
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
CI: "true"
|
||||
DADA_EXTERNAL_MODE: mock
|
||||
steps:
|
||||
- name: Check out frozen revision
|
||||
uses: actions/checkout@v4
|
||||
- name: Enable frozen package manager
|
||||
shell: powershell
|
||||
run: corepack enable
|
||||
- name: Install frozen dependencies
|
||||
shell: powershell
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Verify isolated CI policy
|
||||
shell: powershell
|
||||
run: node scripts/verify-ci-isolation.mjs
|
||||
- name: Run complete automated suite
|
||||
shell: powershell
|
||||
run: pnpm test:all
|
||||
- name: Build candidate portable package
|
||||
shell: powershell
|
||||
run: pnpm package:portable
|
||||
- name: Upload candidate package and evidence
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dada-p0a-candidate-${{ gitea.sha }}
|
||||
path: |
|
||||
.build/portable-release/*.zip
|
||||
.build/portable-release/*.sha256
|
||||
artifacts/tdd/**
|
||||
if-no-files-found: error
|
||||
+1
-1
@@ -59,7 +59,7 @@ export interface CreateAppOptions {
|
||||
publicAssets?: PublicAssetResolver;
|
||||
}
|
||||
|
||||
const supportGateDirectory = resolve("apps/web/support-gate");
|
||||
const supportGateDirectory = resolve(process.env.DADA_SUPPORT_GATE_ROOT ?? "apps/web/support-gate");
|
||||
const supportGateHtml = readFileSync(resolve(supportGateDirectory, "index.html"), "utf8");
|
||||
const supportGateCss = readFileSync(resolve(supportGateDirectory, "support-gate.css"), "utf8");
|
||||
const supportGateJavaScript = readFileSync(resolve(supportGateDirectory, "support-gate.js"), "utf8");
|
||||
|
||||
@@ -192,7 +192,8 @@ export function resolvePathWithinRoot(root: string, objectKey: string) {
|
||||
}
|
||||
|
||||
function openInstanceDatabase(databasePath: string) {
|
||||
const database = new Database(databasePath);
|
||||
const nativeBinding = process.env.DADA_SQLITE_NATIVE_BINDING;
|
||||
const database = new Database(databasePath, nativeBinding ? { nativeBinding } : undefined);
|
||||
database.pragma("journal_mode = WAL");
|
||||
database.exec(`
|
||||
CREATE TABLE instance_metadata (
|
||||
|
||||
@@ -146,7 +146,8 @@ export class ManagedStorage {
|
||||
throw new Error("database_outside_data_root");
|
||||
}
|
||||
mkdirSync(dirname(this.databasePath), { recursive: true });
|
||||
this.database = new Database(this.databasePath);
|
||||
const nativeBinding = process.env.DADA_SQLITE_NATIVE_BINDING;
|
||||
this.database = new Database(this.databasePath, nativeBinding ? { nativeBinding } : undefined);
|
||||
this.database.pragma("journal_mode = WAL");
|
||||
this.database.pragma("foreign_keys = ON");
|
||||
this.database.pragma("busy_timeout = 5000");
|
||||
|
||||
@@ -9,7 +9,8 @@ export class WorkerStorageStatus {
|
||||
private readonly database: BetterSqlite3.Database;
|
||||
|
||||
constructor(databasePath: string) {
|
||||
this.database = new Database(databasePath);
|
||||
const nativeBinding = process.env.DADA_SQLITE_NATIVE_BINDING;
|
||||
this.database = new Database(databasePath, nativeBinding ? { nativeBinding } : undefined);
|
||||
this.database.pragma("busy_timeout = 5000");
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"required_labels": ["self-hosted", "windows", "x64", "dada-isolated"],
|
||||
"capacity": 1,
|
||||
"external_mode": "mock",
|
||||
"forbidden_inputs": [
|
||||
"windows_credential_manager",
|
||||
"LocalDataRoot",
|
||||
"canonical_asset_archive"
|
||||
],
|
||||
"artifact_scope": ["portable-package", "tdd-evidence"]
|
||||
}
|
||||
+5
-2
@@ -18,7 +18,8 @@
|
||||
"test:visual": "node scripts/validate-layer-scope.mjs VISUAL",
|
||||
"test:performance": "node scripts/validate-layer-scope.mjs PERFORMANCE",
|
||||
"test:security": "node scripts/verify-frozen-dependencies.mjs && node scripts/redaction-scan.mjs",
|
||||
"test:package": "pnpm run typecheck && pnpm --filter @dada/shared-contracts build && pnpm --filter @dada/web build && pnpm --filter @dada/api build && pnpm --filter @dada/worker build && node scripts/package-smoke.mjs && node scripts/loopback-boundary-smoke.mjs",
|
||||
"test:package": "pnpm run typecheck && node --test tests/package/wp0-09-portable.test.mjs && node scripts/package-smoke.mjs && node scripts/loopback-boundary-smoke.mjs",
|
||||
"package:portable": "node scripts/build-portable.mjs",
|
||||
"generate:openapi": "node scripts/generate-openapi.mjs",
|
||||
"check:openapi": "node scripts/check-openapi.mjs",
|
||||
"validate:tdd-trace": "node scripts/validate-tdd-trace.mjs",
|
||||
@@ -36,7 +37,9 @@
|
||||
"test:wp0-07": "node scripts/run-wp0-07-validation.mjs",
|
||||
"test:wp0-07:red": "node scripts/run-wp0-07-validation.mjs --phase red",
|
||||
"test:wp0-08": "node scripts/run-wp0-08-validation.mjs",
|
||||
"test:wp0-08:red": "node scripts/run-wp0-08-validation.mjs --phase red"
|
||||
"test:wp0-08:red": "node scripts/run-wp0-08-validation.mjs --phase red",
|
||||
"test:wp0-09": "node scripts/run-wp0-09-validation.mjs",
|
||||
"test:wp0-09:red": "node scripts/run-wp0-09-validation.mjs --phase red"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.62.0",
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { buildAndValidatePortablePackage } from "./lib/portable-package.mjs";
|
||||
|
||||
const outputIndex = process.argv.indexOf("--output");
|
||||
const outputRoot = outputIndex >= 0 ? resolve(process.argv[outputIndex + 1]) : resolve(".build", "portable-release");
|
||||
const result = await buildAndValidatePortablePackage({ outputRoot });
|
||||
console.log(JSON.stringify({
|
||||
package: result.packageManifest.package_name,
|
||||
sha256: result.packageManifest.zip_sha256,
|
||||
status: result.status,
|
||||
}, null, 2));
|
||||
@@ -0,0 +1,395 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { createRequire } from "node:module";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
rmdirSync,
|
||||
statSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
||||
|
||||
import { frozenRuntime } from "../frozen-versions.mjs";
|
||||
|
||||
const repositoryRoot = resolve(import.meta.dirname, "..", "..");
|
||||
const fixedPort = 43121;
|
||||
const textExtensions = new Set([".cjs", ".css", ".html", ".js", ".json", ".mjs", ".txt", ".xml"]);
|
||||
const developmentOnlyDependencyDirectories = new Set([".github", "benchmark", "benchmarks", "docs", "examples", "test", "tests"]);
|
||||
|
||||
function debug(message) {
|
||||
if (process.env.DADA_PACKAGE_DEBUG === "1") process.stderr.write(`[portable-package] ${message}\n`);
|
||||
}
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
const executable = process.platform === "win32" && command === "pnpm" ? (process.env.ComSpec ?? "cmd.exe") : command;
|
||||
const actualArgs = executable === command ? args : ["/d", "/s", "/c", [command, ...args].join(" ")];
|
||||
const result = spawnSync(executable, actualArgs, { cwd: repositoryRoot, encoding: "utf8", stdio: "pipe", ...options });
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`${command} ${args.join(" ")} failed:\n${result.stdout ?? ""}\n${result.stderr ?? ""}`);
|
||||
}
|
||||
return result.stdout?.trim() ?? "";
|
||||
}
|
||||
|
||||
function ensureBuildOutput(path) {
|
||||
const buildRoot = resolve(repositoryRoot, ".build");
|
||||
const resolved = resolve(path);
|
||||
if (resolved !== buildRoot && !resolved.startsWith(`${buildRoot}${sep}`)) {
|
||||
throw new Error("Portable package output must remain under the repository .build directory.");
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function json(path) {
|
||||
return JSON.parse(readFileSync(path, "utf8"));
|
||||
}
|
||||
|
||||
function writeJson(path, value) {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function copyTree(source, destination, filter = () => true) {
|
||||
if (!filter(source)) return;
|
||||
const attributes = statSync(source);
|
||||
if (attributes.isDirectory()) {
|
||||
mkdirSync(destination, { recursive: true });
|
||||
for (const entry of readdirSync(source)) {
|
||||
copyTree(join(source, entry), join(destination, entry), filter);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (attributes.isFile()) copyFileSync(source, destination);
|
||||
}
|
||||
|
||||
function removeTree(path) {
|
||||
if (!existsSync(path)) return;
|
||||
const attributes = lstatSync(path);
|
||||
if (!attributes.isDirectory() || attributes.isSymbolicLink()) {
|
||||
unlinkSync(path);
|
||||
return;
|
||||
}
|
||||
for (const entry of readdirSync(path)) removeTree(join(path, entry));
|
||||
rmdirSync(path);
|
||||
}
|
||||
|
||||
function packageRootFromEntry(entryPath, expectedName) {
|
||||
let current = dirname(entryPath);
|
||||
while (current !== dirname(current)) {
|
||||
const manifestPath = join(current, "package.json");
|
||||
if (existsSync(manifestPath) && json(manifestPath).name === expectedName) return current;
|
||||
current = dirname(current);
|
||||
}
|
||||
throw new Error(`Cannot resolve package root for ${expectedName}.`);
|
||||
}
|
||||
|
||||
function copyRuntimeDependencies(sourceRoot, destinationRoot, rootNames) {
|
||||
debug(`resolve dependencies for ${relative(repositoryRoot, sourceRoot)}`);
|
||||
const records = new Map();
|
||||
function copyResolved(name, requireFrom, destinationNodeModules, ancestors) {
|
||||
let entry;
|
||||
try {
|
||||
entry = requireFrom.resolve(name);
|
||||
} catch (error) {
|
||||
throw new Error(`Runtime dependency ${name} is unavailable from ${sourceRoot}.`, { cause: error });
|
||||
}
|
||||
const root = packageRootFromEntry(entry, name);
|
||||
const manifest = json(join(root, "package.json"));
|
||||
const identity = `${manifest.name}@${manifest.version}`;
|
||||
const destination = join(destinationNodeModules, ...name.split("/"));
|
||||
debug(`copy dependency ${identity}`);
|
||||
mkdirSync(dirname(destination), { recursive: true });
|
||||
copyTree(root, destination, (source) => {
|
||||
const name = basename(source);
|
||||
return name !== "node_modules" && !developmentOnlyDependencyDirectories.has(name);
|
||||
});
|
||||
records.set(identity, { name: manifest.name, version: manifest.version });
|
||||
if (ancestors.has(identity)) return;
|
||||
const nestedAncestors = new Set(ancestors).add(identity);
|
||||
const nestedRequire = createRequire(join(root, "package.json"));
|
||||
for (const dependency of Object.keys(manifest.dependencies ?? {})) {
|
||||
copyResolved(dependency, nestedRequire, join(destination, "node_modules"), nestedAncestors);
|
||||
}
|
||||
}
|
||||
const rootRequire = createRequire(join(sourceRoot, "package.json"));
|
||||
for (const name of rootNames) copyResolved(name, rootRequire, join(destinationRoot, "node_modules"), new Set());
|
||||
return [...records.values()]
|
||||
.sort((left, right) => left.name.localeCompare(right.name));
|
||||
}
|
||||
|
||||
function listFiles(root) {
|
||||
const files = [];
|
||||
const reparsePoints = [];
|
||||
function walk(current) {
|
||||
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
||||
const path = join(current, entry.name);
|
||||
const attributes = lstatSync(path);
|
||||
if (attributes.isSymbolicLink()) reparsePoints.push(relative(root, path).replaceAll("\\", "/"));
|
||||
else if (entry.isDirectory()) walk(path);
|
||||
else if (entry.isFile()) files.push(path);
|
||||
}
|
||||
}
|
||||
walk(root);
|
||||
return { files, reparsePoints };
|
||||
}
|
||||
|
||||
function fileSha256(path) {
|
||||
return createHash("sha256").update(readFileSync(path)).digest("hex").toUpperCase();
|
||||
}
|
||||
|
||||
function copyApplication(source, destination, runtimeDependencies) {
|
||||
mkdirSync(destination, { recursive: true });
|
||||
copyTree(join(source, "dist"), join(destination, "dist"));
|
||||
writeJson(join(destination, "package.json"), { private: true, type: "module" });
|
||||
return copyRuntimeDependencies(source, destination, runtimeDependencies);
|
||||
}
|
||||
|
||||
function buildArtifacts(stagingRoot) {
|
||||
debug("build workspace artifacts");
|
||||
run("pnpm", ["--filter", "@dada/shared-contracts", "build"]);
|
||||
run("pnpm", ["--filter", "@dada/web", "build"]);
|
||||
run("pnpm", ["--filter", "@dada/api", "build"]);
|
||||
run("pnpm", ["--filter", "@dada/worker", "build"]);
|
||||
run("dotnet", ["restore", "supervisor/Dada.Supervisor/Dada.Supervisor.csproj", "--configfile", "NuGet.Config"]);
|
||||
run("dotnet", [
|
||||
"publish",
|
||||
"supervisor/Dada.Supervisor/Dada.Supervisor.csproj",
|
||||
"--configuration", "Release",
|
||||
"--no-restore",
|
||||
"--output", stagingRoot,
|
||||
"-p:DebugType=None",
|
||||
"-p:DebugSymbols=false",
|
||||
]);
|
||||
}
|
||||
|
||||
function createZip(packageDirectory, zipPath) {
|
||||
const escapedPackage = packageDirectory.replaceAll("'", "''");
|
||||
const escapedZip = zipPath.replaceAll("'", "''");
|
||||
run("powershell.exe", [
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-Command",
|
||||
`Compress-Archive -LiteralPath '${escapedPackage}' -DestinationPath '${escapedZip}' -CompressionLevel Optimal`,
|
||||
]);
|
||||
}
|
||||
|
||||
async function waitForHealth(child) {
|
||||
const deadline = Date.now() + 15_000;
|
||||
let lastError;
|
||||
while (Date.now() < deadline) {
|
||||
if (child.exitCode !== null) throw new Error(`Packaged API exited early with code ${child.exitCode}.`);
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${fixedPort}/healthz`, { headers: { Host: `127.0.0.1:${fixedPort}` } });
|
||||
if (response.ok) return response.json();
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
await new Promise((resolveDelay) => setTimeout(resolveDelay, 100));
|
||||
}
|
||||
throw new Error("Packaged API did not become healthy on fixed port 43121.", { cause: lastError });
|
||||
}
|
||||
|
||||
async function verifyExtractedPackage(zipPath, packageName) {
|
||||
const extractRoot = mkdtempSync(join(tmpdir(), "dada-wp0-09-"));
|
||||
try {
|
||||
const escapedZip = zipPath.replaceAll("'", "''");
|
||||
const escapedExtract = extractRoot.replaceAll("'", "''");
|
||||
run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", `Expand-Archive -LiteralPath '${escapedZip}' -DestinationPath '${escapedExtract}'`]);
|
||||
const packageRoot = join(extractRoot, packageName);
|
||||
const nativeResult = spawnSync(join(packageRoot, "runtime", "node.exe"), [join(packageRoot, "server", "native-smoke.cjs")], {
|
||||
cwd: packageRoot,
|
||||
encoding: "utf8",
|
||||
});
|
||||
if (nativeResult.status !== 0) throw new Error(`Packaged native module failed:\n${nativeResult.stderr ?? ""}`);
|
||||
const native = JSON.parse(nativeResult.stdout.trim());
|
||||
const supervisorProbe = spawnSync(join(packageRoot, "Dada.exe"), ["--package-layout-probe"], {
|
||||
cwd: packageRoot,
|
||||
encoding: "utf8",
|
||||
windowsHide: true,
|
||||
});
|
||||
if (supervisorProbe.status !== 2) throw new Error(`Packaged Dada.exe usage probe exited with ${supervisorProbe.status}.`);
|
||||
const api = spawn(join(packageRoot, "runtime", "node.exe"), [join(packageRoot, "server", "api.mjs")], {
|
||||
cwd: packageRoot,
|
||||
env: { ...process.env, DADA_SUPPORT_GATE_ROOT: join(packageRoot, "web", "support-gate") },
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
});
|
||||
try {
|
||||
const health = await waitForHealth(api);
|
||||
const releaseGate = await fetch(`http://127.0.0.1:${fixedPort}/api/v1/support/check`, {
|
||||
body: JSON.stringify({
|
||||
brands: [{ brand: "Google Chrome", version: "150" }],
|
||||
full_version_list: [{ brand: "Google Chrome", version: "150.0.0.0" }],
|
||||
platform: "Windows",
|
||||
}),
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"sec-ch-ua": '"Google Chrome";v="150"',
|
||||
"sec-ch-ua-full-version-list": '"Google Chrome";v="150.0.0.0"',
|
||||
"sec-ch-ua-platform": '"Windows"',
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
if (releaseGate.status !== 426) throw new Error(`Candidate RELEASE.json unexpectedly passed with ${releaseGate.status}.`);
|
||||
return {
|
||||
api: { executable: "runtime/node.exe", health, pid: api.pid, release_gate: { status_code: releaseGate.status }, status: "passed" },
|
||||
native,
|
||||
supervisor: { credential_store_access: false, executable: "Dada.exe", exit_code: supervisorProbe.status, status: "passed" },
|
||||
};
|
||||
} finally {
|
||||
if (api.exitCode === null) {
|
||||
const exited = new Promise((resolveExit) => api.once("exit", resolveExit));
|
||||
api.kill();
|
||||
await exited;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
removeTree(extractRoot);
|
||||
}
|
||||
}
|
||||
|
||||
function scanPackage(packageDirectory) {
|
||||
const { files, reparsePoints } = listFiles(packageDirectory);
|
||||
const disallowedMatches = [];
|
||||
const repositoryPath = repositoryRoot.toLowerCase();
|
||||
const windowsUserPath = `${process.env.SystemDrive ?? "C:"}\\Users\\`.toLowerCase();
|
||||
const userName = (process.env.USERNAME ?? "").toLowerCase();
|
||||
for (const path of files) {
|
||||
const extension = path.slice(path.lastIndexOf(".")).toLowerCase();
|
||||
if (!textExtensions.has(extension)) continue;
|
||||
const content = readFileSync(path, "utf8").toLowerCase();
|
||||
const relativePath = relative(packageDirectory, path).replaceAll("\\", "/");
|
||||
if (content.includes(repositoryPath)) disallowedMatches.push({ path: relativePath, rule: "repository_absolute_path" });
|
||||
if (content.includes(windowsUserPath)) disallowedMatches.push({ path: relativePath, rule: "windows_user_path" });
|
||||
if (userName.length >= 3 && content.includes(userName)) disallowedMatches.push({ path: relativePath, rule: "windows_username" });
|
||||
if (/\b(?:sk|key)-[a-z0-9_-]{16,}\b/i.test(content)) disallowedMatches.push({ path: relativePath, rule: "credential_shape" });
|
||||
if (!relativePath.includes("/node_modules/") && !relativePath.startsWith("LICENSES/")) {
|
||||
for (const email of content.match(/[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}/gi) ?? []) {
|
||||
if (!email.endsWith(".invalid")) disallowedMatches.push({ path: relativePath, rule: "non_fixture_email" });
|
||||
}
|
||||
}
|
||||
}
|
||||
return { disallowed_matches: disallowedMatches, reparse_points: reparsePoints, scanned_files: files.length, status: disallowedMatches.length === 0 && reparsePoints.length === 0 ? "passed" : "failed" };
|
||||
}
|
||||
|
||||
export async function buildAndValidatePortablePackage({ evidenceDirectory, outputRoot }) {
|
||||
if (process.platform !== frozenRuntime.os || process.arch !== frozenRuntime.arch || process.version.slice(1) !== frozenRuntime.node) {
|
||||
throw new Error("Portable package build requires frozen Node 24.13.0 on win-x64.");
|
||||
}
|
||||
const resolvedOutput = ensureBuildOutput(outputRoot);
|
||||
removeTree(resolvedOutput);
|
||||
mkdirSync(resolvedOutput, { recursive: true });
|
||||
const packageManifest = json(join(repositoryRoot, "package.json"));
|
||||
const appVersion = packageManifest.version;
|
||||
const packageName = `Dada-P0A-${appVersion}-win-x64`;
|
||||
const packageDirectory = join(resolvedOutput, packageName);
|
||||
const supervisorPublish = join(resolvedOutput, "supervisor-publish");
|
||||
buildArtifacts(supervisorPublish);
|
||||
debug("assemble package layout");
|
||||
mkdirSync(packageDirectory, { recursive: true });
|
||||
|
||||
const supervisorExecutable = join(supervisorPublish, "Dada.Supervisor.exe");
|
||||
if (!existsSync(supervisorExecutable)) throw new Error("Supervisor publish did not produce Dada.Supervisor.exe.");
|
||||
copyFileSync(supervisorExecutable, join(packageDirectory, "Dada.exe"));
|
||||
for (const name of ["Dada.Supervisor.deps.json", "Dada.Supervisor.dll", "Dada.Supervisor.runtimeconfig.json"]) {
|
||||
copyFileSync(join(supervisorPublish, name), join(packageDirectory, name));
|
||||
}
|
||||
mkdirSync(join(packageDirectory, "runtime"), { recursive: true });
|
||||
copyFileSync(process.execPath, join(packageDirectory, "runtime", "node.exe"));
|
||||
|
||||
const serverRoot = join(packageDirectory, "server");
|
||||
debug("copy API application");
|
||||
const apiDependencies = copyApplication(join(repositoryRoot, "apps", "api"), join(serverRoot, "api"), ["@fastify/swagger", "@sinclair/typebox", "better-sqlite3", "fastify"]);
|
||||
debug("copy Worker application");
|
||||
const workerDependencies = copyApplication(join(repositoryRoot, "apps", "worker"), join(serverRoot, "worker"), ["better-sqlite3"]);
|
||||
const sharedDestination = join(serverRoot, "api", "node_modules", "@dada", "shared-contracts");
|
||||
mkdirSync(sharedDestination, { recursive: true });
|
||||
copyTree(join(repositoryRoot, "packages", "shared-contracts", "dist"), join(sharedDestination, "dist"));
|
||||
copyFileSync(join(repositoryRoot, "packages", "shared-contracts", "package.json"), join(sharedDestination, "package.json"));
|
||||
writeFileSync(join(serverRoot, "api.mjs"), 'import "./api/dist/main.js";\n');
|
||||
writeFileSync(join(serverRoot, "worker.mjs"), 'import "./worker/dist/worker.js";\n');
|
||||
|
||||
const nativeSource = join(repositoryRoot, "apps", "api", "node_modules", "better-sqlite3", "prebuilds", "win32-x64.node");
|
||||
mkdirSync(join(serverRoot, "native"), { recursive: true });
|
||||
copyFileSync(nativeSource, join(serverRoot, "native", "better_sqlite3.node"));
|
||||
writeFileSync(join(serverRoot, "native-smoke.cjs"), [
|
||||
'"use strict";',
|
||||
'const { createRequire } = require("node:module");',
|
||||
'const { join } = require("node:path");',
|
||||
'const requireApi = createRequire(join(__dirname, "api", "package.json"));',
|
||||
'const Database = requireApi("better-sqlite3");',
|
||||
'const database = new Database(":memory:", { nativeBinding: join(__dirname, "native", "better_sqlite3.node") });',
|
||||
'const row = database.prepare("select 1 as ok").get();',
|
||||
'database.close();',
|
||||
'process.stdout.write(JSON.stringify({ status: row.ok === 1 ? "passed" : "failed" }));',
|
||||
'',
|
||||
].join("\n"));
|
||||
|
||||
copyTree(join(repositoryRoot, "apps", "web", "dist"), join(packageDirectory, "web"));
|
||||
copyTree(join(repositoryRoot, "apps", "web", "support-gate"), join(packageDirectory, "web", "support-gate"));
|
||||
writeJson(join(packageDirectory, "migrations", "manifest.json"), { migrations: [], schema_version: "0" });
|
||||
writeJson(join(packageDirectory, "asset-metadata", "manifest.json"), { resources: [], schema_version: "1.0", source: "external_read_only" });
|
||||
writeJson(join(packageDirectory, "LICENSES", "third-party.json"), { api: apiDependencies, runtime: { node: frozenRuntime.node }, schema_version: "1.0", worker: workerDependencies });
|
||||
|
||||
const commit = run("git", ["rev-parse", "HEAD"]);
|
||||
writeJson(join(packageDirectory, "RELEASE.json"), {
|
||||
app_version: appVersion,
|
||||
browsers: [],
|
||||
build_commit: commit,
|
||||
release_status: "candidate_unvalidated",
|
||||
schema_version: "0",
|
||||
windows_build: null,
|
||||
});
|
||||
writeFileSync(join(packageDirectory, "START-HERE.txt"), [
|
||||
"Dada P0-A candidate package",
|
||||
"",
|
||||
"This candidate is unsigned and is not a final P0-A release.",
|
||||
"Verify the adjacent SHA-256 file before first launch.",
|
||||
"Windows SmartScreen may warn on first launch because the executable is unsigned.",
|
||||
"For an antivirus alert, compare the package hash with the Gitea build record.",
|
||||
"Do not disable antivirus protection, add broad exclusions, or skip hash verification.",
|
||||
"To update, exit Dada from the tray and replace the complete program directory.",
|
||||
"Dada uses 127.0.0.1:43121 and does not support LAN or remote access.",
|
||||
"A final RELEASE.json is created only after WP-7 acceptance.",
|
||||
"",
|
||||
].join("\r\n"));
|
||||
|
||||
removeTree(supervisorPublish);
|
||||
const packageScan = scanPackage(packageDirectory);
|
||||
if (packageScan.status !== "passed") throw new Error(`Portable package scan failed: ${JSON.stringify(packageScan.disallowed_matches)}`);
|
||||
const zipPath = join(resolvedOutput, `${packageName}.zip`);
|
||||
createZip(packageDirectory, zipPath);
|
||||
const zipSha256 = fileSha256(zipPath);
|
||||
const shaPath = `${zipPath}.sha256`;
|
||||
writeFileSync(shaPath, `${zipSha256} ${basename(zipPath)}\n`);
|
||||
const processTree = await verifyExtractedPackage(zipPath, packageName);
|
||||
const fileEntries = listFiles(packageDirectory).files.map((path) => ({
|
||||
path: relative(packageDirectory, path).replaceAll("\\", "/"),
|
||||
sha256: fileSha256(path),
|
||||
size: statSync(path).size,
|
||||
})).sort((left, right) => left.path.localeCompare(right.path));
|
||||
const finalManifest = {
|
||||
app_version: appVersion,
|
||||
files: fileEntries,
|
||||
fixed_port: fixedPort,
|
||||
package_name: packageName,
|
||||
release_status: "candidate_unvalidated",
|
||||
schema_version: "1.0",
|
||||
zip_sha256: zipSha256,
|
||||
};
|
||||
if (evidenceDirectory) {
|
||||
mkdirSync(evidenceDirectory, { recursive: true });
|
||||
writeJson(join(evidenceDirectory, "package-manifest.json"), finalManifest);
|
||||
writeFileSync(join(evidenceDirectory, "sha256.txt"), `${zipSha256} ${basename(zipPath)}\n`);
|
||||
writeJson(join(evidenceDirectory, "package-scan.json"), packageScan);
|
||||
writeJson(join(evidenceDirectory, "process-tree.json"), processTree);
|
||||
}
|
||||
return { packageManifest: finalManifest, packageScan, processTree, status: "passed" };
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const phaseIndex = process.argv.indexOf("--phase");
|
||||
const phase = phaseIndex >= 0 ? process.argv[phaseIndex + 1] : "green";
|
||||
if (!["red", "green"].includes(phase)) throw new Error(`Unsupported phase: ${phase}`);
|
||||
const runId = process.env.DADA_TDD_RUN_ID ?? `wp0-09-${phase}-${new Date().toISOString().replace(/[^0-9]/g, "")}`;
|
||||
const runDirectory = resolve("artifacts", "tdd", runId);
|
||||
const caseId = "TDD-WP0-PKG-001-portable-zip";
|
||||
const caseDirectory = resolve(runDirectory, "cases", caseId);
|
||||
if (existsSync(runDirectory)) throw new Error(`Evidence run already exists: ${runId}`);
|
||||
mkdirSync(caseDirectory, { recursive: true });
|
||||
|
||||
const commandSpecs = phase === "red"
|
||||
? [["node --test tests/package/wp0-09-portable.test.mjs", ["node", "--test", "tests/package/wp0-09-portable.test.mjs"]]]
|
||||
: [
|
||||
["pnpm test:security", ["pnpm", "test:security"]],
|
||||
["pnpm test:package", ["pnpm", "test:package"]],
|
||||
["pnpm validate:tdd-trace", ["pnpm", "validate:tdd-trace"]],
|
||||
];
|
||||
const environment = { ...process.env, DADA_EVIDENCE_DIR_PACKAGE: caseDirectory };
|
||||
const startedAt = new Date().toISOString();
|
||||
const commands = [];
|
||||
for (const [command, invocation] of commandSpecs) {
|
||||
const started_at = new Date().toISOString();
|
||||
const [program, ...args] = invocation;
|
||||
const executable = process.platform === "win32" ? (process.env.ComSpec ?? "cmd.exe") : program;
|
||||
const actualArgs = process.platform === "win32" ? ["/d", "/s", "/c", [program, ...args].join(" ")] : args;
|
||||
const execution = spawnSync(executable, actualArgs, { encoding: "utf8", env: environment });
|
||||
if (execution.stdout) process.stdout.write(execution.stdout);
|
||||
if (execution.stderr) process.stderr.write(execution.stderr);
|
||||
commands.push({ command, exit_code: execution.status ?? 1, finished_at: new Date().toISOString(), started_at });
|
||||
}
|
||||
|
||||
writeFileSync(resolve(caseDirectory, "commands.json"), `${JSON.stringify({ commands, phase, run_id: runId, schema_version: "1.0" }, null, 2)}\n`);
|
||||
const evidenceRefs = ["package-manifest.json", "sha256.txt", "package-scan.json", "process-tree.json"];
|
||||
const missingEvidence = phase === "green" ? evidenceRefs.filter((path) => !existsSync(resolve(caseDirectory, path))) : [];
|
||||
const commandState = phase === "red" ? commands.every((item) => item.exit_code !== 0) : commands.every((item) => item.exit_code === 0);
|
||||
const status = phase === "red" ? (commandState ? "red_confirmed" : "failed") : (commandState && missingEvidence.length === 0 ? "passed" : "failed");
|
||||
const result = {
|
||||
acceptance_criteria: ["AC-24", "AC-41", "AC-56"],
|
||||
automation: ["automated"],
|
||||
commit: spawnSync("git", ["rev-parse", "--short", "HEAD"], { encoding: "utf8" }).stdout.trim(),
|
||||
environment: { arch: process.arch, node: process.version.slice(1), os: process.platform },
|
||||
evidence_refs: evidenceRefs,
|
||||
finished_at: new Date().toISOString(),
|
||||
layer: ["PACKAGE_SECURITY"],
|
||||
manifest: { path: "tasks.manifest.json", sha256: createHash("sha256").update(readFileSync("tasks.manifest.json")).digest("hex").toUpperCase() },
|
||||
missing_evidence: missingEvidence,
|
||||
parent_family: "TDD-WP0-PKG-001",
|
||||
phase,
|
||||
release_gate: ["work_package:WP-0", "release:P0-A"],
|
||||
requirements: ["NFR-01", "NFR-09"],
|
||||
run_id: runId,
|
||||
schema_version: "1.0",
|
||||
started_at: startedAt,
|
||||
status,
|
||||
task_id: "TASK-WP0-09",
|
||||
test_id: caseId,
|
||||
work_package: "WP-0",
|
||||
worktree_under_test: spawnSync("git", ["status", "--porcelain"], { encoding: "utf8" }).stdout.trim() ? "uncommitted implementation" : "clean committed implementation",
|
||||
};
|
||||
writeFileSync(resolve(caseDirectory, "result.json"), `${JSON.stringify(result, null, 2)}\n`);
|
||||
const summary = { cases: [{ missing_evidence: missingEvidence, status, test_id: caseId }], phase, run_id: runId, status };
|
||||
writeFileSync(resolve(runDirectory, "evidence.json"), `${JSON.stringify(summary, null, 2)}\n`);
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
if (status !== (phase === "red" ? "red_confirmed" : "passed")) process.exit(1);
|
||||
@@ -0,0 +1,29 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const policy = JSON.parse(readFileSync("ci/runner-isolation-policy.json", "utf8"));
|
||||
const workflow = readFileSync(".gitea/workflows/p0a-ci.yml", "utf8");
|
||||
|
||||
for (const label of policy.required_labels) assert.match(workflow, new RegExp(`\\b${label}\\b`));
|
||||
assert.match(workflow, /pnpm install --frozen-lockfile/);
|
||||
assert.match(workflow, /DADA_EXTERNAL_MODE:\s*mock/);
|
||||
assert.match(workflow, /pnpm test:all/);
|
||||
assert.match(workflow, /pnpm package:portable/);
|
||||
|
||||
const forbiddenEnvironment = [
|
||||
"DADA_LOCAL_DATA_ROOT",
|
||||
"DADA_ASSET_ROOT",
|
||||
"AI_GATEWAY_API_KEY",
|
||||
"RESEND_API_KEY",
|
||||
"AMAP_API_KEY",
|
||||
];
|
||||
const present = forbiddenEnvironment.filter((name) => Object.hasOwn(process.env, name));
|
||||
assert.deepEqual(present, [], `Isolated CI received forbidden environment inputs: ${present.join(", ")}`);
|
||||
|
||||
console.log(JSON.stringify({
|
||||
external_mode: policy.external_mode,
|
||||
forbidden_environment_inputs: present,
|
||||
required_labels: policy.required_labels,
|
||||
schema_version: policy.schema_version,
|
||||
status: "passed",
|
||||
}, null, 2));
|
||||
@@ -40,8 +40,8 @@ internal sealed class SupervisorRuntime : IAsyncDisposable
|
||||
}
|
||||
|
||||
var node = Path.Combine(AppContext.BaseDirectory, "runtime", "node.exe");
|
||||
var apiEntry = Path.Combine(AppContext.BaseDirectory, "apps", "api", "dist", "main.js");
|
||||
var workerEntry = Path.Combine(AppContext.BaseDirectory, "apps", "worker", "dist", "worker.js");
|
||||
var apiEntry = Path.Combine(AppContext.BaseDirectory, "server", "api.mjs");
|
||||
var workerEntry = Path.Combine(AppContext.BaseDirectory, "server", "worker.mjs");
|
||||
if (!File.Exists(node) || !File.Exists(apiEntry) || !File.Exists(workerEntry)) return SupervisorState.StartupFailed;
|
||||
|
||||
api = CreateComponent(node, apiEntry, ChildRole.Api, SupervisorState.ApiDegraded);
|
||||
@@ -57,6 +57,9 @@ internal sealed class SupervisorRuntime : IAsyncDisposable
|
||||
var component = new ManagedComponentSupervisor(async cancellationToken =>
|
||||
{
|
||||
var startInfo = new ProcessStartInfo(node);
|
||||
startInfo.WorkingDirectory = AppContext.BaseDirectory;
|
||||
startInfo.Environment["DADA_SQLITE_NATIVE_BINDING"] = Path.Combine(AppContext.BaseDirectory, "server", "native", "better_sqlite3.node");
|
||||
startInfo.Environment["DADA_SUPPORT_GATE_ROOT"] = Path.Combine(AppContext.BaseDirectory, "web", "support-gate");
|
||||
startInfo.ArgumentList.Add(entry);
|
||||
var child = await ManagedChildProcess.StartAsync(startInfo, role, credentials, cancellationToken);
|
||||
child.StatusReceived += status =>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { buildAndValidatePortablePackage } from "../../scripts/lib/portable-package.mjs";
|
||||
|
||||
test("builds an isolated candidate portable package", async () => {
|
||||
const outputRoot = resolve(".build", "wp0-09-portable-test");
|
||||
const evidenceDirectory = process.env.DADA_EVIDENCE_DIR_PACKAGE
|
||||
? resolve(process.env.DADA_EVIDENCE_DIR_PACKAGE)
|
||||
: undefined;
|
||||
const result = await buildAndValidatePortablePackage({ evidenceDirectory, outputRoot });
|
||||
|
||||
assert.equal(result.status, "passed");
|
||||
assert.equal(result.packageManifest.fixed_port, 43121);
|
||||
assert.equal(result.packageManifest.release_status, "candidate_unvalidated");
|
||||
assert.equal(result.packageScan.disallowed_matches.length, 0);
|
||||
assert.equal(result.packageScan.reparse_points.length, 0);
|
||||
assert.equal(result.processTree.api.health.bind_scope, "loopback");
|
||||
assert.equal(result.processTree.api.health.port, 43121);
|
||||
assert.equal(result.processTree.api.release_gate.status_code, 426);
|
||||
assert.equal(result.processTree.native.status, "passed");
|
||||
assert.equal(result.processTree.supervisor.credential_store_access, false);
|
||||
assert.equal(result.processTree.supervisor.status, "passed");
|
||||
});
|
||||
Reference in New Issue
Block a user