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;
|
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 supportGateHtml = readFileSync(resolve(supportGateDirectory, "index.html"), "utf8");
|
||||||
const supportGateCss = readFileSync(resolve(supportGateDirectory, "support-gate.css"), "utf8");
|
const supportGateCss = readFileSync(resolve(supportGateDirectory, "support-gate.css"), "utf8");
|
||||||
const supportGateJavaScript = readFileSync(resolve(supportGateDirectory, "support-gate.js"), "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) {
|
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.pragma("journal_mode = WAL");
|
||||||
database.exec(`
|
database.exec(`
|
||||||
CREATE TABLE instance_metadata (
|
CREATE TABLE instance_metadata (
|
||||||
|
|||||||
@@ -146,7 +146,8 @@ export class ManagedStorage {
|
|||||||
throw new Error("database_outside_data_root");
|
throw new Error("database_outside_data_root");
|
||||||
}
|
}
|
||||||
mkdirSync(dirname(this.databasePath), { recursive: true });
|
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("journal_mode = WAL");
|
||||||
this.database.pragma("foreign_keys = ON");
|
this.database.pragma("foreign_keys = ON");
|
||||||
this.database.pragma("busy_timeout = 5000");
|
this.database.pragma("busy_timeout = 5000");
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ export class WorkerStorageStatus {
|
|||||||
private readonly database: BetterSqlite3.Database;
|
private readonly database: BetterSqlite3.Database;
|
||||||
|
|
||||||
constructor(databasePath: string) {
|
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");
|
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:visual": "node scripts/validate-layer-scope.mjs VISUAL",
|
||||||
"test:performance": "node scripts/validate-layer-scope.mjs PERFORMANCE",
|
"test:performance": "node scripts/validate-layer-scope.mjs PERFORMANCE",
|
||||||
"test:security": "node scripts/verify-frozen-dependencies.mjs && node scripts/redaction-scan.mjs",
|
"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",
|
"generate:openapi": "node scripts/generate-openapi.mjs",
|
||||||
"check:openapi": "node scripts/check-openapi.mjs",
|
"check:openapi": "node scripts/check-openapi.mjs",
|
||||||
"validate:tdd-trace": "node scripts/validate-tdd-trace.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": "node scripts/run-wp0-07-validation.mjs",
|
||||||
"test:wp0-07:red": "node scripts/run-wp0-07-validation.mjs --phase red",
|
"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": "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": {
|
"devDependencies": {
|
||||||
"@playwright/test": "1.62.0",
|
"@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 node = Path.Combine(AppContext.BaseDirectory, "runtime", "node.exe");
|
||||||
var apiEntry = Path.Combine(AppContext.BaseDirectory, "apps", "api", "dist", "main.js");
|
var apiEntry = Path.Combine(AppContext.BaseDirectory, "server", "api.mjs");
|
||||||
var workerEntry = Path.Combine(AppContext.BaseDirectory, "apps", "worker", "dist", "worker.js");
|
var workerEntry = Path.Combine(AppContext.BaseDirectory, "server", "worker.mjs");
|
||||||
if (!File.Exists(node) || !File.Exists(apiEntry) || !File.Exists(workerEntry)) return SupervisorState.StartupFailed;
|
if (!File.Exists(node) || !File.Exists(apiEntry) || !File.Exists(workerEntry)) return SupervisorState.StartupFailed;
|
||||||
|
|
||||||
api = CreateComponent(node, apiEntry, ChildRole.Api, SupervisorState.ApiDegraded);
|
api = CreateComponent(node, apiEntry, ChildRole.Api, SupervisorState.ApiDegraded);
|
||||||
@@ -57,6 +57,9 @@ internal sealed class SupervisorRuntime : IAsyncDisposable
|
|||||||
var component = new ManagedComponentSupervisor(async cancellationToken =>
|
var component = new ManagedComponentSupervisor(async cancellationToken =>
|
||||||
{
|
{
|
||||||
var startInfo = new ProcessStartInfo(node);
|
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);
|
startInfo.ArgumentList.Add(entry);
|
||||||
var child = await ManagedChildProcess.StartAsync(startInfo, role, credentials, cancellationToken);
|
var child = await ManagedChildProcess.StartAsync(startInfo, role, credentials, cancellationToken);
|
||||||
child.StatusReceived += status =>
|
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