diff --git a/infra/deploy-runner/build-tasker-ops-ui-comment-edit-artifacts.mjs b/infra/deploy-runner/build-tasker-ops-ui-comment-edit-artifacts.mjs new file mode 100644 index 0000000..21f9593 --- /dev/null +++ b/infra/deploy-runner/build-tasker-ops-ui-comment-edit-artifacts.mjs @@ -0,0 +1,143 @@ +#!/usr/bin/env node +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { cp, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const platformRoot = resolve(scriptDir, "../.."); +const taskerRoot = resolve( + process.env.NODEDC_TASKMANAGER_ROOT || resolve(platformRoot, "../../data/dc_taskmanager/NODEDC_TASKMANAGER"), +); +const opsAgentsRoot = resolve( + process.env.NODEDC_OPS_AGENTS_ROOT || resolve(platformRoot, "../../data/NODEDC_TASKMANAGER_CODEXAPI"), +); +const artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts")); +const [release = "20260806-001", ...extra] = process.argv.slice(2); + +if (extra.length || release !== "20260806-001") { + throw new Error("usage: build-tasker-ops-ui-comment-edit-artifacts.mjs [20260806-001]"); +} + +const descriptors = [ + { + artifactBasename: `nodedc-tasker-ops-ui-comment-edit-${release}.tgz`, + component: "tasker", + expectedCommit: "12b595b1ed12638fe2d4722d00dcd7b8e625a94c", + files: [ + "plane-src/apps/api/plane/authentication/views/nodedc_agent_adapter.py", + "plane-src/apps/api/plane/urls.py", + "plane-src/apps/web/core/components/dropdowns/member/member-options.tsx", + "plane-src/apps/web/core/components/dropdowns/state/base.tsx", + "plane-src/apps/web/styles/globals.css", + "plane-src/packages/ui/src/dropdown/multi-select.tsx", + "plane-src/packages/ui/src/dropdown/single-select.tsx", + ], + patchId: `tasker-ops-ui-comment-edit-${release}`, + sourceRoot: taskerRoot, + }, + { + artifactBasename: `nodedc-ops-agents-comment-edit-${release}.tgz`, + component: "ops-agents", + expectedCommit: "0f3bf9d8aacfb445d33ea02162e0707957646890", + files: [ + "src/mcp/tool-runtime.ts", + "src/routes/tools.ts", + "src/tasker/client.ts", + ], + patchId: `ops-agents-comment-edit-${release}`, + sourceRoot: opsAgentsRoot, + }, +]; + +await mkdir(artifactDir, { recursive: true }); +const artifacts = []; + +for (const descriptor of descriptors) { + const sourceCommit = gitOutput(descriptor.sourceRoot, ["rev-parse", "HEAD"]); + if (sourceCommit !== descriptor.expectedCommit) { + throw new Error(`source_commit_mismatch:${descriptor.component}:${sourceCommit}`); + } + const sourceStatus = gitOutput(descriptor.sourceRoot, ["status", "--porcelain"]); + if (sourceStatus) throw new Error(`source_worktree_not_clean:${descriptor.component}`); + + const stage = await mkdtemp(join(tmpdir(), `nodedc-${descriptor.component}-ui-comment-edit-`)); + const payload = join(stage, "payload"); + const artifact = join(artifactDir, descriptor.artifactBasename); + + try { + await assertFresh(artifact); + await mkdir(payload, { recursive: true }); + for (const relativePath of descriptor.files) { + const source = resolve(descriptor.sourceRoot, relativePath); + const sourceStat = await lstat(source); + if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) { + throw new Error(`source_file_rejected:${descriptor.component}:${relativePath}`); + } + const destination = join(payload, relativePath); + await mkdir(dirname(destination), { recursive: true }); + await cp(source, destination, { force: false, verbatimSymlinks: true }); + } + + await writeFile( + join(stage, "manifest.env"), + `id=${descriptor.patchId}\ncomponent=${descriptor.component}\ntype=app-overlay\n`, + "utf8", + ); + await writeFile(join(stage, "files.txt"), `${descriptor.files.join("\n")}\n`, "utf8"); + + const tar = spawnSync("python3", ["-c", canonicalTarScript(), artifact, stage], { + encoding: "utf8", + maxBuffer: 128 * 1024 * 1024, + }); + if (tar.status !== 0) throw new Error(`tar_failed:${descriptor.component}:${tar.stderr || tar.stdout}`); + + const sha256 = createHash("sha256").update(await readFile(artifact)).digest("hex"); + artifacts.push({ + artifact, + component: descriptor.component, + files: descriptor.files, + patchId: descriptor.patchId, + sha256, + sourceCommit, + }); + } finally { + await rm(stage, { recursive: true, force: true }); + } +} + +console.log(JSON.stringify({ ok: true, release, deployOrder: ["tasker", "ops-agents"], artifacts }, null, 2)); + +function gitOutput(cwd, args) { + const result = spawnSync("git", args, { cwd, encoding: "utf8" }); + if (result.status !== 0) throw new Error(`git_failed:${cwd}:${args.join("_")}:${result.stderr || result.stdout}`); + return result.stdout.trim(); +} + +async function assertFresh(path) { + try { + await lstat(path); + } catch (error) { + if (error?.code === "ENOENT") return; + throw error; + } + throw new Error(`output_already_exists:${path}`); +} + +function canonicalTarScript() { + return [ + "import gzip,io,pathlib,sys,tarfile", + "root=pathlib.Path(sys.argv[2])", + "with open(sys.argv[1],'wb') as out:", + " with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:", + " with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:", + " for top in ('manifest.env','files.txt','payload'):", + " p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])", + " for x in paths:", + " info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())", + " info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644", + " with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)", + ].join("\n"); +} diff --git a/infra/deploy-runner/test_tasker_ops_ui_comment_edit_artifacts.py b/infra/deploy-runner/test_tasker_ops_ui_comment_edit_artifacts.py new file mode 100644 index 0000000..1d30082 --- /dev/null +++ b/infra/deploy-runner/test_tasker_ops_ui_comment_edit_artifacts.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +import hashlib +import importlib.machinery +import importlib.util +import json +import os +import subprocess +import tempfile +import unittest +from pathlib import Path + + +SCRIPT_DIR = Path(__file__).resolve().parent +PLATFORM_ROOT = SCRIPT_DIR.parent.parent +BUILDER_PATH = SCRIPT_DIR / "build-tasker-ops-ui-comment-edit-artifacts.mjs" +RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy" +RELEASE = "20260806-001" + + +def load_runner(): + loader = importlib.machinery.SourceFileLoader("nodedc_ui_comment_edit_runner", str(RUNNER_PATH)) + spec = importlib.util.spec_from_loader(loader.name, loader) + module = importlib.util.module_from_spec(spec) + loader.exec_module(module) + return module + + +RUNNER = load_runner() + + +class TaskerOpsUiCommentEditArtifactTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.temporary = tempfile.TemporaryDirectory(prefix="nodedc-ui-comment-edit-") + cls.root = Path(cls.temporary.name) + cls.builds = [] + for index in range(2): + output = cls.root / f"build-{index}" + output.mkdir() + env = os.environ.copy() + env["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(output) + result = subprocess.run( + ["node", str(BUILDER_PATH), RELEASE], + cwd=PLATFORM_ROOT, + env=env, + check=True, + capture_output=True, + text=True, + ) + cls.builds.append(json.loads(result.stdout)) + + @classmethod + def tearDownClass(cls): + cls.temporary.cleanup() + + def artifact(self, build_index, component): + return next(item for item in self.builds[build_index]["artifacts"] if item["component"] == component) + + def test_artifacts_are_reproducible_and_runner_accepted(self): + self.assertEqual(self.builds[0]["deployOrder"], ["tasker", "ops-agents"]) + for component in ("tasker", "ops-agents"): + first = self.artifact(0, component) + second = self.artifact(1, component) + first_path = Path(first["artifact"]) + second_path = Path(second["artifact"]) + first_bytes = first_path.read_bytes() + self.assertEqual(first_bytes, second_path.read_bytes()) + self.assertEqual(first_bytes[4:8], bytes(4)) + self.assertEqual(first["sha256"], hashlib.sha256(first_bytes).hexdigest()) + + with tempfile.TemporaryDirectory() as directory: + manifest, entries, payload = RUNNER.load_artifact(first_path, Path(directory)) + self.assertEqual(manifest["component"], component) + self.assertEqual(manifest["id"], first["patchId"]) + self.assertEqual(entries, first["files"]) + for relative_path in entries: + self.assertTrue((payload / relative_path).is_file()) + + def test_tasker_overlay_contains_ui_and_owned_comment_patch(self): + tasker = self.artifact(0, "tasker") + self.assertEqual(tasker["sourceCommit"], "12b595b1ed12638fe2d4722d00dcd7b8e625a94c") + self.assertIn("plane-src/apps/web/styles/globals.css", tasker["files"]) + self.assertIn( + "plane-src/apps/api/plane/authentication/views/nodedc_agent_adapter.py", + tasker["files"], + ) + + def test_ops_agents_overlay_contains_comment_tool_runtime(self): + ops_agents = self.artifact(0, "ops-agents") + self.assertEqual(ops_agents["sourceCommit"], "0f3bf9d8aacfb445d33ea02162e0707957646890") + self.assertEqual( + set(ops_agents["files"]), + {"src/mcp/tool-runtime.ts", "src/routes/tools.ts", "src/tasker/client.ts"}, + ) + + +if __name__ == "__main__": + unittest.main(verbosity=2)