From 6352a26020b5d2484a0409a84982f7389e811d4c Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 8 Aug 2026 11:48:47 +0300 Subject: [PATCH] =?UTF-8?q?CHORE=20-=20NAS=20DEPLOY:=20=D0=B0=D1=80=D1=82?= =?UTF-8?q?=D0=B5=D1=84=D0=B0=D0=BA=D1=82=20Tasker=20UI=20recovery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../build-tasker-ui-recovery-artifact.mjs | 128 ++++++++++++++++++ .../test_tasker_ui_recovery_artifact.py | 103 ++++++++++++++ 2 files changed, 231 insertions(+) create mode 100644 infra/deploy-runner/build-tasker-ui-recovery-artifact.mjs create mode 100644 infra/deploy-runner/test_tasker_ui_recovery_artifact.py diff --git a/infra/deploy-runner/build-tasker-ui-recovery-artifact.mjs b/infra/deploy-runner/build-tasker-ui-recovery-artifact.mjs new file mode 100644 index 0000000..531c210 --- /dev/null +++ b/infra/deploy-runner/build-tasker-ui-recovery-artifact.mjs @@ -0,0 +1,128 @@ +#!/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 = "20260808-001", ...extra] = process.argv.slice(2); + +if (extra.length || release !== "20260808-001") { + throw new Error("usage: build-tasker-ui-recovery-artifact.mjs [20260808-001]"); +} + +const descriptor = { + artifactBasename: `nodedc-tasker-ui-recovery-${release}.tgz`, + component: "tasker", + expectedCommit: "ca21a8f5bc48ce6f20b79cd99eb79aed79edc627", + files: [ + "plane-src/apps/web/ce/components/instance/maintenance-message.tsx", + "plane-src/apps/web/core/components/dropdowns/cycle/cycle-options.tsx", + "plane-src/apps/web/core/components/dropdowns/estimate.tsx", + "plane-src/apps/web/core/components/dropdowns/module/module-options.tsx", + "plane-src/apps/web/core/components/instance/maintenance-view.tsx", + "plane-src/apps/web/core/components/issues/issue-layouts/properties/label-dropdown.tsx", + "plane-src/apps/web/core/lib/wrappers/instance-wrapper.tsx", + ], + patchId: `tasker-ui-recovery-${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-ui-recovery-")); +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_ui_recovery_artifact.py b/infra/deploy-runner/test_tasker_ui_recovery_artifact.py new file mode 100644 index 0000000..f8cd50f --- /dev/null +++ b/infra/deploy-runner/test_tasker_ui_recovery_artifact.py @@ -0,0 +1,103 @@ +#!/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-ui-recovery-artifact.mjs" +RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy" +RELEASE = "20260808-001" +SOURCE_COMMIT = "ca21a8f5bc48ce6f20b79cd99eb79aed79edc627" +EXPECTED_FILES = { + "plane-src/apps/web/ce/components/instance/maintenance-message.tsx", + "plane-src/apps/web/core/components/dropdowns/cycle/cycle-options.tsx", + "plane-src/apps/web/core/components/dropdowns/estimate.tsx", + "plane-src/apps/web/core/components/dropdowns/module/module-options.tsx", + "plane-src/apps/web/core/components/instance/maintenance-view.tsx", + "plane-src/apps/web/core/components/issues/issue-layouts/properties/label-dropdown.tsx", + "plane-src/apps/web/core/lib/wrappers/instance-wrapper.tsx", +} + + +def load_runner(): + loader = importlib.machinery.SourceFileLoader("nodedc_tasker_ui_recovery_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 TaskerUiRecoveryArtifactTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.temporary = tempfile.TemporaryDirectory(prefix="nodedc-tasker-ui-recovery-") + 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-ui-recovery-{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["component"], "tasker") + 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_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), "20260808-002"], + cwd=PLATFORM_ROOT, + env=env, + capture_output=True, + text=True, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("usage: build-tasker-ui-recovery-artifact.mjs", result.stderr) + + +if __name__ == "__main__": + unittest.main(verbosity=2)