[DEPLOY-RECEIVER] v1.0 · signed allowlist webhook · read-only gatekeeper probe
This commit is contained in:
parent
82ec50ba29
commit
3a5f2ec901
7
deployment/requests/WORK-PROBE-20260713-002.json
Normal file
7
deployment/requests/WORK-PROBE-20260713-002.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"request_id": "WORK-PROBE-20260713-002",
|
||||
"module": "gatekeeper",
|
||||
"action": "inspect-gatekeeper",
|
||||
"approved": true,
|
||||
"note": "Read-only deployment-chain verification requested by ICE-GL∞."
|
||||
}
|
||||
74
server-tools/deployment-receiver/README.md
Normal file
74
server-tools/deployment-receiver/README.md
Normal file
@ -0,0 +1,74 @@
|
||||
# Guanghu Deployment Receiver v1.0
|
||||
|
||||
这是 `fifth-domain` 的服务器端 Forgejo Webhook 接收器。它把仓库提交转换为受控、可审计的服务器动作,但不接受提交中的任意 shell 命令。
|
||||
|
||||
## 第一版能力
|
||||
|
||||
- 校验 Forgejo/Gitea `X-Forgejo-Signature` / `X-Gitea-Signature` HMAC-SHA256。
|
||||
- 只接受 `bingshuo/fifth-domain` 的 `refs/heads/main` 推送。
|
||||
- 只读取 `deployment/requests/*.json`。
|
||||
- 请求只能包含 `request_id/module/action/approved/note`,出现 `cmd` 等额外字段直接拒绝。
|
||||
- 动作和模块必须在服务器本地 `config.json` 双重白名单中登记。
|
||||
- 使用参数数组和 `shell:false`,禁止 shell 拼接。
|
||||
- 单实例部署锁、执行超时、输出上限和本地 JSON 审计回执。
|
||||
- 当前只登记 `inspect-gatekeeper`:读取线上 Gatekeeper 文件哈希、认证特征与 PM2 状态,不读取任何凭证,不修改服务。
|
||||
|
||||
## 交给服务器编程 AI 的部署步骤
|
||||
|
||||
1. 在目标服务器准备只读部署用户与目录:
|
||||
|
||||
```bash
|
||||
sudo useradd --system --home /var/lib/guanghu-deployment-receiver --shell /usr/sbin/nologin guanghu-deploy
|
||||
sudo mkdir -p /opt/zhuyuan /etc/guanghu-deployment-receiver /var/lib/guanghu-deployment-receiver/receipts
|
||||
sudo chown -R guanghu-deploy:guanghu-deploy /var/lib/guanghu-deployment-receiver
|
||||
```
|
||||
|
||||
2. 将 `fifth-domain` 克隆或更新到 `/opt/zhuyuan/fifth-domain`。确保 `guanghu-deploy` 可以执行仓库的 `git fetch`,但不要给系统 root 权限。
|
||||
|
||||
3. 复制配置:
|
||||
|
||||
```bash
|
||||
sudo cp server-tools/deployment-receiver/config.example.json /etc/guanghu-deployment-receiver/config.json
|
||||
```
|
||||
|
||||
4. 生成至少 32 字节的随机 Webhook 密钥,只放服务器:
|
||||
|
||||
```bash
|
||||
umask 077
|
||||
printf 'FORGEJO_WEBHOOK_SECRET=%s\n' "$(openssl rand -hex 32)" | sudo tee /etc/guanghu-deployment-receiver/secret.env >/dev/null
|
||||
```
|
||||
|
||||
不要把密钥提交到仓库。将同一个值配置到 Forgejo 仓库 Webhook 的 Secret。
|
||||
|
||||
5. 安装并启动 systemd 服务:
|
||||
|
||||
```bash
|
||||
sudo cp server-tools/deployment-receiver/guanghu-deployment-receiver.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now guanghu-deployment-receiver
|
||||
curl -fsS http://127.0.0.1:3981/health
|
||||
```
|
||||
|
||||
6. 使用 Nginx 暴露单一 HTTPS 路径,反代到 `127.0.0.1:3981/forgejo/deploy`。不要直接开放 3981 公网端口。
|
||||
|
||||
7. 在 Forgejo 的 `bingshuo/fifth-domain` 添加 Push Webhook:
|
||||
|
||||
- URL:服务器编程 AI 配置的 HTTPS 地址,例如 `https://deploy.example.com/forgejo/deploy`
|
||||
- Secret:步骤 4 的随机值
|
||||
- Event:Push events
|
||||
- Branch filter:`main`
|
||||
|
||||
8. 部署完成后,重新提交或轻微更新:
|
||||
|
||||
`deployment/requests/WORK-PROBE-20260713-002.json`
|
||||
|
||||
接收器会在服务器本地写入:
|
||||
|
||||
`/var/lib/guanghu-deployment-receiver/receipts/WORK-PROBE-20260713-002-*.json`
|
||||
|
||||
## 重要边界
|
||||
|
||||
- v1 不自动修改或重启 Gatekeeper。
|
||||
- 新增真实部署动作时,服务器端 `config.json` 必须登记一个固定脚本路径;请求文件不能决定命令。
|
||||
- 每个部署脚本必须自行实现备份、健康检查和失败回滚,再加入白名单。
|
||||
- 不要把仓库 PAT、服务器 Token、邮箱、Webhook Secret 或环境变量写进回执。
|
||||
21
server-tools/deployment-receiver/config.example.json
Normal file
21
server-tools/deployment-receiver/config.example.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"listen_host": "127.0.0.1",
|
||||
"listen_port": 3981,
|
||||
"webhook_path": "/forgejo/deploy",
|
||||
"health_path": "/health",
|
||||
"repository_full_name": "bingshuo/fifth-domain",
|
||||
"allowed_ref": "refs/heads/main",
|
||||
"repo_path": "/opt/zhuyuan/fifth-domain",
|
||||
"manifest_prefix": "deployment/requests/",
|
||||
"audit_dir": "/var/lib/guanghu-deployment-receiver/receipts",
|
||||
"lock_file": "/var/lib/guanghu-deployment-receiver/deploy.lock",
|
||||
"max_body_bytes": 1048576,
|
||||
"max_execution_ms": 120000,
|
||||
"actions": {
|
||||
"inspect-gatekeeper": {
|
||||
"argv": ["/usr/bin/node", "/opt/zhuyuan/fifth-domain/server-tools/deployment-receiver/scripts/inspect-gatekeeper.js"],
|
||||
"allowed_modules": ["gatekeeper"],
|
||||
"mode": "read-only"
|
||||
}
|
||||
}
|
||||
}
|
||||
14
server-tools/deployment-receiver/ecosystem.config.cjs
Normal file
14
server-tools/deployment-receiver/ecosystem.config.cjs
Normal file
@ -0,0 +1,14 @@
|
||||
module.exports = {
|
||||
apps: [{
|
||||
name: "guanghu-deployment-receiver",
|
||||
script: "receiver.js",
|
||||
cwd: "/opt/zhuyuan/fifth-domain/server-tools/deployment-receiver",
|
||||
instances: 1,
|
||||
autorestart: true,
|
||||
max_memory_restart: "128M",
|
||||
env: {
|
||||
NODE_ENV: "production",
|
||||
DEPLOY_RECEIVER_CONFIG: "/etc/guanghu-deployment-receiver/config.json"
|
||||
}
|
||||
}]
|
||||
};
|
||||
@ -0,0 +1,24 @@
|
||||
[Unit]
|
||||
Description=Guanghu allowlisted Forgejo deployment receiver
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=guanghu-deploy
|
||||
Group=guanghu-deploy
|
||||
WorkingDirectory=/opt/zhuyuan/fifth-domain/server-tools/deployment-receiver
|
||||
Environment=DEPLOY_RECEIVER_CONFIG=/etc/guanghu-deployment-receiver/config.json
|
||||
EnvironmentFile=/etc/guanghu-deployment-receiver/secret.env
|
||||
ExecStart=/usr/bin/node receiver.js
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/opt/zhuyuan/fifth-domain /var/lib/guanghu-deployment-receiver
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
13
server-tools/deployment-receiver/package.json
Normal file
13
server-tools/deployment-receiver/package.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "guanghu-deployment-receiver",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Allowlisted Forgejo webhook receiver for Fifth Domain deployments",
|
||||
"scripts": {
|
||||
"start": "node receiver.js",
|
||||
"test": "node --test test/*.test.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
210
server-tools/deployment-receiver/receiver.js
Normal file
210
server-tools/deployment-receiver/receiver.js
Normal file
@ -0,0 +1,210 @@
|
||||
"use strict";
|
||||
|
||||
const crypto = require("node:crypto");
|
||||
const fs = require("node:fs");
|
||||
const http = require("node:http");
|
||||
const path = require("node:path");
|
||||
const { spawn } = require("node:child_process");
|
||||
|
||||
function loadConfig() {
|
||||
const configPath = process.env.DEPLOY_RECEIVER_CONFIG;
|
||||
if (!configPath) throw new Error("DEPLOY_RECEIVER_CONFIG is required");
|
||||
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
||||
for (const key of ["repo_path", "repository_full_name", "allowed_ref", "audit_dir", "lock_file", "actions"]) {
|
||||
if (!config[key]) throw new Error(`missing config field: ${key}`);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
function safeEqualHex(expected, supplied) {
|
||||
if (!/^[a-f0-9]{64}$/i.test(supplied || "")) return false;
|
||||
const a = Buffer.from(expected, "hex");
|
||||
const b = Buffer.from(supplied, "hex");
|
||||
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
function verifySignature(secret, rawBody, header) {
|
||||
const supplied = String(header || "").replace(/^sha256=/i, "");
|
||||
const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
|
||||
return safeEqualHex(expected, supplied);
|
||||
}
|
||||
|
||||
function collectChangedFiles(payload) {
|
||||
const files = new Set();
|
||||
for (const commit of payload.commits || []) {
|
||||
for (const group of ["added", "modified"]) {
|
||||
for (const file of commit[group] || []) files.add(file);
|
||||
}
|
||||
}
|
||||
return [...files];
|
||||
}
|
||||
|
||||
function validManifestPath(file, prefix) {
|
||||
return file.startsWith(prefix) && /^[A-Za-z0-9._/-]+\.json$/.test(file) && !file.includes("..") && !path.isAbsolute(file);
|
||||
}
|
||||
|
||||
function validRequest(request) {
|
||||
if (!request || typeof request !== "object" || Array.isArray(request)) return false;
|
||||
return /^[A-Za-z0-9._-]{8,80}$/.test(request.request_id || "") &&
|
||||
/^[a-z0-9-]{2,60}$/.test(request.module || "") &&
|
||||
/^[a-z0-9-]{2,60}$/.test(request.action || "") &&
|
||||
request.approved === true &&
|
||||
Object.keys(request).every((key) => ["request_id", "module", "action", "approved", "note"].includes(key));
|
||||
}
|
||||
|
||||
function run(argv, options = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(argv[0], argv.slice(1), {
|
||||
cwd: options.cwd,
|
||||
env: options.env || { PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" },
|
||||
shell: false,
|
||||
stdio: ["ignore", "pipe", "pipe"]
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
const limit = options.outputLimit || 64 * 1024;
|
||||
child.stdout.on("data", (chunk) => { if (stdout.length < limit) stdout += chunk; });
|
||||
child.stderr.on("data", (chunk) => { if (stderr.length < limit) stderr += chunk; });
|
||||
const timer = setTimeout(() => child.kill("SIGKILL"), options.timeout || 120000);
|
||||
child.on("error", (error) => {
|
||||
clearTimeout(timer);
|
||||
resolve({ code: -1, stdout, stderr: `${stderr}${error.message}`, timed_out: false });
|
||||
});
|
||||
child.on("close", (code, signal) => {
|
||||
clearTimeout(timer);
|
||||
resolve({ code: code ?? -1, stdout, stderr, timed_out: signal === "SIGKILL" });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function readManifest(config, sha, file) {
|
||||
if (!/^[a-f0-9]{40,64}$/i.test(sha)) throw new Error("invalid commit sha");
|
||||
const result = await run(["/usr/bin/git", "-C", config.repo_path, "show", `${sha}:${file}`], { timeout: 15000 });
|
||||
if (result.code !== 0) throw new Error(`cannot read manifest: ${result.stderr.slice(0, 300)}`);
|
||||
return JSON.parse(result.stdout);
|
||||
}
|
||||
|
||||
function writeReceipt(config, receipt) {
|
||||
fs.mkdirSync(config.audit_dir, { recursive: true, mode: 0o750 });
|
||||
const filename = `${receipt.request_id || "event"}-${Date.now()}.json`;
|
||||
const target = path.join(config.audit_dir, filename);
|
||||
fs.writeFileSync(target, `${JSON.stringify(receipt, null, 2)}\n`, { mode: 0o640, flag: "wx" });
|
||||
return target;
|
||||
}
|
||||
|
||||
function acquireLock(lockFile) {
|
||||
fs.mkdirSync(path.dirname(lockFile), { recursive: true, mode: 0o750 });
|
||||
const fd = fs.openSync(lockFile, "wx", 0o640);
|
||||
fs.writeFileSync(fd, `${process.pid}\n`);
|
||||
return () => {
|
||||
fs.closeSync(fd);
|
||||
try { fs.unlinkSync(lockFile); } catch (_) {}
|
||||
};
|
||||
}
|
||||
|
||||
async function handlePayload(config, payload) {
|
||||
if (payload.ref !== config.allowed_ref) return { accepted: false, reason: "ref_not_allowed" };
|
||||
if (payload.repository?.full_name !== config.repository_full_name) return { accepted: false, reason: "repository_not_allowed" };
|
||||
const sha = payload.after;
|
||||
const manifests = collectChangedFiles(payload).filter((file) => validManifestPath(file, config.manifest_prefix));
|
||||
if (manifests.length === 0) return { accepted: true, executed: 0, reason: "no_deployment_manifest" };
|
||||
if (manifests.length > 5) return { accepted: false, reason: "too_many_manifests" };
|
||||
|
||||
let release;
|
||||
try { release = acquireLock(config.lock_file); }
|
||||
catch (_) { return { accepted: false, reason: "deployment_locked" }; }
|
||||
|
||||
const receipts = [];
|
||||
try {
|
||||
const fetchResult = await run(["/usr/bin/git", "-C", config.repo_path, "fetch", "--quiet", "origin", config.allowed_ref.replace("refs/heads/", "")], { timeout: 30000 });
|
||||
if (fetchResult.code !== 0) throw new Error(`git fetch failed: ${fetchResult.stderr.slice(0, 300)}`);
|
||||
|
||||
for (const file of manifests) {
|
||||
let request;
|
||||
try { request = await readManifest(config, sha, file); }
|
||||
catch (error) {
|
||||
receipts.push({ request_id: "invalid", status: "rejected", reason: error.message, file, checked_at: new Date().toISOString() });
|
||||
continue;
|
||||
}
|
||||
if (!validRequest(request)) {
|
||||
receipts.push({ request_id: request?.request_id || "invalid", status: "rejected", reason: "invalid_request_schema", file, checked_at: new Date().toISOString() });
|
||||
continue;
|
||||
}
|
||||
const spec = config.actions[request.action];
|
||||
if (!spec || !Array.isArray(spec.argv) || !spec.allowed_modules?.includes(request.module)) {
|
||||
receipts.push({ request_id: request.request_id, status: "rejected", reason: "action_or_module_not_allowed", file, checked_at: new Date().toISOString() });
|
||||
continue;
|
||||
}
|
||||
const started = new Date().toISOString();
|
||||
const result = await run(spec.argv, { cwd: config.repo_path, timeout: config.max_execution_ms });
|
||||
const receipt = {
|
||||
request_id: request.request_id,
|
||||
module: request.module,
|
||||
action: request.action,
|
||||
mode: spec.mode || "controlled",
|
||||
status: result.code === 0 ? "succeeded" : "failed",
|
||||
exit_code: result.code,
|
||||
timed_out: result.timed_out,
|
||||
stdout: result.stdout.slice(0, 16000),
|
||||
stderr: result.stderr.slice(0, 16000),
|
||||
commit: sha,
|
||||
started_at: started,
|
||||
completed_at: new Date().toISOString()
|
||||
};
|
||||
receipt.local_receipt = writeReceipt(config, receipt);
|
||||
receipts.push(receipt);
|
||||
}
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
return { accepted: true, executed: receipts.length, receipts };
|
||||
}
|
||||
|
||||
function createServer(config, secret) {
|
||||
return http.createServer((req, res) => {
|
||||
if (req.method === "GET" && req.url === (config.health_path || "/health")) {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true, service: "guanghu-deployment-receiver", version: "1.0.0" }));
|
||||
return;
|
||||
}
|
||||
if (req.method !== "POST" || req.url !== (config.webhook_path || "/forgejo/deploy")) {
|
||||
res.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
req.on("data", (chunk) => {
|
||||
size += chunk.length;
|
||||
if (size > (config.max_body_bytes || 1048576)) req.destroy();
|
||||
else chunks.push(chunk);
|
||||
});
|
||||
req.on("end", async () => {
|
||||
const raw = Buffer.concat(chunks);
|
||||
if (!verifySignature(secret, raw, req.headers["x-forgejo-signature"] || req.headers["x-gitea-signature"])) {
|
||||
res.writeHead(401, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: false, error: "invalid_signature" }));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await handlePayload(config, JSON.parse(raw.toString("utf8")));
|
||||
res.writeHead(result.accepted ? 202 : 409, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: result.accepted, ...result }));
|
||||
} catch (error) {
|
||||
writeReceipt(config, { request_id: "event", status: "failed", reason: error.message, checked_at: new Date().toISOString() });
|
||||
res.writeHead(500, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: false, error: "receiver_error" }));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
const config = loadConfig();
|
||||
const secret = process.env.FORGEJO_WEBHOOK_SECRET;
|
||||
if (!secret || secret.length < 32) throw new Error("FORGEJO_WEBHOOK_SECRET must be at least 32 characters");
|
||||
createServer(config, secret).listen(config.listen_port || 3981, config.listen_host || "127.0.0.1", () => {
|
||||
console.log(`guanghu-deployment-receiver listening on ${config.listen_host || "127.0.0.1"}:${config.listen_port || 3981}`);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { collectChangedFiles, createServer, handlePayload, validManifestPath, validRequest, verifySignature };
|
||||
@ -0,0 +1,49 @@
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const crypto = require("node:crypto");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
const candidates = [
|
||||
"/opt/zhuyuan/gatekeeper/engine-v3.js",
|
||||
"/opt/engine.js"
|
||||
];
|
||||
|
||||
function digest(file) {
|
||||
return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
||||
}
|
||||
|
||||
function pm2Info() {
|
||||
try {
|
||||
const list = JSON.parse(execFileSync("/usr/bin/env", ["pm2", "jlist"], { encoding: "utf8", timeout: 10000 }));
|
||||
return list.filter((item) => /engine|gatekeeper/i.test(item.name || "")).map((item) => ({
|
||||
name: item.name,
|
||||
status: item.pm2_env?.status,
|
||||
script: item.pm2_env?.pm_exec_path,
|
||||
restart_time: item.pm2_env?.restart_time,
|
||||
started_at: item.pm2_env?.pm_uptime ? new Date(item.pm2_env.pm_uptime).toISOString() : null
|
||||
}));
|
||||
} catch (error) {
|
||||
return [{ error: error.message.slice(0, 300) }];
|
||||
}
|
||||
}
|
||||
|
||||
const files = candidates.filter((file) => fs.existsSync(file)).map((file) => {
|
||||
const source = fs.readFileSync(file, "utf8");
|
||||
return {
|
||||
path: file,
|
||||
sha256: digest(file),
|
||||
contains_direct_email_mode: source.includes("requestEmail") || source.includes("email_mode: requestEmail"),
|
||||
contains_hmac_headers: source.includes("X-Sovereign") && source.includes("X-Minute"),
|
||||
contains_auth_request: source.includes("/auth/request"),
|
||||
contains_exec_route: source.includes("/exec")
|
||||
};
|
||||
});
|
||||
|
||||
process.stdout.write(`${JSON.stringify({
|
||||
ok: true,
|
||||
checked_at: new Date().toISOString(),
|
||||
hostname: require("node:os").hostname(),
|
||||
files,
|
||||
pm2: pm2Info()
|
||||
}, null, 2)}\n`);
|
||||
29
server-tools/deployment-receiver/test/receiver.test.js
Normal file
29
server-tools/deployment-receiver/test/receiver.test.js
Normal file
@ -0,0 +1,29 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const crypto = require("node:crypto");
|
||||
const test = require("node:test");
|
||||
const { collectChangedFiles, validManifestPath, validRequest, verifySignature } = require("../receiver");
|
||||
|
||||
test("verifies Forgejo HMAC signatures", () => {
|
||||
const secret = "a".repeat(32);
|
||||
const body = Buffer.from('{"ok":true}');
|
||||
const signature = crypto.createHmac("sha256", secret).update(body).digest("hex");
|
||||
assert.equal(verifySignature(secret, body, signature), true);
|
||||
assert.equal(verifySignature(secret, body, "0".repeat(64)), false);
|
||||
});
|
||||
|
||||
test("accepts only safe manifest paths", () => {
|
||||
assert.equal(validManifestPath("deployment/requests/REQ-001.json", "deployment/requests/"), true);
|
||||
assert.equal(validManifestPath("deployment/requests/../../secret.json", "deployment/requests/"), false);
|
||||
assert.equal(validManifestPath("deployment/requests/REQ 001.json", "deployment/requests/"), false);
|
||||
});
|
||||
|
||||
test("rejects arbitrary command fields", () => {
|
||||
assert.equal(validRequest({ request_id: "REQ-20260713-001", module: "gatekeeper", action: "inspect-gatekeeper", approved: true }), true);
|
||||
assert.equal(validRequest({ request_id: "REQ-20260713-001", module: "gatekeeper", action: "inspect-gatekeeper", approved: true, cmd: "rm -rf /" }), false);
|
||||
});
|
||||
|
||||
test("collects only added and modified files", () => {
|
||||
assert.deepEqual(collectChangedFiles({ commits: [{ added: ["a"], modified: ["b"], removed: ["c"] }] }).sort(), ["a", "b"]);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user