From 6e2baf16fc083978474945a2ec0cff8b7760af3e Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 29 Aug 2026 16:09:52 +0300 Subject: [PATCH] feat(deploy): add Tasker attachment formats artifact --- ...ild-tasker-attachment-formats-artifact.mjs | 126 ++++++++++++++++++ ...test_tasker_attachment_formats_artifact.py | 109 +++++++++++++++ 2 files changed, 235 insertions(+) create mode 100644 infra/deploy-runner/build-tasker-attachment-formats-artifact.mjs create mode 100644 infra/deploy-runner/test_tasker_attachment_formats_artifact.py diff --git a/infra/deploy-runner/build-tasker-attachment-formats-artifact.mjs b/infra/deploy-runner/build-tasker-attachment-formats-artifact.mjs new file mode 100644 index 0000000..b502b21 --- /dev/null +++ b/infra/deploy-runner/build-tasker-attachment-formats-artifact.mjs @@ -0,0 +1,126 @@ +#!/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 artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts")); +const [release = "20260829-001", ...extra] = process.argv.slice(2); + +if (extra.length || release !== "20260829-001") { + throw new Error("usage: build-tasker-attachment-formats-artifact.mjs [20260829-001]"); +} + +const descriptor = { + artifactBasename: `nodedc-tasker-attachment-formats-${release}.tgz`, + component: "tasker", + expectedCommit: "f9308539bfe71e4e2359c47ed5c230fa1fb386c9", + files: [ + "plane-src/apps/api/plane/settings/common.py", + "plane-src/apps/web/core/components/issues/attachment/attachment-list-item.tsx", + "plane-src/apps/web/core/components/issues/peek-overview/view.tsx", + "plane-src/apps/web/styles/globals.css", + "plane-src/packages/services/src/file/helper.ts", + ], + patchId: `tasker-attachment-formats-${release}`, + sourceRoot: taskerRoot, +}; + +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}`); + +await mkdir(artifactDir, { recursive: true }); +const stage = await mkdtemp(join(tmpdir(), "nodedc-tasker-attachment-formats-")); +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"); + console.log( + JSON.stringify( + { + ok: true, + release, + artifact, + component: descriptor.component, + files: descriptor.files, + patchId: descriptor.patchId, + sha256, + sourceCommit, + }, + null, + 2, + ), + ); +} finally { + await rm(stage, { recursive: true, force: true }); +} + +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_attachment_formats_artifact.py b/infra/deploy-runner/test_tasker_attachment_formats_artifact.py new file mode 100644 index 0000000..9607567 --- /dev/null +++ b/infra/deploy-runner/test_tasker_attachment_formats_artifact.py @@ -0,0 +1,109 @@ +#!/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-attachment-formats-artifact.mjs" +RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy" +RELEASE = "20260829-001" +SOURCE_COMMIT = "f9308539bfe71e4e2359c47ed5c230fa1fb386c9" +EXPECTED_FILES = { + "plane-src/apps/api/plane/settings/common.py", + "plane-src/apps/web/core/components/issues/attachment/attachment-list-item.tsx", + "plane-src/apps/web/core/components/issues/peek-overview/view.tsx", + "plane-src/apps/web/styles/globals.css", + "plane-src/packages/services/src/file/helper.ts", +} + + +def load_runner(): + loader = importlib.machinery.SourceFileLoader("nodedc_tasker_attachment_formats_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 TaskerAttachmentFormatsArtifactTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.temporary = tempfile.TemporaryDirectory(prefix="nodedc-tasker-attachment-formats-") + 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 test_artifact_is_reproducible_and_runner_accepted(self): + first = self.builds[0] + second = self.builds[1] + 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()) + self.assertEqual(first["sourceCommit"], SOURCE_COMMIT) + self.assertEqual(first["patchId"], f"tasker-attachment-formats-{RELEASE}") + self.assertEqual(set(first["files"]), EXPECTED_FILES) + + with tempfile.TemporaryDirectory() as directory: + manifest, entries, payload = RUNNER.load_artifact(first_path, Path(directory)) + self.assertEqual(manifest, { + "id": first["patchId"], + "component": "tasker", + "type": "app-overlay", + }) + self.assertEqual(entries, first["files"]) + for relative_path in entries: + self.assertTrue((payload / relative_path).is_file()) + + def test_runner_selects_only_registered_tasker_scope(self): + entries = self.builds[0]["files"] + self.assertEqual(RUNNER.component_services("tasker", entries), ("api", "worker", "beat-worker", "web")) + self.assertEqual(len(RUNNER.component_builds("tasker", entries)), 2) + + def test_builder_rejects_an_unexpected_release(self): + env = os.environ.copy() + env["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(self.root / "unexpected-release") + result = subprocess.run( + ["node", str(BUILDER_PATH), "20260829-002"], + cwd=PLATFORM_ROOT, + env=env, + capture_output=True, + text=True, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("usage: build-tasker-attachment-formats-artifact.mjs", result.stderr) + + +if __name__ == "__main__": + unittest.main(verbosity=2)