mirror of
https://github.com/multica-ai/multica.git
synced 2026-09-28 13:23:48 +08:00
The backend service environment in docker-compose.selfhost.yml is an explicit allowlist, and MULTICA_DINGTALK_SECRET_KEY was never on it: Docker self-hosters who set the key in .env still got a disabled DingTalk integration. Telegram had the same gap until #8611. - Map MULTICA_DINGTALK_SECRET_KEY into the backend service. - Document MULTICA_SLACK_SECRET_KEY and MULTICA_TELEGRAM_SECRET_KEY in .env.example next to the other channel keys. - Extend selfhost-config.test.sh: every key the server loads through secretbox.LoadKey must be mapped in docker-compose.selfhost.yml and listed in .env.example. The list comes from the server, not the docs, because Telegram's key was missing from both. - Run script-checks when server/cmd/server/router.go changes, where the integration gates live, so a new channel key trips the guard in the PR that adds it. Co-authored-by: multica-agent <github@multica.ai>
212 lines
11 KiB
JavaScript
212 lines
11 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join, matchesGlob } from "node:path";
|
|
import { spawnSync } from "node:child_process";
|
|
import { fileURLToPath } from "node:url";
|
|
import test from "node:test";
|
|
import { checkGate, decideScopes, filters } from "./ci-scope.mjs";
|
|
|
|
// The filters use only literal paths, * and **; Node's matcher exercises those
|
|
// against realistic changes, including files removed or renamed by a PR.
|
|
function filterFiles(files) {
|
|
return Object.fromEntries(Object.entries(filters).map(([scope, patterns]) => [
|
|
scope, String(files.some((file) => patterns.some((pattern) => matchesGlob(file, pattern)))),
|
|
]));
|
|
}
|
|
|
|
for (const [name, files, selected] of [
|
|
["readme only", ["README.md"], []],
|
|
["docs only", ["apps/docs/content/docs/guide.mdx"], ["quality"]],
|
|
["web changelog", ["apps/web/features/landing/i18n/en.ts"], ["frontend", "quality"]],
|
|
["UI Lab", ["apps/ui-lab/src/app.tsx"], ["frontend", "quality"]],
|
|
["mobile UI", ["apps/mobile/app/index.tsx"], ["quality"]],
|
|
["migration only", ["server/migrations/999_example.up.sql"], ["backend", "sqlc"]],
|
|
["agent process code", ["server/pkg/agent/cursor_background.go"], ["backend", "runtime"]],
|
|
["daemon dependency", ["server/internal/skill/service.go"], ["backend", "runtime"]],
|
|
["native test compilation dependency", ["server/pkg/db/generated/issues.sql.go"], ["backend", "sqlc", "runtime"]],
|
|
["Go dependencies", ["server/go.mod", "server/go.sum"], ["backend", "runtime"]],
|
|
["Helm only", ["deploy/helm/multica/templates/deployment.yaml"], ["scripts"]],
|
|
["container entrypoint", ["docker/entrypoint.sh"], ["scripts"]],
|
|
["selfhost config", [".env.example"], ["scripts", "installer"]],
|
|
["shell installer", ["scripts/install.sh"], ["scripts", "installer"]],
|
|
["PowerShell installer", ["scripts/install.ps1.test.ps1"], ["installer"]],
|
|
["cleanup script", ["scripts/drop-database.sh"], ["scripts"]],
|
|
["performance harness", ["scripts/perf-compare.test.sh"], ["scripts"]],
|
|
["reserved slug source", ["server/internal/handler/reserved_slugs.json"], ["backend", "runtime", "scripts"]],
|
|
["reserved slug output", ["packages/core/paths/reserved-slugs.ts"], ["frontend", "quality", "scripts"]],
|
|
["integration key gates", ["server/cmd/server/router.go"], ["backend", "runtime", "scripts"]],
|
|
["cross-module runtime contract", ["packages/core/runtimes/cli-version.ts"], ["frontend", "backend", "runtime", "quality"]],
|
|
["lockfile", ["pnpm-lock.yaml"], ["frontend", "quality"]],
|
|
["package patch", ["patches/example.patch"], ["frontend", "quality"]],
|
|
["radius policy", ["scripts/check-ui-radius-tokens.mjs"], ["quality"]],
|
|
["shared quality action", [".github/actions/frontend-quality/action.yml"], ["frontend", "quality"]],
|
|
["new bitmap", ["apps/web/public/hero.png"], ["frontend", "quality", "images"]],
|
|
["mixed docs and migration", ["apps/docs/content/guide.mdx", "server/migrations/999_example.up.sql"], ["quality", "backend", "sqlc"]],
|
|
["CI configuration", [".github/ci-paths.json"], Object.keys(filters)],
|
|
]) {
|
|
test(`PR and main select only affected scopes: ${name}`, () => {
|
|
for (const event of ["pull_request", "push"]) {
|
|
const outputs = decideScopes(event, filterFiles(files));
|
|
assert.equal(outputs.full, "false");
|
|
assert.deepEqual(Object.keys(filters).filter((scope) => outputs[scope] === "true").sort(),
|
|
[...selected].sort());
|
|
assert.equal(outputs.quality_only, String(selected.includes("quality") && !selected.includes("frontend")));
|
|
}
|
|
});
|
|
}
|
|
|
|
test("scheduled and manual runs select every scope without a path-filter result", () => {
|
|
for (const event of ["schedule", "workflow_dispatch"]) {
|
|
const { quality_only, ...scopes } = decideScopes(event, {});
|
|
assert.ok(Object.values(scopes).every((value) => value === "true"));
|
|
assert.equal(quality_only, "false", "the full frontend build owns quality checks");
|
|
}
|
|
});
|
|
|
|
test("missing, malformed and unsupported filter results fail closed", () => {
|
|
for (const value of [undefined, "", "unknown", true]) {
|
|
assert.throws(() => decideScopes("push", { ...filterFiles([]), backend: value }), /backend/);
|
|
}
|
|
assert.throws(() => decideScopes("unknown", filterFiles([])), /Unsupported/);
|
|
});
|
|
|
|
// Read the production wiring without installing workspace dependencies in the
|
|
// lightweight changes job. These fields deliberately use single-line syntax;
|
|
// unsupported formatting fails the assertions instead of being silently ignored.
|
|
const workflow = readFileSync(new URL("../.github/workflows/ci.yml", import.meta.url), "utf8");
|
|
const jobSource = workflow.slice(workflow.indexOf("\njobs:\n") + "\njobs:\n".length);
|
|
const headings = [...jobSource.matchAll(/^ ([\w-]+):$/gm)];
|
|
const jobs = Object.fromEntries(headings.map((match, index) => [
|
|
match[1], jobSource.slice(match.index, headings[index + 1]?.index),
|
|
]));
|
|
function field(source, pattern) {
|
|
const match = source.match(pattern);
|
|
assert.ok(match, `Missing production field: ${pattern}`);
|
|
return match[1];
|
|
}
|
|
function productionMapping(gate) {
|
|
return JSON.parse(field(jobs[gate], /^ JOB_SCOPES: '(.+)'$/m));
|
|
}
|
|
function productionNeeds(gate, outputs) {
|
|
const dependencies = field(jobs[gate], /^ needs: \[(.+)\]$/m).split(", ");
|
|
return Object.fromEntries(dependencies.map((job) => {
|
|
if (job === "changes") return [job, { result: "success", outputs }];
|
|
const scope = field(jobs[job], /^ if: \$\{\{ needs\.changes\.outputs\.(\w+) == 'true' \}\}$/m);
|
|
return [job, { result: outputs[scope] === "true" ? "success" : "skipped" }];
|
|
}));
|
|
}
|
|
|
|
for (const gate of ["frontend", "backend"]) {
|
|
test(`production ${gate} gate matches every dependency, condition and scope output`, () => {
|
|
const mapping = productionMapping(gate);
|
|
assert.match(jobs[gate], /^ if: \$\{\{ !cancelled\(\) \}\}$/m);
|
|
assert.match(jobs[gate], /^ NEEDS_JSON: \$\{\{ toJSON\(needs\) \}\}$/m);
|
|
assert.match(jobs[gate], /^ run: node scripts\/ci-scope\.mjs gate$/m);
|
|
for (const scope of Object.values(mapping)) {
|
|
assert.ok(jobs.changes.includes(` ${scope}: \${{ steps.decide.outputs.${scope} }}`));
|
|
}
|
|
const scopes = Object.keys(filters);
|
|
// Exercise mixed scopes too: installers and quality may run without the
|
|
// corresponding product backend/frontend suite being selected.
|
|
for (let mask = 0; mask < 2 ** scopes.length; mask++) {
|
|
const filtered = Object.fromEntries(scopes.map((scope, bit) => [scope, String(Boolean(mask & (1 << bit)))]));
|
|
const outputs = decideScopes("pull_request", filtered);
|
|
checkGate(productionNeeds(gate, outputs), mapping);
|
|
}
|
|
for (const event of ["schedule", "workflow_dispatch"]) {
|
|
checkGate(productionNeeds(gate, decideScopes(event, {})), mapping);
|
|
}
|
|
});
|
|
|
|
test(`production ${gate} gate rejects unsuccessful or missing selected jobs`, () => {
|
|
const mapping = productionMapping(gate);
|
|
for (const job of Object.keys(mapping)) {
|
|
// A docs-only run selects the standalone quality runner; a full run
|
|
// selects every other dependency, including the installer matrix.
|
|
const outputs = job === "frontend-quality"
|
|
? decideScopes("pull_request", filterFiles(["apps/docs/content/guide.mdx"]))
|
|
: decideScopes("workflow_dispatch", {});
|
|
for (const result of ["failure", "cancelled", "skipped", undefined]) {
|
|
const input = productionNeeds(gate, outputs);
|
|
assert.equal(input[job].result, "success");
|
|
input[job].result = result;
|
|
assert.throws(() => checkGate(input, mapping), new RegExp(job));
|
|
}
|
|
const missing = productionNeeds(gate, outputs);
|
|
delete missing[job];
|
|
assert.throws(() => checkGate(missing, mapping), new RegExp(job));
|
|
}
|
|
});
|
|
|
|
test(`production ${gate} gate rejects invalid filtering and unchecked dependencies`, () => {
|
|
const mapping = productionMapping(gate);
|
|
const unselected = () => productionNeeds(gate, decideScopes("pull_request", filterFiles([])));
|
|
for (const result of ["failure", "cancelled", "skipped", undefined]) {
|
|
const input = unselected();
|
|
input.changes.result = result;
|
|
assert.throws(() => checkGate(input, mapping), /Path filtering/);
|
|
}
|
|
for (const [job, scope] of Object.entries(mapping)) {
|
|
const failed = unselected();
|
|
failed[job].result = "failure";
|
|
assert.throws(() => checkGate(failed, mapping), new RegExp(job));
|
|
for (const value of [undefined, "", "unknown"]) {
|
|
const invalid = unselected();
|
|
invalid.changes.outputs[scope] = value;
|
|
assert.throws(() => checkGate(invalid, mapping), /scope/);
|
|
}
|
|
}
|
|
const extra = unselected();
|
|
extra.extra = { result: "failure" };
|
|
assert.throws(() => checkGate(extra, mapping), /Unchecked dependency/);
|
|
});
|
|
}
|
|
|
|
test("the backend gate owns the three-platform installer matrix", () => {
|
|
assert.equal(productionMapping("backend").installer, "installer");
|
|
assert.match(jobs.installer, /^ os: \[ubuntu-latest, macos-latest, windows-latest\]$/m);
|
|
assert.doesNotMatch(jobs.installer, /continue-on-error:/);
|
|
});
|
|
|
|
test("quality checks have exactly one runner and reuse the product build install", () => {
|
|
const invocation = "uses: ./.github/actions/frontend-quality";
|
|
const owners = Object.entries(jobs).filter(([, source]) => source.includes(invocation)).map(([job]) => job);
|
|
assert.deepEqual(owners.sort(), ["frontend-build", "frontend-quality"]);
|
|
assert.equal(productionMapping("frontend")["frontend-quality"], "quality_only");
|
|
assert.match(jobs["frontend-build"], / - name: Check frontend quality\n uses: \.\/\.github\/actions\/frontend-quality\n/);
|
|
for (const frontend of ["true", "false"]) {
|
|
for (const quality of ["true", "false"]) {
|
|
const outputs = decideScopes("pull_request", { ...filterFiles([]), frontend, quality });
|
|
const results = productionNeeds("frontend", outputs);
|
|
const runners = owners.filter((job) => results[job].result === "success");
|
|
assert.equal(runners.length, frontend === "true" || quality === "true" ? 1 : 0);
|
|
}
|
|
}
|
|
const action = readFileSync(new URL("../.github/actions/frontend-quality/action.yml", import.meta.url), "utf8");
|
|
assert.match(action, /run: pnpm knip/);
|
|
assert.match(action, /continue-on-error: true/);
|
|
assert.doesNotMatch(action, /pnpm install/);
|
|
});
|
|
|
|
test("the CLI writes real Actions outputs and exits nonzero on a failed gate", (t) => {
|
|
const dir = mkdtempSync(join(tmpdir(), "ci-scope-"));
|
|
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
|
const output = join(dir, "output");
|
|
const script = fileURLToPath(new URL("./ci-scope.mjs", import.meta.url));
|
|
const run = spawnSync(process.execPath, [script, "decide"], {
|
|
env: { ...process.env, EVENT_NAME: "push", FILTER_RESULTS: JSON.stringify(filterFiles(["README.md"])), GITHUB_OUTPUT: output },
|
|
encoding: "utf8",
|
|
});
|
|
assert.equal(run.status, 0, run.stderr);
|
|
assert.match(readFileSync(output, "utf8"), /^backend=false$/m);
|
|
const failed = productionNeeds("backend", decideScopes("workflow_dispatch", {}));
|
|
failed["backend-tests"].result = "failure";
|
|
const gate = spawnSync(process.execPath, [script, "gate"], {
|
|
env: { ...process.env, NEEDS_JSON: JSON.stringify(failed), JOB_SCOPES: JSON.stringify(productionMapping("backend")) },
|
|
encoding: "utf8",
|
|
});
|
|
assert.equal(gate.status, 1);
|
|
assert.match(gate.stderr, /backend-tests/);
|
|
});
|