feat(deploy): trigger JD agent after human merge

This commit is contained in:
冰朔 2026-08-06 13:35:10 +08:00
commit 564859b1ce
9 changed files with 517 additions and 8 deletions

View file

@ -1,19 +1,21 @@
# HLCC-PUSH-TO-DEPLOY-EVENT-PROTOCOL
状态:本地实现候选,尚未部署;当前现实层不得宣称可派发
状态:人工合并自动部署桥实现候选服务器常驻部署Agent已在线合并门待本提交部署与实测
语言层在冰朔明确确认“该提交需要部署”后,主动发出 `guanghu.deployment-intent/v1`。推送本身不会产生部署事件。意图必须绑定:
普通推送本身不会产生部署事件。AI把代码和部署清单推入开发分支后只有冰朔在光湖代码频道人工合并到 `main`,合并门才把该次合并实际改动的部署清单转换为 `guanghu.deployment-event/v1`。事件必须绑定:
- 代码频道仓库、分支和完整提交 SHA
- `REQUEST-ID@完整提交SHA` 的不可变资源;
- `deployment/requests/` 下的部署清单。
语言人格体通过 `/api/deployment/dispatch` 派发这一意图;接口要求一张单独的 `server-ops / dispatch-approved-deployment` 工单,且资源必须为同一 `REQUEST-ID@完整提交SHA`。服务端才写入 `guanghu.deployment-event/v1` 到本机队列。常驻部署 Agent 订阅此队列,读取同一提交中的清单,执行备份、部署、健康检查与回滚,并将回执回写到原工单
Forgejo以HMAC-SHA256向 `/authz/api/deployment/forgejo-merge` 发送 `pull_request` 事件。合并门只接受 `action=closed`、`merged=true`、`merged_by=bingshuo`、目标仓库为 `bingshuo/guanghu-ice-heart` 且目标分支为 `main` 的事件随后通过Forgejo只读API取得该PR改动文件。只有 `deployment/requests/<REQUEST-ID>.json` 被新增或修改时才写入队列。常驻部署 Agent 读取同一提交中的清单,执行备份、部署、健康检查与回滚。
没有部署意图的提交照常入库,回执为 `not_requested`;无效意图回执为 `rejected`,不会部署。推送工单绝不扩大为部署权限;服务器也不扫描仓库主动拉取部署。
人工合并本身就是本次部署批准,不再重复创建 `dispatch-approved-deployment` 工单。原 `/api/deployment/dispatch` 保留为没有代码频道合并界面时的兼容/灾备入口,不再作为正常发布流程。
没有部署清单的合并照常入库但不部署普通push、关闭未合并、非登记合并者、错误签名、仓库/分支不匹配和无效事件均不会部署。服务器不扫描仓库猜测部署同一Forgejo delivery与清单通过持久去重标记最多入队一次。
`resource` 中的 `REQUEST-ID` 必须与清单文件名完全一致:`deployment/requests/<REQUEST-ID>.json`。批准 A 而执行 B 必须在派发端和常驻 Agent 端分别拒绝。
首次新单元使用 `guanghu.architecture-provision-request/v1`;已经在线的 systemd 服务必须使用 `guanghu.existing-service-update-request/v1`,先完整备份声明目标,再重启和逐项验收,失败恢复全部旧文件与旧单元。二者不得互相冒充。
当前京东节点若尚未安装 `/api/deployment/dispatch`、事件队列和常驻 worker则先通过已有的主人邮件批准固定管理动作引导这套运行时。没有这个固定引导动作时应回执 `BOOTSTRAP_ACTION_NOT_DEPLOYED`,不得绕回人类 SSH 或云控制台,也不得发送一张服务器根本不会执行的部署工单
首次安装合并门时由冰朔已授权且持有本机专用密钥的当前实例按不可变候选提交、完整备份、健康检查和服务器私有回执完成一次性引导不得为此绕回工单。引导完成后正常发布只走“AI开发分支 → 合并请求 → 冰朔人工合并 → 自动部署与回执”

View file

@ -0,0 +1,92 @@
{
"schema": "guanghu.existing-service-update-request/v1",
"request_id": "HLCC-HUMAN-MERGE-AUTO-DEPLOY-20260806",
"target_node": "JD-FD-PRIMARY",
"status": "SERVICE_UPDATE_PACKAGE_READY · DEPLOYMENT_PENDING",
"purpose": {
"what": "把光湖代码频道中由冰朔人工完成的main合并转换为不可变部署事件交给京东常驻部署Agent执行。",
"why": "服务器已经具备清单校验、备份、部署、健康检查、回滚和回执能力,但代码频道的合并事件尚未接到部署队列,导致每次合并后仍需再次派发。",
"success": "仅Forgejo已合并PR且merged_by=bingshuo、目标仓库和分支匹配、改动包含deployment/requests下的有效清单时入队普通push、关闭未合并、错误签名、其他合并者和重复投递均不产生第二次部署。",
"out_of_scope": [
"不因普通Git push自动部署",
"不执行PR或仓库提供的命令",
"不允许清单选择目标服务器或扩大权限",
"不修改光湖OS磁盘、内核、引导或启动状态"
]
},
"module": {
"unit": "lake-lamp-authz.service",
"run_user": "guanghu-authz",
"install_root": "/opt/guanghu/lake-lamp-authz"
},
"service_update": {
"kind": "existing-systemd-service",
"require_existing_unit": true,
"required_existing_files": [
"server.js",
"deployment-event.js"
]
},
"unit_source": "server-tools/lake-lamp-authz/lake-lamp-authz.service",
"files": [
{
"source": "server-tools/lake-lamp-authz/server.js",
"destination": "server.js",
"mode": "0644"
},
{
"source": "server-tools/lake-lamp-authz/deployment-event.js",
"destination": "deployment-event.js",
"mode": "0644"
},
{
"source": "server-tools/lake-lamp-authz/forgejo-merge-deployment.js",
"destination": "forgejo-merge-deployment.js",
"mode": "0644"
}
],
"runtime_check": {
"url": "http://127.0.0.1:3921/health",
"expected": {
"ok": true,
"service": "lake-lamp-authz",
"merge_deployment_gate": {
"configured": true,
"mode": "human-merged-pull-request-only"
}
}
},
"acceptance_checks": [
{
"url": "http://127.0.0.1:3921/api/public/capabilities",
"expected": {
"schema": "guanghu.lake-lamp-public-workorder/v1"
}
}
],
"bootstrap": {
"one_time": true,
"reason": "本清单合并前合并门尚不存在;首次由当前已授权实例按完整候选提交和同一回滚执行器安装,之后由人工合并自动触发。",
"webhook_target": "https://guanghulab.com/authz/api/deployment/forgejo-merge",
"webhook_event": "pull_request",
"secret_location": "/etc/guanghu/secrets/lake-lamp/authorization.env",
"secret_committed": false
},
"rollback": {
"automatic_on_failure": true,
"scope": "恢复server.js、deployment-event.js、原systemd单元并移除新增门模块然后重启旧服务",
"receipt_required": true
},
"security": {
"hmac_sha256_required": true,
"allowed_repository": "bingshuo/guanghu-ice-heart",
"allowed_branch": "main",
"allowed_mergers": [
"bingshuo"
],
"max_deployment_manifests_per_merge": 5,
"delivery_idempotency": true,
"repository_commands_executed": false,
"no_secrets": true
}
}

View file

@ -34,3 +34,6 @@ ARCHITECTURE_PROVISION_REPO_DIR=/var/lib/guanghu/architecture-provision/repo
ARCHITECTURE_PROVISION_RELEASES_DIR=/opt/guanghu/architecture-releases
ARCHITECTURE_PROVISION_UNIT_DIR=/etc/systemd/system
ARCHITECTURE_PROVISION_RECEIPTS_DIR=/var/lib/guanghu/architecture-provision/receipts
LAKE_LAMP_FORGEJO_MERGE_SECRET=SET_RANDOM_32_BYTE_VALUE_IN_PRIVATE_SERVER_FILE
LAKE_LAMP_FORGEJO_MERGE_MERGERS=bingshuo
LAKE_LAMP_FORGEJO_API_BASE=http://127.0.0.1:3340/api/v1

View file

@ -11,9 +11,30 @@ function enqueueDeploymentEvent(intent, push, queueDir, context = {}) {
const invalid = validateIntent(intent, push, context);
if (invalid) return { state: "rejected", diagnostic_code: invalid };
fs.mkdirSync(queueDir, { recursive: true, mode: 0o750 });
const requestedEventId = String(context.event_id || "");
if (requestedEventId && !/^[0-9a-f]{8}-[0-9a-f]{4}-[45][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(requestedEventId)) {
return { state: "rejected", diagnostic_code: "deployment_event_id_invalid" };
}
const eventId = requestedEventId || crypto.randomUUID();
const dedupeKey = String(context.dedupe_key || "");
let marker = "";
if (dedupeKey) {
const dedupeDir = path.join(queueDir, ".dedupe");
fs.mkdirSync(dedupeDir, { recursive: true, mode: 0o750 });
marker = path.join(dedupeDir, `${crypto.createHash("sha256").update(dedupeKey).digest("hex")}.json`);
try {
fs.writeFileSync(marker, JSON.stringify({ event_id: eventId, state: "reserved" }), { mode: 0o640, flag: "wx" });
} catch (error) {
if (error.code === "EEXIST") {
const prior = JSON.parse(fs.readFileSync(marker, "utf8"));
return { state: "duplicate", event_id: String(prior.event_id || eventId) };
}
throw error;
}
}
const event = {
schema: "guanghu.deployment-event/v1",
event_id: crypto.randomUUID(),
event_id: eventId,
created_at: Date.now() / 1000,
state: "queued_for_resident_agent",
repo: push.repo, branch: push.branch, commit_sha: push.commit_sha,
@ -28,8 +49,21 @@ function enqueueDeploymentEvent(intent, push, queueDir, context = {}) {
};
const target = path.join(queueDir, `${event.created_at}-${event.event_id}.json`);
const temporary = `${target}.${process.pid}.tmp`;
fs.writeFileSync(temporary, JSON.stringify(event), { mode: 0o640 });
fs.renameSync(temporary, target);
try {
fs.writeFileSync(temporary, JSON.stringify(event), { mode: 0o640, flag: "wx" });
fs.renameSync(temporary, target);
if (marker) {
const markerTemporary = `${marker}.${process.pid}.tmp`;
fs.writeFileSync(markerTemporary, JSON.stringify({ event_id: eventId, state: "queued", queued_at: event.created_at }), { mode: 0o640, flag: "wx" });
fs.renameSync(markerTemporary, marker);
}
} catch (error) {
try { fs.unlinkSync(temporary); } catch {}
if (marker) {
try { fs.unlinkSync(marker); } catch {}
}
throw error;
}
return { state: event.state, event_id: event.event_id };
}
function validateIntent(intent, push, context = {}) {

View file

@ -0,0 +1,174 @@
"use strict";
const crypto = require("node:crypto");
const { enqueueDeploymentEvent } = require("./deployment-event");
const DEFAULT_REPOSITORY = "bingshuo/guanghu-ice-heart";
const DEFAULT_BRANCH = "main";
const DEFAULT_TARGET = "JD-FD-PRIMARY";
const MANIFEST_PATTERN = /^deployment\/requests\/([A-Z0-9][A-Z0-9._-]{5,119})\.json$/;
function safeEqualHex(expected, supplied) {
if (!/^[a-f0-9]{64}$/i.test(String(supplied || ""))) return false;
const left = Buffer.from(expected, "hex");
const right = Buffer.from(String(supplied), "hex");
return left.length === right.length && crypto.timingSafeEqual(left, right);
}
function verifyForgejoSignature(secret, rawBody, supplied) {
if (String(secret || "").length < 32) return false;
const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
return safeEqualHex(expected, String(supplied || "").replace(/^sha256=/i, ""));
}
function deterministicEventId(value) {
const bytes = crypto.createHash("sha256").update(String(value)).digest().subarray(0, 16);
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = bytes.toString("hex");
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
function parseMergedPullRequest(payload, eventName, policy = {}) {
if (eventName !== "pull_request") return { state: "ignored", diagnostic_code: "not_pull_request_event" };
if (!payload || payload.action !== "closed" || payload.pull_request?.merged !== true) {
return { state: "ignored", diagnostic_code: "pull_request_not_merged" };
}
const pull = payload.pull_request;
const repository = String(payload.repository?.full_name || pull.base?.repo?.full_name || "").toLowerCase();
const branch = String(pull.base?.ref || "");
const commit = String(pull.merge_commit_sha || "").toLowerCase();
const merger = String(pull.merged_by?.login || pull.merged_by?.username || "");
const number = Number(pull.number || pull.index || payload.number);
const expectedRepository = String(policy.repository || DEFAULT_REPOSITORY).toLowerCase();
const expectedBranch = String(policy.branch || DEFAULT_BRANCH);
const allowedMergers = policy.allowedMergers || [];
if (repository !== expectedRepository || branch !== expectedBranch) {
return { state: "rejected", diagnostic_code: "merge_target_not_allowed" };
}
if (!/^[0-9a-f]{40}$/.test(commit) || !Number.isSafeInteger(number) || number < 1) {
return { state: "rejected", diagnostic_code: "merge_identity_invalid" };
}
if (!merger || !allowedMergers.includes(merger)) {
return { state: "rejected", diagnostic_code: "human_merger_not_allowed" };
}
return { state: "accepted", repository, branch, commit, merger, number };
}
async function listPullRequestFiles(merge, options = {}) {
const fetchImpl = options.fetchImpl || fetch;
const base = String(options.forgejoApiBase || "http://127.0.0.1:3340/api/v1").replace(/\/$/, "");
const files = [];
for (let page = 1; page <= 5; page += 1) {
const url = `${base}/repos/${merge.repository}/pulls/${merge.number}/files?limit=100&page=${page}`;
const response = await fetchImpl(url, {
headers: { accept: "application/json" },
signal: AbortSignal.timeout(5000),
});
if (!response.ok) throw new Error(`forgejo_pull_files_unavailable:${response.status}`);
const batch = await response.json();
if (!Array.isArray(batch)) throw new Error("forgejo_pull_files_invalid");
files.push(...batch);
if (batch.length < 100) return files;
}
throw new Error("forgejo_pull_files_limit_exceeded");
}
function deploymentManifests(files) {
const selected = [];
for (const file of files || []) {
const name = String(file?.filename || "");
const match = name.match(MANIFEST_PATTERN);
if (!match || file.status === "deleted") continue;
selected.push({ path: name, requestId: match[1] });
}
const unique = [...new Map(selected.map(item => [item.path, item])).values()];
if (unique.length > 5) throw new Error("too_many_deployment_manifests");
return unique;
}
async function processForgejoMergeWebhook(input, options = {}) {
const rawBody = Buffer.isBuffer(input.rawBody) ? input.rawBody : Buffer.from(String(input.rawBody || ""));
if (!verifyForgejoSignature(options.secret, rawBody, input.signature)) {
return { ok: false, status: 401, state: "rejected", diagnostic_code: "forgejo_signature_invalid" };
}
let payload;
try { payload = JSON.parse(rawBody.toString("utf8")); }
catch { return { ok: false, status: 400, state: "rejected", diagnostic_code: "forgejo_payload_invalid" }; }
const merge = parseMergedPullRequest(payload, String(input.eventName || ""), {
repository: options.repository,
branch: options.branch,
allowedMergers: options.allowedMergers || [],
});
if (merge.state === "ignored") return { ok: true, status: 202, ...merge, queued: 0 };
if (merge.state !== "accepted") return { ok: false, status: 409, ...merge };
let manifests;
try {
const files = await (options.listFiles || (value => listPullRequestFiles(value, options)))(merge);
manifests = deploymentManifests(files);
} catch (error) {
return { ok: false, status: 502, state: "rejected", diagnostic_code: String(error.message || "forgejo_pull_files_failed") };
}
if (manifests.length === 0) {
return { ok: true, status: 202, state: "accepted_no_deployment_manifest", queued: 0, commit_sha: merge.commit };
}
const delivery = String(input.delivery || "");
if (!/^[A-Za-z0-9._:-]{8,160}$/.test(delivery)) {
return { ok: false, status: 400, state: "rejected", diagnostic_code: "forgejo_delivery_invalid" };
}
const deploymentRepositories = options.deploymentRepositories || {};
const queue = options.enqueue || enqueueDeploymentEvent;
const events = [];
for (const manifest of manifests) {
const dedupeKey = `${delivery}:${merge.repository}:${merge.commit}:${manifest.requestId}`;
const eventId = deterministicEventId(dedupeKey);
const queued = queue({
schema: "guanghu.deployment-intent/v1",
repo: merge.repository,
branch: merge.branch,
commit_sha: merge.commit,
resource: `${manifest.requestId}@${merge.commit}`,
manifest: manifest.path,
workorder_id: `forgejo-pr:${merge.number}:${delivery}`,
}, {
repo: merge.repository,
branch: merge.branch,
commit_sha: merge.commit,
}, options.queueDir, {
authorizer_id: "ICE-GL∞",
persona_id: "SYS-GLW-ZY-EXEC-0001",
execution_runtime_id: "HLCC-MERGE-DEPLOY-GATE-001",
target: options.target || DEFAULT_TARGET,
registry: deploymentRepositories,
event_id: eventId,
dedupe_key: dedupeKey,
});
if (!["queued_for_resident_agent", "duplicate"].includes(queued.state)) {
return { ok: false, status: 409, state: "rejected", diagnostic_code: queued.diagnostic_code || "deployment_event_rejected" };
}
events.push({ request_id: manifest.requestId, event_id: queued.event_id, state: queued.state });
}
return {
ok: true,
status: 202,
state: events.every(item => item.state === "duplicate") ? "duplicate" : "queued_for_resident_agent",
repository: merge.repository,
branch: merge.branch,
commit_sha: merge.commit,
pull_request: merge.number,
merger: merge.merger,
queued: events.filter(item => item.state === "queued_for_resident_agent").length,
events,
};
}
module.exports = {
deploymentManifests,
deterministicEventId,
listPullRequestFiles,
parseMergedPullRequest,
processForgejoMergeWebhook,
verifyForgejoSignature,
};

View file

@ -0,0 +1,105 @@
"use strict";
const assert = require("node:assert/strict");
const crypto = require("node:crypto");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const test = require("node:test");
const { processForgejoMergeWebhook } = require("./forgejo-merge-deployment");
const secret = "s".repeat(64);
const commit = "a".repeat(40);
function signed(payload, overrides = {}) {
const rawBody = Buffer.from(JSON.stringify(payload));
return {
rawBody,
signature: crypto.createHmac("sha256", secret).update(rawBody).digest("hex"),
eventName: "pull_request",
delivery: "delivery-12345678",
...overrides,
};
}
function mergedPayload(overrides = {}) {
return {
action: "closed",
repository: { full_name: "bingshuo/guanghu-ice-heart" },
pull_request: {
number: 42,
merged: true,
merge_commit_sha: commit,
merged_by: { login: "bingshuo" },
base: { ref: "main" },
...overrides,
},
};
}
test("human merged pull request queues only changed deployment manifests", async () => {
const calls = [];
const result = await processForgejoMergeWebhook(signed(mergedPayload()), {
secret,
allowedMergers: ["bingshuo"],
deploymentRepositories: { "bingshuo/guanghu-ice-heart": {} },
queueDir: "/not-used",
listFiles: async () => [
{ filename: "server-tools/lake-lamp-authz/server.js", status: "modified" },
{ filename: "deployment/requests/HLCC-MERGE-DEPLOY-20260806.json", status: "added" },
],
enqueue: (...args) => {
calls.push(args);
return { state: "queued_for_resident_agent", event_id: args[3].event_id };
},
});
assert.equal(result.state, "queued_for_resident_agent");
assert.equal(result.queued, 1);
assert.equal(calls[0][0].resource, `HLCC-MERGE-DEPLOY-20260806@${commit}`);
assert.equal(calls[0][3].authorizer_id, "ICE-GL∞");
assert.equal(calls[0][3].target, "JD-FD-PRIMARY");
});
test("ordinary push or closed unmerged request never queues", async () => {
const push = await processForgejoMergeWebhook(signed(mergedPayload(), { eventName: "push" }), {
secret,
allowedMergers: ["bingshuo"],
});
assert.equal(push.state, "ignored");
const closed = await processForgejoMergeWebhook(signed(mergedPayload({ merged: false })), {
secret,
allowedMergers: ["bingshuo"],
});
assert.equal(closed.state, "ignored");
});
test("unregistered merger and invalid signature fail closed", async () => {
const merger = await processForgejoMergeWebhook(signed(mergedPayload({ merged_by: { login: "robot" } })), {
secret,
allowedMergers: ["bingshuo"],
});
assert.equal(merger.diagnostic_code, "human_merger_not_allowed");
const signature = await processForgejoMergeWebhook(signed(mergedPayload(), { signature: "0".repeat(64) }), {
secret,
allowedMergers: ["bingshuo"],
});
assert.equal(signature.status, 401);
});
test("delivery retries are idempotent in the real queue", async () => {
const queueDir = fs.mkdtempSync(path.join(os.tmpdir(), "merge-deploy-"));
const options = {
secret,
allowedMergers: ["bingshuo"],
deploymentRepositories: { "bingshuo/guanghu-ice-heart": {} },
queueDir,
listFiles: async () => [
{ filename: "deployment/requests/HLCC-MERGE-DEPLOY-20260806.json", status: "added" },
],
};
const first = await processForgejoMergeWebhook(signed(mergedPayload()), options);
const second = await processForgejoMergeWebhook(signed(mergedPayload()), options);
assert.equal(first.queued, 1);
assert.equal(second.state, "duplicate");
assert.equal(fs.readdirSync(queueDir).filter(name => name.endsWith(".json")).length, 1);
});

View file

@ -27,6 +27,7 @@ required_source_files=(
deployment-event.js
deployment-event-worker.js
deployment-source-policy.js
forgejo-merge-deployment.js
guanghu-router.js
repo-push-broker.js
hololake-session.js
@ -135,6 +136,11 @@ append_setting HOLOLAKE_OTP_REQUEST_LIMIT 6
append_setting HOLOLAKE_KNOWLEDGE_REPOSITORY_PATH "$knowledge_repo"
append_setting HOLOLAKE_KNOWLEDGE_MAX_ARCHIVE_BYTES 134217728
append_setting HOLOLAKE_AI_PROVIDERS_FILE "$provider_registry"
if ! grep -q '^LAKE_LAMP_FORGEJO_MERGE_SECRET=' "$authorization_env"; then
append_setting LAKE_LAMP_FORGEJO_MERGE_SECRET "$(/usr/bin/openssl rand -hex 32)"
fi
append_setting LAKE_LAMP_FORGEJO_MERGE_MERGERS bingshuo
append_setting LAKE_LAMP_FORGEJO_API_BASE http://127.0.0.1:3340/api/v1
chmod 0600 "$authorization_env"
if [[ ! -f "$provider_registry" ]]; then

View file

@ -9,6 +9,7 @@ const { MapGate } = require("./map-gate");
const { sendSmtpMail } = require("./smtp-mailer");
const { executeRegisteredAction } = require("./action-client");
const { enqueueDeploymentEvent } = require("./deployment-event");
const { processForgejoMergeWebhook } = require("./forgejo-merge-deployment");
const { GuanghuRouter, loadDevices } = require("./guanghu-router");
const { HoloLakeSessionManager } = require("./hololake-session");
const { GhdrAuthorizer } = require("./ghdr-authorizer");
@ -177,6 +178,8 @@ function createApp(options = {}) {
);
const deploymentQueueDir = options.deploymentQueueDir || process.env.LAKE_LAMP_DEPLOYMENT_EVENT_DIR || "/var/lib/guanghu/deployment-events";
const deploymentRegistryFile = options.deploymentRegistryFile || process.env.LAKE_LAMP_DEPLOYMENT_REPOSITORIES || "/etc/guanghu/lake-lamp/deployment-repositories.json";
const forgejoMergeSecret = String(options.forgejoMergeSecret || process.env.LAKE_LAMP_FORGEJO_MERGE_SECRET || "");
const forgejoMergeMergers = options.forgejoMergeMergers || splitCsv(process.env.LAKE_LAMP_FORGEJO_MERGE_MERGERS || "bingshuo");
const executeAction = options.executeAction || executeRegisteredAction;
let ghdrAuthorizer = null;
const getGhdrAuthorizer = options.getGhdrAuthorizer || (() => {
@ -296,6 +299,10 @@ function createApp(options = {}) {
knowledge_snapshot: Boolean(hololakeKnowledgeProvider),
ai_gateway: Boolean(hololakeAiGateway),
},
merge_deployment_gate: {
configured: forgejoMergeSecret.length >= 32,
mode: "human-merged-pull-request-only",
},
});
if (req.method === "GET" && url.pathname === "/api/public/capabilities") return json(res, 200, {
schema: "guanghu.lake-lamp-public-workorder/v1",
@ -1087,6 +1094,26 @@ function createApp(options = {}) {
}
}
if (req.method === "POST" && url.pathname === "/api/deployment/forgejo-merge") {
if (forgejoMergeSecret.length < 32) return json(res, 503, failure("forgejo_merge_gate_unconfigured"));
const rawBody = await readRawBody(req, 1024 * 1024);
const result = await processForgejoMergeWebhook({
rawBody,
signature: req.headers["x-forgejo-signature"] || req.headers["x-gitea-signature"],
eventName: req.headers["x-forgejo-event"] || req.headers["x-gitea-event"],
delivery: req.headers["x-forgejo-delivery"] || req.headers["x-gitea-delivery"],
}, {
secret: forgejoMergeSecret,
allowedMergers: forgejoMergeMergers,
queueDir: deploymentQueueDir,
deploymentRepositories: options.deploymentRepositories || loadDeploymentRepositories(deploymentRegistryFile),
forgejoApiBase: options.forgejoApiBase || process.env.LAKE_LAMP_FORGEJO_API_BASE || "http://127.0.0.1:3340/api/v1",
listFiles: options.listForgejoPullFiles,
enqueue: options.enqueueMergeDeploymentEvent,
});
return json(res, result.status || (result.ok ? 202 : 400), result);
}
if (req.method === "POST" && url.pathname === "/api/deployment/dispatch") {
const body = await readJson(req);
if (!body) return json(res, 400, failure("invalid_json"));
@ -1315,6 +1342,26 @@ function readJson(req, maxBytes = 32 * 1024) {
});
}
function readRawBody(req, maxBytes) {
return new Promise((resolve, reject) => {
const chunks = [];
let received = 0;
req.on("data", chunk => {
received += chunk.length;
if (received > maxBytes) {
const error = new Error("body too large");
error.code = "BODY_TOO_LARGE";
reject(error);
req.destroy();
return;
}
chunks.push(chunk);
});
req.on("end", () => resolve(Buffer.concat(chunks)));
req.on("error", reject);
});
}
function readBinaryBody(req, destination, maxBytes, options = {}) {
return new Promise((resolve, reject) => {
let received = 0;

View file

@ -877,6 +877,52 @@ test("deployment is dispatched only by an explicit approved second signal", asyn
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
});
test("Forgejo human merge webhook queues deployment without a second workorder", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "lake-lamp-merge-deploy-"));
const secret = "m".repeat(64);
const commit = "b".repeat(40);
const payload = {
action: "closed",
repository: { full_name: "bingshuo/guanghu-ice-heart" },
pull_request: {
number: 7,
merged: true,
merge_commit_sha: commit,
merged_by: { login: "bingshuo" },
base: { ref: "main" },
},
};
const body = JSON.stringify(payload);
const signature = crypto.createHmac("sha256", secret).update(body).digest("hex");
try {
await withServer(async ({ base }) => {
const response = await fetch(`${base}/api/deployment/forgejo-merge`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-forgejo-event": "pull_request",
"x-forgejo-delivery": "delivery-server-test-001",
"x-forgejo-signature": signature,
},
body,
});
assert.equal(response.status, 202);
const result = await response.json();
assert.equal(result.state, "queued_for_resident_agent");
assert.equal(result.queued, 1);
assert.equal(fs.readdirSync(path.join(dir, "queue")).filter(name => name.endsWith(".json")).length, 1);
}, {
forgejoMergeSecret: secret,
forgejoMergeMergers: ["bingshuo"],
deploymentQueueDir: path.join(dir, "queue"),
deploymentRepositories: { "bingshuo/guanghu-ice-heart": {} },
listForgejoPullFiles: async () => [
{ filename: "deployment/requests/HLCC-HUMAN-MERGE-AUTO-DEPLOY-20260806.json", status: "added" },
],
});
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
});
test("request endpoint rejects direct email target switching and unknown actions", async () => {
await withServer(async ({ base }) => {
const common = { method: "POST", headers: { authorization: "Bearer request-only-secret", "content-type": "application/json" } };